From 6197ee7f5ed027fd12bc8dc6a91743c66fe76cd4 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Wed, 27 May 2026 13:17:20 +0200 Subject: [PATCH] feat: particle cloud (no discrete dots) + geo-IP country preselect on login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/index.ts | 2 + apps/api/src/routes/geo.ts | 33 ++ apps/web/app/login/page.tsx | 16 + apps/web/components/hero-step-rotator.tsx | 19 +- .../particle-hero/ParticleField.tsx | 27 +- apps/web/components/particle-hero/index.tsx | 35 +- apps/web/components/particle-hero/shaders.ts | 39 +- apps/web/public/videos/hero-poster.jpg | Bin 29351 -> 48996 bytes apps/web/public/videos/hero.mp4 | Bin 280146 -> 555354 bytes apps/web/public/videos/hero.webm | Bin 202782 -> 886553 bytes remotion/audio.mp3 | Bin 0 -> 1178998 bytes remotion/package.json | 2 +- remotion/src/HeroVideo.tsx | 31 +- remotion/src/scenes/BuildScene.tsx | 179 +++++- remotion/src/scenes/DiscoveryScene.tsx | 88 ++- remotion/src/scenes/LibraryScene.tsx | 216 ++++--- remotion/src/scenes/SecretsScene.tsx | 530 ++++++++++++++++++ 17 files changed, 1053 insertions(+), 164 deletions(-) create mode 100644 apps/api/src/routes/geo.ts create mode 100644 remotion/audio.mp3 create mode 100644 remotion/src/scenes/SecretsScene.tsx 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 (