feat: particle cloud (no discrete dots) + geo-IP country preselect on login
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s

Two coordinated polish moves the owner asked for.

## 1. Hero particle field — "no white dots, just a glow that follows the mouse and is always in motion"

Previous tuning (uPointSize 2.8, uBaseAlpha 0.6) gave discrete indigo
dots that additively saturated to near-white in dense clusters. The
owner wanted no granular dots visible at all — a continuous indigo
cloud that the cursor pulls toward itself.

Changes:

- **Render fragment**: replaced the anti-aliased disc SDF
  (`smoothstep(0.5, 0.42, d)` — hard edge) with a Gaussian falloff
  (`exp(-d * d * 6.0)` — smooth blob, no edge). Each particle is now
  a soft volume that blends seamlessly with neighbours.

- **Sim fragment**: replaced the outward-gradient ring push with a
  mouse-halo attraction. Particles drift toward an ideal radius
  (~0.20) around the cursor, with exp-bell falloff so they don't
  collapse onto the cursor or feel influenced from across the canvas.
  `ringField()` helper is now unused but kept for future use.

- **JS uniforms**: `uPointSize` 2.8→14 (256-tier) / 3.6→20 (128-tier);
  `uBaseAlpha` 0.6→0.055. Individual particles are below the
  perception threshold for "dot" but 65k of them additively composite
  into a continuous cloud. With the much lower per-particle alpha,
  the cumulative brightness never saturates to white.

- **ParticleField tick loop**: asymmetric ring-active fade — `alpha
  = 0.14` ramping in (fast cursor response), `0.012` decaying out
  (slow glow trail after the pointer moves away). Matches the brief
  "glow longer + attractive to mouse but always in motion".

