feat: particle cloud (no discrete dots) + geo-IP country preselect on login
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
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:
parent
035e55f00c
commit
6197ee7f5e
@ -10,6 +10,7 @@ import { accountRoutes } from './routes/account.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { billingRoutes } from './routes/billing.js';
|
||||
import { geoRoutes } from './routes/geo.js';
|
||||
import { oauthRoutes } from './routes/oauth.js';
|
||||
import { serverRoutes } from './routes/servers.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
@ -81,6 +82,7 @@ await app.register(templateRoutes);
|
||||
await app.register(billingRoutes);
|
||||
await app.register(supportRoutes);
|
||||
await app.register(accountRoutes);
|
||||
await app.register(geoRoutes);
|
||||
|
||||
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
|
||||
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
|
||||
|
||||
33
apps/api/src/routes/geo.ts
Normal file
33
apps/api/src/routes/geo.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@ -231,6 +231,22 @@ export default function LoginPage() {
|
||||
else if (p.sms) setMethod('phone');
|
||||
})
|
||||
.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');
|
||||
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
||||
}, []);
|
||||
|
||||
@ -23,6 +23,10 @@ interface Step {
|
||||
// scrolling inside the tile. `whitespace-pre-wrap` on the <pre> below
|
||||
// lets any remaining over-width tokens (e.g. someone shrinks the
|
||||
// viewport to 320 px) wrap instead of overflowing.
|
||||
// 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[] = [
|
||||
{
|
||||
label: 'prompt.txt',
|
||||
@ -31,11 +35,20 @@ const STEPS: Step[] = [
|
||||
searches our Notion workspace.
|
||||
|
||||
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',
|
||||
badge: '02 · Generate',
|
||||
badge: '03 · Generate',
|
||||
code: `✓ Generating spec (2 tools)
|
||||
✓ Static checks passed
|
||||
✓ Building image 17.2s
|
||||
@ -44,7 +57,7 @@ Auth: NOTION_API_KEY`,
|
||||
},
|
||||
{
|
||||
label: 'claude.config.json',
|
||||
badge: '03 · Connect',
|
||||
badge: '04 · Connect',
|
||||
code: `{
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
|
||||
@ -169,16 +169,18 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
|
||||
|
||||
const renderUniforms = {
|
||||
uPositions: { value: rtB.texture },
|
||||
// Bigger dots + higher base alpha = more volumetric "calm field"
|
||||
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked
|
||||
// stuttery because individual particles were hard to track between
|
||||
// frames). With these values the field has a denser cumulative
|
||||
// glow without any change to the simulation itself.
|
||||
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
|
||||
// Huge soft blobs at very low per-particle alpha → no individual
|
||||
// dots are visible, but 65k of them additively composite into a
|
||||
// continuous indigo cloud. This matches the brief "no white dots,
|
||||
// just a glow." When dots were 2.8px at 0.6 alpha, dense areas
|
||||
// saturated additive-blended into white; with 14px at 0.05 the
|
||||
// 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 },
|
||||
uColorCalm: { value: colorCalm },
|
||||
uColorHot: { value: colorHot },
|
||||
uBaseAlpha: { value: 0.6 },
|
||||
uBaseAlpha: { value: 0.055 },
|
||||
};
|
||||
const particleMat = new THREE.ShaderMaterial({
|
||||
vertexShader: renderVertex,
|
||||
@ -250,10 +252,15 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
|
||||
smoothed.x = smoothed.x * 0.85 + target.x * 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;
|
||||
simUniforms.uRingActive.value =
|
||||
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
|
||||
const cur = simUniforms.uRingActive.value;
|
||||
const alpha = targetActive > cur ? 0.14 : 0.012;
|
||||
simUniforms.uRingActive.value = cur * (1 - alpha) + targetActive * alpha;
|
||||
|
||||
simUniforms.uTime.value = t;
|
||||
simUniforms.uDelta.value = delta;
|
||||
|
||||
@ -136,28 +136,41 @@ export function ParticleHero() {
|
||||
return () => reduce.removeEventListener('change', onReduceChange);
|
||||
}, []);
|
||||
|
||||
// Static fallback: radial indigo glow + faint dotted mask.
|
||||
// Used both for 'unknown' (pre-hydration) and 'fallback'.
|
||||
// Static fallback: pure indigo radial glow, no dot grid. The
|
||||
// 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') {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
// 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',
|
||||
background:
|
||||
'radial-gradient(65% 80% at 50% 45%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -162,18 +162,18 @@ export const simFragment = /* glsl */ `
|
||||
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.045) + 53.7);
|
||||
vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation
|
||||
|
||||
// --- Ring push: gradient of the ring field, pointing outward ---
|
||||
float h = 0.003;
|
||||
float fx0 = ringField(pos - vec2(h, 0.0));
|
||||
float fx1 = ringField(pos + vec2(h, 0.0));
|
||||
float fy0 = ringField(pos - vec2(0.0, h));
|
||||
float fy1 = ringField(pos + vec2(0.0, h));
|
||||
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
|
||||
float fieldHere = ringField(pos);
|
||||
// Push along gradient — particles get nudged away from the ring crest.
|
||||
// Magnitude is scaled by uMotionScale so reduced-motion users get a
|
||||
// softer shove while the ring position still tracks at full fidelity.
|
||||
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
|
||||
// --- Mouse halo pull (attraction, not repulsion) ---
|
||||
// Particles are drawn toward a soft halo orbiting the cursor —
|
||||
// strongest at ~0.20 distance, fading both closer and farther.
|
||||
// Closer-fade prevents the cloud from collapsing onto the cursor;
|
||||
// farther-fade keeps the influence local. The result is a moving
|
||||
// bright spot that follows the pointer with a continuous breathing
|
||||
// ring of indigo around it, rather than the old outward push that
|
||||
// hollowed the cloud where the cursor sat.
|
||||
vec2 toMouse = uRingPos - pos;
|
||||
float distToMouse = length(toMouse) + 0.001;
|
||||
float halo = exp(-pow(distToMouse - 0.20, 2.0) * 22.0);
|
||||
vec2 ringVel = (toMouse / distToMouse) * halo * 0.05 * uRingActive * uMotionScale;
|
||||
|
||||
// --- Soft containment toward origin if particle escaped ---
|
||||
float r = length(pos);
|
||||
@ -247,14 +247,19 @@ export const renderFragment = /* glsl */ `
|
||||
varying float vScale;
|
||||
|
||||
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 a = smoothstep(0.5, 0.42, d);
|
||||
float a = exp(-d * d * 6.0);
|
||||
if (a <= 0.001) discard;
|
||||
|
||||
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
|
||||
// green only on real shoves. The 0.04..0.18 band is roughly where
|
||||
// ring pushes live; idle drift stays below 0.03.
|
||||
// Velocity-driven mix kept, but with the new low base alpha the
|
||||
// green tint is barely visible — by design. The cloud is calm.
|
||||
float t = smoothstep(0.04, 0.18, vVel);
|
||||
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
BIN
remotion/audio.mp3
Normal file
Binary file not shown.
@ -6,7 +6,7 @@
|
||||
"studio": "remotion studio src/index.ts",
|
||||
"render:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
|
||||
"render:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32",
|
||||
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 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",
|
||||
"to-web": "node scripts/publish-to-web.mjs",
|
||||
"build": "pnpm render:all && pnpm to-web"
|
||||
|
||||
@ -2,25 +2,28 @@ import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remo
|
||||
import { C } from './lib/colors';
|
||||
import { clampLerp } from './lib/easings';
|
||||
import { PromptScene } from './scenes/PromptScene';
|
||||
import { SecretsScene } from './scenes/SecretsScene';
|
||||
import { BuildScene } from './scenes/BuildScene';
|
||||
import { LibraryScene } from './scenes/LibraryScene';
|
||||
import { DiscoveryScene } from './scenes/DiscoveryScene';
|
||||
|
||||
export const HERO_FPS = 30;
|
||||
export const HERO_DURATION_FRAMES = 300; // 10s
|
||||
export const HERO_DURATION_FRAMES = 450; // 15s
|
||||
|
||||
// Scene timing. Each beat overlaps the next by ~6 frames so the
|
||||
// transitions crossfade rather than hard-cut.
|
||||
//
|
||||
// P1 prompt [ 0, 81)
|
||||
// P2 build [ 75, 171)
|
||||
// P3 library [165, 246)
|
||||
// P4 discovery [240, 300)
|
||||
// P1 prompt [ 0, 81) 81f prompt typed
|
||||
// P1.5 secrets [ 75, 165) 90f vault panel + arrow fork
|
||||
// P2 build [159, 261) 102f log + server card
|
||||
// P3 library [255, 366) 111f morph into template grid
|
||||
// P4 discovery [360, 450) 90f fork counter + community
|
||||
export const BEAT = {
|
||||
prompt: { in: 0, out: 81 },
|
||||
build: { in: 75, out: 171 },
|
||||
library: { in: 165, out: 246 },
|
||||
discovery: { in: 240, out: 300 },
|
||||
secrets: { in: 75, out: 165 },
|
||||
build: { in: 159, out: 261 },
|
||||
library: { in: 255, out: 366 },
|
||||
discovery: { in: 360, out: 450 },
|
||||
} as const;
|
||||
|
||||
const FADE_FRAMES = 12;
|
||||
@ -29,9 +32,9 @@ export function HeroVideo() {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 299 ≈
|
||||
// frame 0 (both essentially-black). Browser <video loop> will jump back
|
||||
// and the seam is invisible.
|
||||
// Loop-clean: ramp opacity to 0 over the last 12 frames so the final frame
|
||||
// ≈ frame 0 (both essentially-black). Browser <video loop> jumps back and
|
||||
// the seam is invisible.
|
||||
const loopFade = interpolate(
|
||||
frame,
|
||||
[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.
|
||||
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 libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.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} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
{secretsAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: secretsAlpha }}>
|
||||
<SecretsScene localFrame={frame - BEAT.secrets.in} fps={fps} />
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
{buildAlpha > 0 && (
|
||||
<AbsoluteFill style={{ opacity: buildAlpha }}>
|
||||
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
|
||||
|
||||
@ -2,11 +2,19 @@ import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp } from '../lib/easings';
|
||||
|
||||
// Phase 2 (frames 75–171 global → localFrame 0..96): build log streams in
|
||||
// line-by-line, then a server card emerges.
|
||||
// Phase 2 (frames 159–261 global → localFrame 0..102): build log streams in
|
||||
// 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.
|
||||
// Server card emerges at localFrame ~64.
|
||||
// Then a subtle caption appears below the card:
|
||||
// "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 = [
|
||||
{ label: 'Generating spec', detail: '2 tools detected' },
|
||||
@ -17,7 +25,9 @@ const LOG_LINES = [
|
||||
|
||||
const LINE_STAGGER = 10;
|
||||
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 }) {
|
||||
const panelIn = springIn(localFrame, fps, 0);
|
||||
@ -26,7 +36,14 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
|
||||
// Card emerges late in phase.
|
||||
const cardIn = softSpring(localFrame, fps, CARD_START, 24);
|
||||
// Once the card is up, the log panel slides up to make room.
|
||||
const panelShift = interpolate(cardIn, [0, 1], [0, -140]);
|
||||
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 (
|
||||
<div
|
||||
@ -91,8 +108,32 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
|
||||
<ServerCard
|
||||
progress={cardIn}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -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 y = interpolate(progress, [0, 1], [40, 180]);
|
||||
// Pulse the live dot at ~1Hz.
|
||||
const pulsePhase = (localFrame - 60) / 30;
|
||||
const livePulse = 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2);
|
||||
// Live dot pulses once the slots have arrived.
|
||||
const liveOn = secretSlotIn > 0.6;
|
||||
const pulsePhase = (localFrame - (SLOTS_START + 18)) / 30;
|
||||
const livePulse = liveOn
|
||||
? 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -181,12 +235,13 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 480,
|
||||
width: 540,
|
||||
backgroundColor: C.bgElevated,
|
||||
border: `1.5px solid ${C.accent}`,
|
||||
borderRadius: 16,
|
||||
padding: '24px 28px',
|
||||
boxShadow: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Header row: title + live dot */}
|
||||
@ -215,9 +270,10 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
|
||||
gap: 8,
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 12,
|
||||
color: C.success,
|
||||
color: liveOn ? C.success : C.fgSubtle,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.4 + 0.6 * Math.min(1, secretSlotIn),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -225,15 +281,41 @@ function ServerCard({ progress, localFrame }: { progress: number; localFrame: nu
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: C.success,
|
||||
boxShadow: `0 0 ${10 * livePulse}px ${C.success}`,
|
||||
opacity: 0.5 + 0.5 * livePulse,
|
||||
backgroundColor: liveOn ? C.success : C.fgSubtle,
|
||||
boxShadow: liveOn ? `0 0 ${10 * livePulse}px ${C.success}` : 'none',
|
||||
opacity: liveOn ? 0.5 + 0.5 * livePulse : 0.5,
|
||||
}}
|
||||
/>
|
||||
live
|
||||
{liveOn ? 'live' : 'starting'}
|
||||
</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 */}
|
||||
<ToolRow name="search_pages" desc="full-text query" />
|
||||
<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 }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -2,13 +2,13 @@ import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
||||
|
||||
// Phase 4 (frames 240–300 global → localFrame 0..60): the user's card is
|
||||
// the hero. A fork count ticks 0 → 247 with micro-particles. A subtitle
|
||||
// pops in: "1,200+ developers building".
|
||||
// Phase 4 (frames 360–450 global → localFrame 0..90): the user's card is
|
||||
// the hero. Fork count ticks 0 → 247 with micro-particles. Subtitle pops:
|
||||
// "1,200+ developers building".
|
||||
//
|
||||
// We keep the same grid layout as Phase 3 (so the transition is a
|
||||
// crossfade in place) but zoom slightly toward the hero card and emphasize
|
||||
// it. The fork counter sits on the hero card.
|
||||
// New beat in v5: as forks accumulate, a few non-hero cards get a small
|
||||
// "NEW SECRET" pulse near their slot, implying each forker plugs in their
|
||||
// own credential. Subtle, not loud.
|
||||
|
||||
const GRID_COLS = 3;
|
||||
const GRID_ROWS = 2;
|
||||
@ -21,22 +21,26 @@ const CARDS = [
|
||||
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
|
||||
{ name: 'slack-digest', toolCount: 4, highlighted: false },
|
||||
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
|
||||
{ name: 'gmail-triage', toolCount: 3, highlighted: false }, // demote — only the user's is the star
|
||||
{ name: 'gmail-triage', toolCount: 3, highlighted: false },
|
||||
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
|
||||
];
|
||||
|
||||
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 }) {
|
||||
// Slow zoom toward the hero card.
|
||||
const zoom = clampLerp(localFrame, 0, 50);
|
||||
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]);
|
||||
|
||||
// Fork count ticks 0 → 247 over ~36 frames starting at localFrame 6.
|
||||
const tickProgress = clampLerp(localFrame, 6, 42);
|
||||
const forkCount = Math.floor(tickProgress * TARGET_FORKS);
|
||||
|
||||
// Subtitle "1,200+ developers building" pops at localFrame ~28.
|
||||
const subIn = softSpring(localFrame, fps, 28, 18);
|
||||
|
||||
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
|
||||
@ -44,13 +48,11 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
||||
const gridLeft = (1920 - gridW) / 2;
|
||||
const gridTop = (1080 - gridH) / 2 + 30;
|
||||
|
||||
// Hero card position (index 1: col=1, row=0)
|
||||
const heroX = gridLeft + 1 * (CARD_W + GAP);
|
||||
const heroY = gridTop + 0 * (CARD_H + GAP);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* Section caption (matches Phase 3 caption position) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -68,7 +70,6 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
||||
template library
|
||||
</div>
|
||||
|
||||
{/* Zoom group — scales everything around the hero card center */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -84,9 +85,15 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
||||
const y = gridTop + row * (CARD_H + GAP);
|
||||
|
||||
const isHero = i === 1;
|
||||
// Non-hero cards desaturate slightly as the camera focuses.
|
||||
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 1;
|
||||
|
||||
// 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}
|
||||
@ -99,12 +106,16 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
||||
opacity: dim,
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner card={card} isHero={isHero} forkCount={isHero ? forkCount : null} />
|
||||
<TemplateCardInner
|
||||
card={card}
|
||||
isHero={isHero}
|
||||
forkCount={isHero ? forkCount : null}
|
||||
newSecretPulse={newSecretPulse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Fork-tick micro-particles around the hero card */}
|
||||
<ForkParticles
|
||||
localFrame={localFrame}
|
||||
x={heroX + CARD_W - 22}
|
||||
@ -112,7 +123,6 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle "1,200+ developers building" — bottom of frame */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -138,10 +148,12 @@ function TemplateCardInner({
|
||||
card,
|
||||
isHero,
|
||||
forkCount,
|
||||
newSecretPulse,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
isHero: boolean;
|
||||
forkCount: number | null;
|
||||
newSecretPulse: number;
|
||||
}) {
|
||||
const border = isHero ? C.accent : C.border;
|
||||
const shadow = isHero
|
||||
@ -186,7 +198,7 @@ function TemplateCardInner({
|
||||
/>
|
||||
</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) => (
|
||||
<div
|
||||
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
|
||||
@ -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() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
|
||||
@ -256,8 +306,6 @@ function ForkParticles({
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
// Emit a particle every ~3 frames during the tick window (frames 6-42).
|
||||
// Each particle lives ~14 frames, drifting up and fading out.
|
||||
const PARTICLES = 12;
|
||||
const EMIT_START = 6;
|
||||
const EMIT_INTERVAL = 3;
|
||||
|
||||
@ -2,12 +2,14 @@ import { interpolate } from 'remotion';
|
||||
import { C } from '../lib/colors';
|
||||
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
|
||||
|
||||
// Phase 3 (frames 165–246 global → localFrame 0..81): the server card pulls
|
||||
// Phase 3 (frames 255–366 global → localFrame 0..111): the server card pulls
|
||||
// back / scales down and multiplies into a 3×2 grid of template cards.
|
||||
//
|
||||
// We "zoom out" by starting with a single big card centered (matches the
|
||||
// position where Phase 2 left it) and animating it to grid-slot (1,0)
|
||||
// while five sibling cards fade in around it.
|
||||
// New beat in v5: as the hero card joins the grid, the lock icon next to its
|
||||
// NOTION_API_KEY slot DETACHES and fades upward — the slot then reads
|
||||
// `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_ROWS = 2;
|
||||
@ -15,9 +17,6 @@ const CARD_W = 340;
|
||||
const CARD_H = 180;
|
||||
const GAP = 28;
|
||||
|
||||
// Card metadata. The "hero" card (index 1, top-center) corresponds to the
|
||||
// server the user just built. It stays highlighted with the indigo border.
|
||||
// Index 0 and 4 are also highlighted to suggest "popular templates".
|
||||
const CARDS = [
|
||||
{ name: 'github-issues', toolCount: 3, highlighted: false },
|
||||
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
|
||||
@ -27,23 +26,26 @@ const CARDS = [
|
||||
{ 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 }) {
|
||||
// The caption above the grid.
|
||||
const captionIn = clampLerp(localFrame, 20, 36);
|
||||
|
||||
// Hero card animation — starts at its previous-scene location (centered,
|
||||
// big) and morphs to grid slot 1.
|
||||
const heroProgress = softSpring(localFrame, fps, 0, 30);
|
||||
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 gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
|
||||
const gridLeft = (1920 - gridW) / 2;
|
||||
const gridTop = (1080 - gridH) / 2 + 30; // nudge down a bit for the caption
|
||||
const gridTop = (1080 - gridH) / 2 + 30;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{/* Caption */}
|
||||
{/* Section caption */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -68,29 +70,36 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
|
||||
const row = Math.floor(i / GRID_COLS);
|
||||
const targetX = gridLeft + col * (CARD_W + GAP);
|
||||
const targetY = gridTop + row * (CARD_H + GAP);
|
||||
|
||||
const isHero = i === 1;
|
||||
|
||||
if (isHero) {
|
||||
// Hero card morphs from "big centered" to its grid slot.
|
||||
const startW = 480;
|
||||
const startH = 220;
|
||||
const startW = 540;
|
||||
const startH = 240;
|
||||
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 h = interpolate(heroProgress, [0, 1], [startH, CARD_H]);
|
||||
const x = interpolate(heroProgress, [0, 1], [startX, targetX]);
|
||||
const y = interpolate(heroProgress, [0, 1], [startY, targetY]);
|
||||
return (
|
||||
<TemplateCard
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: w,
|
||||
height: h,
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner
|
||||
card={card}
|
||||
x={x}
|
||||
y={y}
|
||||
w={w}
|
||||
h={h}
|
||||
opacity={1}
|
||||
lockDetach={lockDetach}
|
||||
isHero
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -98,9 +107,7 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
|
||||
const delay = 16 + i * 3;
|
||||
const sib = springIn(localFrame, fps, delay);
|
||||
const opacity = clampLerp(localFrame, delay, delay + 14);
|
||||
// Slight scale-in from 0.92.
|
||||
const scale = interpolate(sib, [0, 1], [0.92, 1]);
|
||||
// Use deterministic randomness for tiny drift-in offsets.
|
||||
const drift = (rand(i + 7) - 0.5) * 14;
|
||||
return (
|
||||
<div
|
||||
@ -116,41 +123,30 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
card,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
opacity,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
opacity: number;
|
||||
}) {
|
||||
return (
|
||||
{/* Sub-caption: "templates carry code, not credentials" */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: w,
|
||||
height: h,
|
||||
opacity,
|
||||
bottom: 110,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
||||
fontSize: 14,
|
||||
letterSpacing: 3,
|
||||
textTransform: 'uppercase',
|
||||
color: C.fgSubtle,
|
||||
opacity: subCaptionIn,
|
||||
transform: `translateY(${interpolate(subCaptionIn, [0, 1], [8, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
<TemplateCardInner card={card} w={w} h={h} />
|
||||
templates carry <span style={{ color: C.fg }}>code</span>, not credentials
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -159,20 +155,29 @@ function TemplateCardInner({
|
||||
card,
|
||||
w,
|
||||
h,
|
||||
lockDetach,
|
||||
isHero,
|
||||
}: {
|
||||
card: typeof CARDS[number];
|
||||
card: { name: string; toolCount: number; highlighted: boolean };
|
||||
w: number;
|
||||
h: number;
|
||||
lockDetach: number;
|
||||
isHero: boolean;
|
||||
}) {
|
||||
const border = card.highlighted ? C.accent : C.border;
|
||||
const shadow = card.highlighted
|
||||
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
|
||||
: `0 8px 24px rgba(0,0,0,0.35)`;
|
||||
|
||||
// Pad inside scales mildly with card size (since hero is bigger).
|
||||
const padX = Math.max(18, w * 0.06);
|
||||
const padY = Math.max(16, h * 0.08);
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
style={{
|
||||
@ -186,6 +191,8 @@ function TemplateCardInner({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Top: title + indicator dot */}
|
||||
@ -212,7 +219,55 @@ function TemplateCardInner({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle: faux tool bars */}
|
||||
{/* Middle: either tool bars (default) OR the secret slot pill (hero) */}
|
||||
{isHero ? (
|
||||
<div
|
||||
style={{
|
||||
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={{
|
||||
display: 'inline-flex',
|
||||
transform: `translateY(${lockY}px)`,
|
||||
opacity: lockOpacity,
|
||||
}}
|
||||
>
|
||||
<MiniLockIcon size={w > 380 ? 11 : 9} />
|
||||
</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
|
||||
@ -227,6 +282,7 @@ function TemplateCardInner({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom: tool count + status */}
|
||||
<div
|
||||
@ -248,3 +304,13 @@ function TemplateCardInner({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
530
remotion/src/scenes/SecretsScene.tsx
Normal file
530
remotion/src/scenes/SecretsScene.tsx
Normal 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 75–165 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user