diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 799bdb6..a2b304b 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -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
diff --git a/apps/api/src/routes/geo.ts b/apps/api/src/routes/geo.ts
new file mode 100644
index 0000000..463ef34
--- /dev/null
+++ b/apps/api/src/routes/geo.ts
@@ -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 };
+ });
+}
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx
index 9283ccf..2f98948 100644
--- a/apps/web/app/login/page.tsx
+++ b/apps/web/app/login/page.tsx
@@ -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.');
}, []);
diff --git a/apps/web/components/hero-step-rotator.tsx b/apps/web/components/hero-step-rotator.tsx
index c075558..03adc32 100644
--- a/apps/web/components/hero-step-rotator.tsx
+++ b/apps/web/components/hero-step-rotator.tsx
@@ -23,6 +23,10 @@ interface Step {
// scrolling inside the tile. `whitespace-pre-wrap` on the
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": {
diff --git a/apps/web/components/particle-hero/ParticleField.tsx b/apps/web/components/particle-hero/ParticleField.tsx
index 9f225bf..a5225f1 100644
--- a/apps/web/components/particle-hero/ParticleField.tsx
+++ b/apps/web/components/particle-hero/ParticleField.tsx
@@ -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;
diff --git a/apps/web/components/particle-hero/index.tsx b/apps/web/components/particle-hero/index.tsx
index 667ff94..9bf6c95 100644
--- a/apps/web/components/particle-hero/index.tsx
+++ b/apps/web/components/particle-hero/index.tsx
@@ -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 (
);
}
- return ;
+ // 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 (
+ <>
+
+
+ >
+ );
}
export default ParticleHero;
diff --git a/apps/web/components/particle-hero/shaders.ts b/apps/web/components/particle-hero/shaders.ts
index 7f15b5c..6e58b52 100644
--- a/apps/web/components/particle-hero/shaders.ts
+++ b/apps/web/components/particle-hero/shaders.ts
@@ -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);
diff --git a/apps/web/public/videos/hero-poster.jpg b/apps/web/public/videos/hero-poster.jpg
index 52d43fb..2b5c597 100644
Binary files a/apps/web/public/videos/hero-poster.jpg and b/apps/web/public/videos/hero-poster.jpg differ
diff --git a/apps/web/public/videos/hero.mp4 b/apps/web/public/videos/hero.mp4
index 73bb145..67ff389 100644
Binary files a/apps/web/public/videos/hero.mp4 and b/apps/web/public/videos/hero.mp4 differ
diff --git a/apps/web/public/videos/hero.webm b/apps/web/public/videos/hero.webm
index ca1b714..aaa23c6 100644
Binary files a/apps/web/public/videos/hero.webm and b/apps/web/public/videos/hero.webm differ
diff --git a/remotion/audio.mp3 b/remotion/audio.mp3
new file mode 100644
index 0000000..50f3ab6
Binary files /dev/null and b/remotion/audio.mp3 differ
diff --git a/remotion/package.json b/remotion/package.json
index 10a7486..91c5b75 100644
--- a/remotion/package.json
+++ b/remotion/package.json
@@ -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"
diff --git a/remotion/src/HeroVideo.tsx b/remotion/src/HeroVideo.tsx
index 22c62a9..058f9a0 100644
--- a/remotion/src/HeroVideo.tsx
+++ b/remotion/src/HeroVideo.tsx
@@ -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