- **ParticleHero index.tsx**: added an always-on indigo radial
  gradient behind the WebGL canvas, so the hero never reads as
  visually empty between frames — the canvas additively paints the
  dynamic cloud on top. Removed the white-dot stipple from the
  static fallback (it was the most likely source of the "weisse
  punkte" complaint for any visitor on the fallback path).

## 2. SMS login — pre-select country picker from visitor's geo-IP

The country picker on `/login` previously defaulted to `'CH'` for
everyone. Visitors from DE / AT / US / etc. had to manually scroll
to their dial code — small friction but it sits on the highest-stakes
conversion step in the funnel.

- **New API route** `apps/api/src/routes/geo.ts` →
  `GET /v1/geo/country` returns `{ country: 'CH' | 'DE' | … | null }`
  by reading Cloudflare's `CF-IPCountry` header. Public, no auth —
  reading a 2-letter country code from a geo-IP header isn't PII
  under GDPR / DSG. `'XX'` and `'T1'` (CF's "unknown" + Tor) are
  normalised to `null`. Outside CF (dev), header is missing → null.

- **Login page** picks up the result in the existing `useEffect`,
  guards against codes not in our country list, and calls `setCountry`
  to override the `'CH'` default. Stays at `'CH'` if the detection
  fails or the visitor is on a Tor exit. Verified live: the endpoint
  returns `{"country":"DE"}` from CF's German edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-27 13:17:20 +02:00
parent 035e55f00c
commit 6197ee7f5e
17 changed files with 1053 additions and 164 deletions

View File

@ -10,6 +10,7 @@ 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';
@ -81,6 +82,7 @@ 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

@ -0,0 +1,33 @@
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

@ -231,6 +231,22 @@ 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

@ -23,6 +23,10 @@ interface Step {
// scrolling inside the tile. `whitespace-pre-wrap` on the <pre> below // scrolling inside the tile. `whitespace-pre-wrap` on the <pre> below
// lets any remaining over-width tokens (e.g. someone shrinks the // lets any remaining over-width tokens (e.g. someone shrinks the
// viewport to 320 px) wrap instead of overflowing. // viewport to 320 px) wrap instead of overflowing.
// The prompt deliberately mentions the secret NAME (`NOTION_API_KEY`)
// but never its value — the value goes into the encrypted vault in a
// separate, backend-only flow (step 02 below). This wording mirrors
// what the actual product UI accepts.
const STEPS: Step[] = [ const STEPS: Step[] = [
{ {
label: 'prompt.txt', label: 'prompt.txt',
@ -31,11 +35,20 @@ const STEPS: Step[] = [
searches our Notion workspace. searches our Notion workspace.
Tools: search_pages, get_page Tools: search_pages, get_page
Auth: NOTION_API_KEY`, Needs: NOTION_API_KEY`,
},
{
label: 'secrets.vault',
badge: '02 · Secure',
code: `NOTION_API_KEY = secret_••••••3a8f
NOTION_DB_ID = db_f12c
🔒 AES-256, encrypted at rest
never sent to the AI`,
}, },
{ {
label: 'build.log', label: 'build.log',
badge: '02 · Generate', badge: '03 · Generate',
code: `✓ Generating spec (2 tools) code: `✓ Generating spec (2 tools)
Static checks passed Static checks passed
Building image 17.2s Building image 17.2s
@ -44,7 +57,7 @@ Auth: NOTION_API_KEY`,
}, },
{ {
label: 'claude.config.json', label: 'claude.config.json',
badge: '03 · Connect', badge: '04 · Connect',
code: `{ code: `{
"mcpServers": { "mcpServers": {
"notion": { "notion": {

View File

@ -169,16 +169,18 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
const renderUniforms = { const renderUniforms = {
uPositions: { value: rtB.texture }, uPositions: { value: rtB.texture },
// Bigger dots + higher base alpha = more volumetric "calm field" // Huge soft blobs at very low per-particle alpha → no individual
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked // dots are visible, but 65k of them additively composite into a
// stuttery because individual particles were hard to track between // continuous indigo cloud. This matches the brief "no white dots,
// frames). With these values the field has a denser cumulative // just a glow." When dots were 2.8px at 0.6 alpha, dense areas
// glow without any change to the simulation itself. // saturated additive-blended into white; with 14px at 0.05 the
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 }, // saturation point is far above what 65k particles ever sum to,
// 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.6 }, uBaseAlpha: { value: 0.055 },
}; };
const particleMat = new THREE.ShaderMaterial({ const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex, vertexShader: renderVertex,
@ -250,10 +252,15 @@ 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;
// Fade ring in/out when the pointer enters/leaves. // Asymmetric fade: ramp in quickly when the pointer enters, decay
// 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;
simUniforms.uRingActive.value = const cur = simUniforms.uRingActive.value;
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08; const alpha = targetActive > cur ? 0.14 : 0.012;
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,28 +136,41 @@ export function ParticleHero() {
return () => reduce.removeEventListener('change', onReduceChange); return () => reduce.removeEventListener('change', onReduceChange);
}, []); }, []);
// Static fallback: radial indigo glow + faint dotted mask. // Static fallback: pure indigo radial glow, no dot grid. The
// Used both for 'unknown' (pre-hydration) and 'fallback'. // dot-mask was confusing — it read as "stippled white texture"
// 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={{
backgroundImage: [ background:
// Soft indigo glow centered on the hero 'radial-gradient(65% 80% at 50% 45%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
'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',
}} }}
/> />
); );
} }
return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />; // WebGL path: an always-on indigo radial behind the canvas so the
// 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
// --- Ring push: gradient of the ring field, pointing outward --- // --- Mouse halo pull (attraction, not repulsion) ---
float h = 0.003; // Particles are drawn toward a soft halo orbiting the cursor —
float fx0 = ringField(pos - vec2(h, 0.0)); // strongest at ~0.20 distance, fading both closer and farther.
float fx1 = ringField(pos + vec2(h, 0.0)); // Closer-fade prevents the cloud from collapsing onto the cursor;
float fy0 = ringField(pos - vec2(0.0, h)); // farther-fade keeps the influence local. The result is a moving
float fy1 = ringField(pos + vec2(0.0, h)); // bright spot that follows the pointer with a continuous breathing
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h); // ring of indigo around it, rather than the old outward push that
float fieldHere = ringField(pos); // hollowed the cloud where the cursor sat.
// Push along gradient — particles get nudged away from the ring crest. vec2 toMouse = uRingPos - pos;
// Magnitude is scaled by uMotionScale so reduced-motion users get a float distToMouse = length(toMouse) + 0.001;
// softer shove while the ring position still tracks at full fidelity. float halo = exp(-pow(distToMouse - 0.20, 2.0) * 22.0);
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale; vec2 ringVel = (toMouse / distToMouse) * halo * 0.05 * uRingActive * uMotionScale;
// --- Soft containment toward origin if particle escaped --- // --- Soft containment toward origin if particle escaped ---
float r = length(pos); float r = length(pos);
@ -247,14 +247,19 @@ export const renderFragment = /* glsl */ `
varying float vScale; varying float vScale;
void main() { void main() {
// Disc SDF — anti-aliased round dot. // Soft Gaussian blob — no hard disc edge. Combined with the bigger
// 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 = smoothstep(0.5, 0.42, d); float a = exp(-d * d * 6.0);
if (a <= 0.001) discard; if (a <= 0.001) discard;
// Velocity-driven mix: pin to indigo for typical drift, lerp toward // Velocity-driven mix kept, but with the new low base alpha the
// green only on real shoves. The 0.04..0.18 band is roughly where // green tint is barely visible — by design. The cloud is calm.
// 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: 29 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Binary file not shown.

BIN
remotion/audio.mp3 Normal file

Binary file not shown.

View File

@ -6,7 +6,7 @@
"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",
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 210 --image-format jpeg --jpeg-quality 85", "render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 325 --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"

View File

@ -2,25 +2,28 @@ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remo
import { C } from './lib/colors'; import { C } from './lib/colors';
import { clampLerp } from './lib/easings'; import { clampLerp } from './lib/easings';
import { PromptScene } from './scenes/PromptScene'; import { PromptScene } from './scenes/PromptScene';
import { SecretsScene } from './scenes/SecretsScene';
import { BuildScene } from './scenes/BuildScene'; import { BuildScene } from './scenes/BuildScene';
import { LibraryScene } from './scenes/LibraryScene'; import { LibraryScene } from './scenes/LibraryScene';
import { DiscoveryScene } from './scenes/DiscoveryScene'; import { DiscoveryScene } from './scenes/DiscoveryScene';
export const HERO_FPS = 30; export const HERO_FPS = 30;
export const HERO_DURATION_FRAMES = 300; // 10s export const HERO_DURATION_FRAMES = 450; // 15s
// Scene timing. Each beat overlaps the next by ~6 frames so the // Scene timing. Each beat overlaps the next by ~6 frames so the
// transitions crossfade rather than hard-cut. // transitions crossfade rather than hard-cut.
// //
// P1 prompt [ 0, 81) // P1 prompt [ 0, 81) 81f prompt typed
// P2 build [ 75, 171) // P1.5 secrets [ 75, 165) 90f vault panel + arrow fork
// P3 library [165, 246) // P2 build [159, 261) 102f log + server card
// P4 discovery [240, 300) // P3 library [255, 366) 111f morph into template grid
// P4 discovery [360, 450) 90f fork counter + community
export const BEAT = { export const BEAT = {
prompt: { in: 0, out: 81 }, prompt: { in: 0, out: 81 },
build: { in: 75, out: 171 }, secrets: { in: 75, out: 165 },
library: { in: 165, out: 246 }, build: { in: 159, out: 261 },
discovery: { in: 240, out: 300 }, library: { in: 255, out: 366 },
discovery: { in: 360, out: 450 },
} as const; } as const;
const FADE_FRAMES = 12; const FADE_FRAMES = 12;
@ -29,9 +32,9 @@ 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 frame 299 ≈ // Loop-clean: ramp opacity to 0 over the last 12 frames so the final frame
// frame 0 (both essentially-black). Browser <video loop> will jump back // frame 0 (both essentially-black). Browser <video loop> jumps back and
// and the seam is invisible. // the seam is invisible.
const loopFade = interpolate( const loopFade = interpolate(
frame, frame,
[HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1], [HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1],
@ -41,6 +44,7 @@ export function HeroVideo() {
// Crossfade alpha for each scene over its 6-frame entry/exit overlap. // Crossfade alpha for each scene over its 6-frame entry/exit overlap.
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 buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6); const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6); const 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);
@ -56,6 +60,11 @@ export function HeroVideo() {
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} /> <PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
</AbsoluteFill> </AbsoluteFill>
)} )}
{secretsAlpha > 0 && (
<AbsoluteFill style={{ opacity: secretsAlpha }}>
<SecretsScene localFrame={frame - BEAT.secrets.in} fps={fps} />
</AbsoluteFill>
)}
{buildAlpha > 0 && ( {buildAlpha > 0 && (
<AbsoluteFill style={{ opacity: buildAlpha }}> <AbsoluteFill style={{ opacity: buildAlpha }}>
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} /> <BuildScene localFrame={frame - BEAT.build.in} fps={fps} />

View File

@ -2,11 +2,19 @@ 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 2 (frames 75171 global → localFrame 0..96): build log streams in // Phase 2 (frames 159261 global → localFrame 0..102): build log streams in
// line-by-line, then a server card emerges. // line-by-line, then a server card emerges. Two pills slot into the card:
// • `code` arrives from the left (the LLM side)
// • `🔒 NOTION_API_KEY` arrives from the right (the vault side)
// This visualizes the architectural moment: code and credentials are
// injected at runtime from separate paths.
// //
// Log lines stagger ~12 frames apart starting at localFrame 4. // Then a subtle caption appears below the card:
// Server card emerges at localFrame ~64. // "your isolated container · only you can reach it"
//
// Log lines stagger ~10 frames apart starting at localFrame 4.
// Server card emerges at localFrame ~58.
// Slot pills fly in at localFrame ~78.
const LOG_LINES = [ const LOG_LINES = [
{ label: 'Generating spec', detail: '2 tools detected' }, { label: 'Generating spec', detail: '2 tools detected' },
@ -17,7 +25,9 @@ const LOG_LINES = [
const LINE_STAGGER = 10; const LINE_STAGGER = 10;
const LINE_START = 4; const LINE_START = 4;
const CARD_START = 60; const CARD_START = 58;
const SLOTS_START = 78;
const CAPTION_START = 92;
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);
@ -26,7 +36,14 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
// Card emerges late in phase. // Card emerges late in phase.
const cardIn = softSpring(localFrame, fps, CARD_START, 24); const cardIn = softSpring(localFrame, fps, CARD_START, 24);
// Once the card is up, the log panel slides up to make room. // Once the card is up, the log panel slides up to make room.
const panelShift = interpolate(cardIn, [0, 1], [0, -140]); const panelShift = interpolate(cardIn, [0, 1], [0, -160]);
// Slot pills fly in after card is settled.
const codeSlotIn = softSpring(localFrame, fps, SLOTS_START, 18);
const secretSlotIn = softSpring(localFrame, fps, SLOTS_START + 4, 18);
// Caption appears last.
const captionIn = clampLerp(localFrame, CAPTION_START, CAPTION_START + 10);
return ( return (
<div <div
@ -91,8 +108,32 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
<ServerCard <ServerCard
progress={cardIn} progress={cardIn}
localFrame={localFrame} localFrame={localFrame}
codeSlotIn={codeSlotIn}
secretSlotIn={secretSlotIn}
/> />
)} )}
{/* Isolated container caption */}
{captionIn > 0.01 && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 180,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
letterSpacing: 3,
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: captionIn,
transform: `translateY(${interpolate(captionIn, [0, 1], [6, 0])}px)`,
}}
>
your isolated container · only you can reach it
</div>
)}
</div> </div>
); );
} }
@ -162,12 +203,25 @@ function LogLine({
); );
} }
function ServerCard({ progress, localFrame }: { progress: number; localFrame: number }) { function ServerCard({
progress,
localFrame,
codeSlotIn,
secretSlotIn,
}: {
progress: number;
localFrame: number;
codeSlotIn: number;
secretSlotIn: number;
}) {
const scale = interpolate(progress, [0, 1], [0.85, 1]); const scale = interpolate(progress, [0, 1], [0.85, 1]);
const y = interpolate(progress, [0, 1], [40, 180]); const y = interpolate(progress, [0, 1], [40, 180]);
// Pulse the live dot at ~1Hz. // Live dot pulses once the slots have arrived.
const pulsePhase = (localFrame - 60) / 30; const liveOn = secretSlotIn > 0.6;
const livePulse = 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2); const pulsePhase = (localFrame - (SLOTS_START + 18)) / 30;
const livePulse = liveOn
? 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2)
: 0;
return ( return (
<div <div
@ -181,12 +235,13 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
> >
<div <div
style={{ style={{
width: 480, width: 540,
backgroundColor: C.bgElevated, backgroundColor: C.bgElevated,
border: `1.5px solid ${C.accent}`, border: `1.5px solid ${C.accent}`,
borderRadius: 16, borderRadius: 16,
padding: '24px 28px', padding: '24px 28px',
boxShadow: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`, boxShadow: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
position: 'relative',
}} }}
> >
{/* Header row: title + live dot */} {/* Header row: title + live dot */}
@ -215,9 +270,10 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
gap: 8, gap: 8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12, fontSize: 12,
color: C.success, color: liveOn ? C.success : C.fgSubtle,
letterSpacing: 1.5, letterSpacing: 1.5,
textTransform: 'uppercase', textTransform: 'uppercase',
opacity: 0.4 + 0.6 * Math.min(1, secretSlotIn),
}} }}
> >
<div <div
@ -225,15 +281,41 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: C.success, backgroundColor: liveOn ? C.success : C.fgSubtle,
boxShadow: `0 0 ${10 * livePulse}px ${C.success}`, boxShadow: liveOn ? `0 0 ${10 * livePulse}px ${C.success}` : 'none',
opacity: 0.5 + 0.5 * livePulse, opacity: liveOn ? 0.5 + 0.5 * livePulse : 0.5,
}} }}
/> />
live {liveOn ? 'live' : 'starting'}
</div> </div>
</div> </div>
{/* Slot pills row */}
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 18,
position: 'relative',
minHeight: 38,
}}
>
{/* code slot — arrives from the left */}
<SlotPill
label="code"
kind="code"
in={codeSlotIn}
fromX={-200}
/>
{/* secret slot — arrives from the right */}
<SlotPill
label="NOTION_API_KEY"
kind="secret"
in={secretSlotIn}
fromX={200}
/>
</div>
{/* Tool rows */} {/* Tool rows */}
<ToolRow name="search_pages" desc="full-text query" /> <ToolRow name="search_pages" desc="full-text query" />
<div style={{ height: 8 }} /> <div style={{ height: 8 }} />
@ -243,6 +325,71 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
); );
} }
function SlotPill({
label,
kind,
in: progress,
fromX,
}: {
label: string;
kind: 'code' | 'secret';
in: number;
fromX: number;
}) {
const x = interpolate(progress, [0, 1], [fromX, 0]);
const opacity = clampLerp(progress, 0.05, 0.6);
const isSecret = kind === 'secret';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 14px',
backgroundColor: isSecret ? 'rgba(99,102,241,0.10)' : C.bgSubtle,
border: `1px solid ${isSecret ? C.accentDim : C.borderStrong}`,
borderRadius: 999,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: isSecret ? C.accent : C.fg,
letterSpacing: 0.5,
opacity,
transform: `translateX(${x}px)`,
}}
>
{isSecret ? (
<MiniLockIcon />
) : (
<span
style={{
display: 'inline-flex',
width: 14,
height: 14,
alignItems: 'center',
justifyContent: 'center',
color: C.fgMuted,
fontSize: 13,
}}
>
{'<>'}
</span>
)}
<span>{label}</span>
</div>
);
}
function MiniLockIcon() {
return (
<svg width="13" height="13" 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.4} 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.4} />
<circle cx="8" cy="11" r="1" fill={C.accent} />
</svg>
);
}
function ToolRow({ name, desc }: { name: string; desc: string }) { function ToolRow({ name, desc }: { name: string; desc: string }) {
return ( return (
<div <div

View File

@ -2,13 +2,13 @@ import { interpolate } from 'remotion';
import { C } from '../lib/colors'; import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp, rand } from '../lib/easings'; import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
// Phase 4 (frames 240300 global → localFrame 0..60): the user's card is // Phase 4 (frames 360450 global → localFrame 0..90): the user's card is
// the hero. A fork count ticks 0 → 247 with micro-particles. A subtitle // the hero. Fork count ticks 0 → 247 with micro-particles. Subtitle pops:
// pops in: "1,200+ developers building". // "1,200+ developers building".
// //
// We keep the same grid layout as Phase 3 (so the transition is a // New beat in v5: as forks accumulate, a few non-hero cards get a small
// crossfade in place) but zoom slightly toward the hero card and emphasize // "NEW SECRET" pulse near their slot, implying each forker plugs in their
// it. The fork counter sits on the hero card. // own credential. Subtle, not loud.
const GRID_COLS = 3; const GRID_COLS = 3;
const GRID_ROWS = 2; const GRID_ROWS = 2;
@ -21,22 +21,26 @@ const CARDS = [
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero { name: 'notion-search', toolCount: 2, highlighted: true }, // hero
{ name: 'slack-digest', toolCount: 4, highlighted: false }, { name: 'slack-digest', toolCount: 4, highlighted: false },
{ name: 'linear-tasks', toolCount: 5, highlighted: false }, { name: 'linear-tasks', toolCount: 5, highlighted: false },
{ name: 'gmail-triage', toolCount: 3, highlighted: false }, // demote — only the user's is the star { name: 'gmail-triage', toolCount: 3, highlighted: false },
{ name: 'jira-sprint', toolCount: 6, highlighted: false }, { name: 'jira-sprint', toolCount: 6, highlighted: false },
]; ];
const TARGET_FORKS = 247; const TARGET_FORKS = 247;
// Frames at which each non-hero card flashes "NEW SECRET" (staggered).
const NEW_SECRET_TIMINGS: Record<number, number> = {
0: 22, // github-issues
3: 36, // linear-tasks
5: 52, // jira-sprint
};
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// Slow zoom toward the hero card.
const zoom = clampLerp(localFrame, 0, 50); const zoom = clampLerp(localFrame, 0, 50);
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]); const scale = interpolate(zoom, [0, 1], [1.0, 1.08]);
// Fork count ticks 0 → 247 over ~36 frames starting at localFrame 6.
const tickProgress = clampLerp(localFrame, 6, 42); const tickProgress = clampLerp(localFrame, 6, 42);
const forkCount = Math.floor(tickProgress * TARGET_FORKS); const forkCount = Math.floor(tickProgress * TARGET_FORKS);
// Subtitle "1,200+ developers building" pops at localFrame ~28.
const subIn = softSpring(localFrame, fps, 28, 18); const subIn = softSpring(localFrame, fps, 28, 18);
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP; const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
@ -44,13 +48,11 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
const gridLeft = (1920 - gridW) / 2; const gridLeft = (1920 - gridW) / 2;
const gridTop = (1080 - gridH) / 2 + 30; const gridTop = (1080 - gridH) / 2 + 30;
// Hero card position (index 1: col=1, row=0)
const heroX = gridLeft + 1 * (CARD_W + GAP); const heroX = gridLeft + 1 * (CARD_W + GAP);
const heroY = gridTop + 0 * (CARD_H + GAP); const heroY = gridTop + 0 * (CARD_H + GAP);
return ( return (
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
{/* Section caption (matches Phase 3 caption position) */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -68,7 +70,6 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
template library template library
</div> </div>
{/* Zoom group — scales everything around the hero card center */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -84,9 +85,15 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
const y = gridTop + row * (CARD_H + GAP); const y = gridTop + row * (CARD_H + GAP);
const isHero = i === 1; const isHero = i === 1;
// Non-hero cards desaturate slightly as the camera focuses.
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 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 ( return (
<div <div
key={i} key={i}
@ -99,12 +106,16 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
opacity: dim, opacity: dim,
}} }}
> >
<TemplateCardInner card={card} isHero={isHero} forkCount={isHero ? forkCount : null} /> <TemplateCardInner
card={card}
isHero={isHero}
forkCount={isHero ? forkCount : null}
newSecretPulse={newSecretPulse}
/>
</div> </div>
); );
})} })}
{/* Fork-tick micro-particles around the hero card */}
<ForkParticles <ForkParticles
localFrame={localFrame} localFrame={localFrame}
x={heroX + CARD_W - 22} x={heroX + CARD_W - 22}
@ -112,7 +123,6 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
/> />
</div> </div>
{/* Subtitle "1,200+ developers building" — bottom of frame */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -138,10 +148,12 @@ function TemplateCardInner({
card, card,
isHero, isHero,
forkCount, forkCount,
newSecretPulse,
}: { }: {
card: typeof CARDS[number]; card: typeof CARDS[number];
isHero: boolean; isHero: boolean;
forkCount: number | null; forkCount: number | null;
newSecretPulse: number;
}) { }) {
const border = isHero ? C.accent : C.border; const border = isHero ? C.accent : C.border;
const shadow = isHero const shadow = isHero
@ -186,7 +198,7 @@ function TemplateCardInner({
/> />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, position: 'relative' }}>
{Array.from({ length: 3 }).map((_, j) => ( {Array.from({ length: 3 }).map((_, j) => (
<div <div
key={j} key={j}
@ -199,6 +211,35 @@ function TemplateCardInner({
}} }}
/> />
))} ))}
{/* NEW SECRET pulse — small chip near the tool bars */}
{newSecretPulse > 0.02 && (
<div
style={{
position: 'absolute',
right: -4,
top: -8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 9,
letterSpacing: 1.5,
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 />
new secret
</div>
)}
</div> </div>
<div <div
@ -235,6 +276,15 @@ function TemplateCardInner({
); );
} }
function MiniLockDot() {
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() { 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">
@ -256,8 +306,6 @@ function ForkParticles({
x: number; x: number;
y: number; y: number;
}) { }) {
// Emit a particle every ~3 frames during the tick window (frames 6-42).
// Each particle lives ~14 frames, drifting up and fading out.
const PARTICLES = 12; const PARTICLES = 12;
const EMIT_START = 6; const EMIT_START = 6;
const EMIT_INTERVAL = 3; const EMIT_INTERVAL = 3;

View File

@ -2,12 +2,14 @@ import { interpolate } from 'remotion';
import { C } from '../lib/colors'; import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp, rand } from '../lib/easings'; import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
// Phase 3 (frames 165246 global → localFrame 0..81): the server card pulls // Phase 3 (frames 255366 global → localFrame 0..111): the server card pulls
// back / scales down and multiplies into a 3×2 grid of template cards. // back / scales down and multiplies into a 3×2 grid of template cards.
// //
// We "zoom out" by starting with a single big card centered (matches the // New beat in v5: as the hero card joins the grid, the lock icon next to its
// position where Phase 2 left it) and animating it to grid-slot (1,0) // NOTION_API_KEY slot DETACHES and fades upward — the slot then reads
// while five sibling cards fade in around it. // `NOTION_API_KEY = ?`, communicating that the published template carries
// the recipe but not the secret value. A caption below the grid reinforces:
// "templates carry code, not credentials."
const GRID_COLS = 3; const GRID_COLS = 3;
const GRID_ROWS = 2; const GRID_ROWS = 2;
@ -15,9 +17,6 @@ const CARD_W = 340;
const CARD_H = 180; const CARD_H = 180;
const GAP = 28; const GAP = 28;
// Card metadata. The "hero" card (index 1, top-center) corresponds to the
// server the user just built. It stays highlighted with the indigo border.
// Index 0 and 4 are also highlighted to suggest "popular templates".
const CARDS = [ const CARDS = [
{ name: 'github-issues', toolCount: 3, highlighted: false }, { name: 'github-issues', toolCount: 3, highlighted: false },
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero { name: 'notion-search', toolCount: 2, highlighted: true }, // hero
@ -27,23 +26,26 @@ const CARDS = [
{ name: 'jira-sprint', toolCount: 6, highlighted: false }, { name: 'jira-sprint', toolCount: 6, highlighted: false },
]; ];
// Timing inside Phase 3
const LOCK_DETACH_START = 36; // when hero is settled into the grid
const LOCK_DETACH_END = 56;
const SUB_CAPTION_START = 56;
const SUB_CAPTION_END = 70;
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// The caption above the grid.
const captionIn = clampLerp(localFrame, 20, 36); const captionIn = clampLerp(localFrame, 20, 36);
// Hero card animation — starts at its previous-scene location (centered,
// big) and morphs to grid slot 1.
const heroProgress = softSpring(localFrame, fps, 0, 30); const heroProgress = softSpring(localFrame, fps, 0, 30);
const lockDetach = clampLerp(localFrame, LOCK_DETACH_START, LOCK_DETACH_END);
const subCaptionIn = clampLerp(localFrame, SUB_CAPTION_START, SUB_CAPTION_END);
// The grid is centered. Compute slot positions.
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;
const gridLeft = (1920 - gridW) / 2; const gridLeft = (1920 - gridW) / 2;
const gridTop = (1080 - gridH) / 2 + 30; // nudge down a bit for the caption const gridTop = (1080 - gridH) / 2 + 30;
return ( return (
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
{/* Caption */} {/* Section caption */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -68,29 +70,36 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
const row = Math.floor(i / GRID_COLS); const row = Math.floor(i / GRID_COLS);
const targetX = gridLeft + col * (CARD_W + GAP); const targetX = gridLeft + col * (CARD_W + GAP);
const targetY = gridTop + row * (CARD_H + GAP); const targetY = gridTop + row * (CARD_H + GAP);
const isHero = i === 1; const isHero = i === 1;
if (isHero) { if (isHero) {
// Hero card morphs from "big centered" to its grid slot. const startW = 540;
const startW = 480; const startH = 240;
const startH = 220;
const startX = (1920 - startW) / 2; const startX = (1920 - startW) / 2;
const startY = (1080 - startH) / 2 + 90; // matches BuildScene y=180 offset const startY = (1080 - startH) / 2 + 90;
const w = interpolate(heroProgress, [0, 1], [startW, CARD_W]); const w = interpolate(heroProgress, [0, 1], [startW, CARD_W]);
const h = interpolate(heroProgress, [0, 1], [startH, CARD_H]); const h = interpolate(heroProgress, [0, 1], [startH, CARD_H]);
const x = interpolate(heroProgress, [0, 1], [startX, targetX]); const x = interpolate(heroProgress, [0, 1], [startX, targetX]);
const y = interpolate(heroProgress, [0, 1], [startY, targetY]); const y = interpolate(heroProgress, [0, 1], [startY, targetY]);
return ( return (
<TemplateCard <div
key={i} key={i}
card={card} style={{
x={x} position: 'absolute',
y={y} left: x,
w={w} top: y,
h={h} width: w,
opacity={1} height: h,
/> }}
>
<TemplateCardInner
card={card}
w={w}
h={h}
lockDetach={lockDetach}
isHero
/>
</div>
); );
} }
@ -98,9 +107,7 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
const delay = 16 + i * 3; const delay = 16 + i * 3;
const sib = springIn(localFrame, fps, delay); const sib = springIn(localFrame, fps, delay);
const opacity = clampLerp(localFrame, delay, delay + 14); const opacity = clampLerp(localFrame, delay, delay + 14);
// Slight scale-in from 0.92.
const scale = interpolate(sib, [0, 1], [0.92, 1]); const scale = interpolate(sib, [0, 1], [0.92, 1]);
// Use deterministic randomness for tiny drift-in offsets.
const drift = (rand(i + 7) - 0.5) * 14; const drift = (rand(i + 7) - 0.5) * 14;
return ( return (
<div <div
@ -116,41 +123,30 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
transformOrigin: 'center center', transformOrigin: 'center center',
}} }}
> >
<TemplateCardInner card={card} w={CARD_W} h={CARD_H} /> <TemplateCardInner card={card} w={CARD_W} h={CARD_H} lockDetach={0} isHero={false} />
</div> </div>
); );
})} })}
</div>
);
}
function TemplateCard({ {/* Sub-caption: "templates carry code, not credentials" */}
card, <div
x, style={{
y, position: 'absolute',
w, bottom: 110,
h, left: 0,
opacity, right: 0,
}: { textAlign: 'center',
card: typeof CARDS[number]; fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
x: number; fontSize: 14,
y: number; letterSpacing: 3,
w: number; textTransform: 'uppercase',
h: number; color: C.fgSubtle,
opacity: number; opacity: subCaptionIn,
}) { transform: `translateY(${interpolate(subCaptionIn, [0, 1], [8, 0])}px)`,
return ( }}
<div >
style={{ templates carry <span style={{ color: C.fg }}>code</span>, not credentials
position: 'absolute', </div>
left: x,
top: y,
width: w,
height: h,
opacity,
}}
>
<TemplateCardInner card={card} w={w} h={h} />
</div> </div>
); );
} }
@ -159,20 +155,29 @@ function TemplateCardInner({
card, card,
w, w,
h, h,
lockDetach,
isHero,
}: { }: {
card: typeof CARDS[number]; card: { name: string; toolCount: number; highlighted: boolean };
w: number; w: number;
h: number; h: number;
lockDetach: number;
isHero: boolean;
}) { }) {
const border = card.highlighted ? C.accent : C.border; const border = card.highlighted ? C.accent : C.border;
const shadow = card.highlighted const shadow = card.highlighted
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)` ? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
: `0 8px 24px rgba(0,0,0,0.35)`; : `0 8px 24px rgba(0,0,0,0.35)`;
// Pad inside scales mildly with card size (since hero is bigger).
const padX = Math.max(18, w * 0.06); const padX = Math.max(18, w * 0.06);
const padY = Math.max(16, h * 0.08); const padY = Math.max(16, h * 0.08);
// Show slot row only on hero card — communicates the empty-key story.
// The lock-detach animation: lock lifts up and fades, then "?" appears.
const lockY = -lockDetach * 30;
const lockOpacity = 1 - lockDetach;
const questionOpacity = clampLerp(lockDetach, 0.5, 1);
return ( return (
<div <div
style={{ style={{
@ -186,6 +191,8 @@ function TemplateCardInner({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
position: 'relative',
overflow: 'visible',
}} }}
> >
{/* Top: title + indicator dot */} {/* Top: title + indicator dot */}
@ -212,21 +219,70 @@ function TemplateCardInner({
/> />
</div> </div>
{/* Middle: faux tool bars */} {/* Middle: either tool bars (default) OR the secret slot pill (hero) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> {isHero ? (
{Array.from({ length: Math.min(3, card.toolCount) }).map((_, j) => ( <div
<div style={{
key={j} display: 'flex',
alignItems: 'center',
gap: 8,
padding: '5px 10px',
backgroundColor: 'rgba(99,102,241,0.08)',
border: `1px solid ${C.border}`,
borderRadius: 999,
alignSelf: 'flex-start',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: w > 380 ? 12 : 10,
color: C.fgMuted,
position: 'relative',
}}
>
{/* Lock icon — detaches upward over the lockDetach window */}
<span
style={{ style={{
height: 7, display: 'inline-flex',
borderRadius: 3.5, transform: `translateY(${lockY}px)`,
backgroundColor: C.bgSubtle, opacity: lockOpacity,
width: `${[88, 64, 76][j]}%`,
border: `1px solid ${C.border}`,
}} }}
/> >
))} <MiniLockIcon size={w > 380 ? 11 : 9} />
</div> </span>
{/* Question-mark placeholder appears as lock leaves */}
<span
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: C.fgSubtle,
opacity: questionOpacity,
fontSize: w > 380 ? 11 : 9,
}}
>
?
</span>
<span style={{ marginLeft: 4 }}>NOTION_API_KEY</span>
<span style={{ color: C.fgSubtle }}>=</span>
<span style={{ color: lockDetach > 0.5 ? C.fgSubtle : C.accent }}>
{lockDetach > 0.5 ? '?' : '•••'}
</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Array.from({ length: Math.min(3, card.toolCount) }).map((_, j) => (
<div
key={j}
style={{
height: 7,
borderRadius: 3.5,
backgroundColor: C.bgSubtle,
width: `${[88, 64, 76][j]}%`,
border: `1px solid ${C.border}`,
}}
/>
))}
</div>
)}
{/* Bottom: tool count + status */} {/* Bottom: tool count + status */}
<div <div
@ -248,3 +304,13 @@ function TemplateCardInner({
</div> </div>
); );
} }
function MiniLockIcon({ size = 11 }: { size?: number }) {
return (
<svg width={size} height={size} 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.4} 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.4} />
<circle cx="8" cy="11" r="1" fill={C.accent} />
</svg>
);
}

View File

@ -0,0 +1,530 @@
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp } from '../lib/easings';
// Phase 1.5 (frames 75165 global → localFrame 0..90): the prompt panel from
// Phase 1 slides left and a second, vault-styled panel appears to its right.
// The user's API key is masked (`secret_•••••••••••••••••••3a8f`) and a
// "stored AES-256 · never sent to the AI" sub-label calms the trust nerves.
//
// Toward the end of the phase, two arrows fork apart:
// • prompt → LLM brain icon (active, glowing) → continues right/down
// • secrets → vault icon (active) → cross-marked through the LLM,
// 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 KEY_LABEL = 'NOTION_API_KEY';
const KEY_VISIBLE = 'secret_'; // typed portion before masking
const KEY_BULLETS = '•••••••••••••••••••';
const KEY_TAIL = '3a8f';
export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// 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.
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]);
// Secrets panel: spring in from below-right.
const vaultIn = springIn(localFrame, fps, 8);
const vaultOpacity = clampLerp(localFrame, 8, 24);
const vaultY = interpolate(vaultIn, [0, 1], [40, 0]);
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 maskProgress = clampLerp(localFrame, MASK_START, MASK_END);
const tailProgress = clampLerp(localFrame, TAIL_START, TAIL_END);
const lockOpen = localFrame < LOCK_SNAP_AT;
const typedChars = Math.floor(typingProgress * KEY_VISIBLE.length);
const typedShown = KEY_VISIBLE.slice(0, typedChars);
const bulletChars = Math.floor(maskProgress * KEY_BULLETS.length);
const bulletsShown = KEY_BULLETS.slice(0, bulletChars);
const tailChars = Math.floor(tailProgress * KEY_TAIL.length);
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 xMarkPop = springIn(localFrame, fps, ARROW_START + 8);
// Phase exit drift — last 6 frames, lift up subtly (parent already crossfades).
const exitProgress = clampLerp(localFrame, 84, 90);
const exitY = interpolate(exitProgress, [0, 1], [0, -24]);
return (
<div
style={{
position: 'absolute',
inset: 0,
transform: `translateY(${exitY}px)`,
}}
>
{/* Prompt mini-panel (left) */}
<PromptMiniPanel x={promptX} promptText={PROMPT_TEXT} />
{/* Secrets vault panel (right) */}
<div
style={{
position: 'absolute',
left: 1040,
top: (1080 - 380) / 2,
width: 820,
opacity: vaultOpacity,
transform: `translateY(${vaultY}px) scale(${vaultScale})`,
transformOrigin: 'center center',
}}
>
<VaultPanel
lockOpen={lockOpen}
fps={fps}
localFrame={localFrame}
keyLabel={KEY_LABEL}
keyValue={`${typedShown}${bulletsShown}${tailShown}`}
plaintextOpacity={plaintextOpacity}
/>
</div>
{/* Arrow fork SVG overlay drawn full-frame, but only the arrow paths
are visible; the rest is transparent. */}
<ArrowFork progress={arrowProgress} xPop={xMarkPop} />
</div>
);
}
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 (
<div
style={{
position: 'absolute',
left: x,
top: (1080 - 220) / 2,
width: 760,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
letterSpacing: 4,
textTransform: 'uppercase',
color: C.fgSubtle,
marginBottom: 18,
}}
>
prompt
</div>
<div
style={{
width: '100%',
minHeight: 92,
borderRadius: 14,
backgroundColor: C.bgElevated,
border: `1px solid ${C.borderStrong}`,
boxShadow: `0 16px 50px rgba(0,0,0,0.5)`,
padding: '22px 28px',
display: 'flex',
alignItems: 'center',
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 22,
color: C.accent,
marginRight: 16,
lineHeight: 1,
}}
>
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 20,
color: C.fg,
letterSpacing: 0.2,
lineHeight: 1.4,
}}
>
{promptText}
</div>
</div>
{/* Sublabel — what gets sent here */}
<div
style={{
marginTop: 16,
display: 'flex',
alignItems: 'center',
gap: 10,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
color: C.fgSubtle,
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
<BrainIcon size={14} />
<span>sent to LLM</span>
</div>
</div>
);
}
function VaultPanel({
lockOpen,
fps,
localFrame,
keyLabel,
keyValue,
}: {
lockOpen: boolean;
fps: number;
localFrame: number;
keyLabel: 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 (
<div
style={{
width: '100%',
backgroundColor: C.bgElevated,
border: `2px solid ${C.accentDim}`,
borderRadius: 16,
padding: '26px 32px 28px 32px',
boxShadow: `0 0 0 4px rgba(79, 70, 229, 0.15), 0 24px 70px rgba(0,0,0,0.55)`,
position: 'relative',
}}
>
{/* Top row: lock icon + label + backend-only chip */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 22,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
letterSpacing: 3,
textTransform: 'uppercase',
color: C.fgMuted,
}}
>
<LockIcon open={lockOpen} fps={fps} localFrame={localFrame} />
<span>secrets · encrypted vault</span>
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: C.accent,
border: `1px solid ${C.accentDim}`,
padding: '4px 10px',
borderRadius: 999,
backgroundColor: 'rgba(99, 102, 241, 0.08)',
}}
>
backend-only
</div>
</div>
{/* Key field */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fgSubtle,
letterSpacing: 1.2,
}}
>
{keyLabel}
</div>
<div
style={{
width: '100%',
height: 70,
borderRadius: 10,
backgroundColor: C.bgSubtle,
border: `1px solid ${C.border}`,
display: 'flex',
alignItems: 'center',
padding: '0 22px',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 22,
color: C.fg,
letterSpacing: 1,
overflow: 'hidden',
}}
>
<span>{keyValue}</span>
</div>
</div>
{/* Sub-text */}
<div
style={{
marginTop: 18,
display: 'flex',
alignItems: 'center',
gap: 10,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fgMuted,
letterSpacing: 0.5,
}}
>
<ShieldIcon />
<span>
AES-256 encrypted ·{' '}
<span style={{ color: C.fg, fontWeight: 600 }}>never sent to the AI</span>
</span>
</div>
</div>
);
}
function LockIcon({
open,
fps,
localFrame,
}: {
open: boolean;
fps: number;
localFrame: number;
}) {
// Lock shackle pops down a hair when the lock snaps closed.
const snapAt = 66;
const snap = springIn(localFrame, fps, snapAt);
const shackleY = open ? -3 : interpolate(snap, [0, 1], [-3, 0]);
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
{/* Shackle */}
<path
d={
open
? `M 7 12 L 7 8 A 5 5 0 0 1 17 8 L 17 9`
: `M 7 12 L 7 8 A 5 5 0 0 1 17 8 L 17 12`
}
stroke={C.accent}
strokeWidth={2}
strokeLinecap="round"
fill="none"
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)" />
{/* Keyhole */}
<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} />
</svg>
);
}
function ShieldIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M 8 1.5 L 13.5 3.5 L 13.5 8 Q 13.5 12 8 14.5 Q 2.5 12 2.5 8 L 2.5 3.5 Z"
stroke={C.accent}
strokeWidth={1.4}
fill="rgba(99,102,241,0.10)"
strokeLinejoin="round"
/>
<path d="M 5.5 8 L 7.2 9.6 L 10.5 6.4" stroke={C.accent} strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function BrainIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5" stroke={C.fgMuted} strokeWidth={1.2} fill="rgba(161,161,170,0.08)" />
<path d="M 5.5 8 Q 8 5.5 10.5 8 Q 8 10.5 5.5 8 Z" stroke={C.fgMuted} strokeWidth={1.2} fill="none" />
<circle cx="8" cy="8" r="1" fill={C.fgMuted} />
</svg>
);
}
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 VAULT_OUT = { x: 1040, y: 520 };
const PROMPT_OUT = { x: 820, y: 540 };
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}`;
// 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}`;
// 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}`;
// Approximate lengths for reveal.
const lenP = 80 + 240 + 80 + 80; // ~480 (a generous over-estimate is fine)
const lenPCont = 80 + 600 + 70;
const lenS = 140 + 350 + 60 + 60;
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<defs>
<marker id="arrowAccent" 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>
<marker id="arrowMuted" 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.fgSubtle} />
</marker>
</defs>
{/* Prompt arrow → LLM */}
<path
d={pathP}
stroke={C.accent}
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={lenP}
strokeDashoffset={(1 - progress) * lenP}
opacity={progress > 0 ? 1 : 0}
/>
{/* LLM icon — visible once first arrow reaches it (~progress 0.5) */}
{progress > 0.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} />
{/* Brain-ish glyph */}
<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" />
<circle cx="28" cy="28" r="2.5" fill={C.accent} />
<text x="28" y="74" textAnchor="middle" fill={C.fgMuted} fontSize="13" fontFamily="ui-monospace, SF Mono, Menlo, monospace" letterSpacing="2">
LLM
</text>
</g>
)}
{/* Prompt continuation (after LLM) */}
<path
d={pathPCont}
stroke={C.accent}
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={lenPCont}
strokeDashoffset={(1 - Math.max(0, (progress - 0.5) * 2)) * lenPCont}
opacity={progress > 0.5 ? 1 : 0}
markerEnd="url(#arrowAccent)"
/>
{/* Secrets arrow — dashed, bypasses LLM */}
<path
d={pathS}
stroke={C.fgSubtle}
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="8 6"
opacity={progress > 0 ? interpolate(progress, [0, 0.3, 1], [0, 0.5, 0.9]) : 0}
markerEnd="url(#arrowMuted)"
/>
{/* X-mark on the LLM box for the secrets path — pops in with spring */}
{xPop > 0.05 && (
<g
transform={`translate(${LLM.x + 28} ${LLM.y + 24}) scale(${0.5 + xPop * 0.5})`}
opacity={xPop}
>
<circle r="16" fill={C.bg} stroke="#dc2626" strokeWidth={2} />
<path d="M -6 -6 L 6 6 M 6 -6 L -6 6" stroke="#dc2626" strokeWidth={2.5} strokeLinecap="round" />
</g>
)}
{/* Vault icon label near the secrets arrow start */}
{progress > 0.1 && (
<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} />
{/* 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" />
<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} />
<text x="28" y="62" textAnchor="middle" fill={C.fgMuted} fontSize="12" fontFamily="ui-monospace, SF Mono, Menlo, monospace" letterSpacing="2">
vault
</text>
</g>
)}
</svg>
);
}