feat(landing): full-viewport GPU node-engine hero + waitlist + dashboard auth removal

The /launch landing page (bleeding-edge waitlist hero):
- Full-viewport WebGL node engine (src/lib/hero/nodeEngine.ts): 40k GPU particles
  on a two-FBO GPUComputationRenderer running an 18s looping cinematic — particles
  stream in from the screen edges, slam together and EXPLODE at center, reform into
  a brain / graph constellation / neural lattice, dissolve back out, loop. Glowing
  additive particles + UnrealBloom, fills the whole screen edge to edge.
- Key GPGPU fix: custom DataTexture shape targets read black in GPUComputationRenderer
  (three.js #15882), so shape targets are computed PROCEDURALLY in-shader from the
  per-particle seed. Position integrates raw per-frame velocity (Codrops pattern),
  texel-center reference attribute.
- AmbientField (god-ray glow + parallax starfield), phantomBrain (deterministic
  seed-from-identity memory graph), LandingHero/neuralFlow (WebGPU Cinema-storm
  reuse + WebGL fallback, kept as alternates). Manifesto copy, seed input, email
  capture. No em-dashes. SSR off + prerender for the WebGL route.

Waitlist backend (Supabase, owned data):
- supabase/migrations/0001_waitlist.sql (RLS-locked table, dedup), Edge Function
  waitlist-join (CORS, validation, honeypot, dedup, Resend confirmation), setup doc.

Dashboard auth removed (reverts the launch-polish auth wall that 401'd the dashboard
against itself): mod.rs/state.rs/websocket.rs back to no-token, api.ts/websocket.ts/
+layout.svelte stripped of the token client. Pending-review UX + Memory PR gating kept.

Research/spec docs under docs/launch/. Frontend typechecks 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-24 20:31:39 -05:00
parent 335a42f341
commit cfe8d03d36
26 changed files with 3002 additions and 266 deletions

View file

@ -1,6 +1,8 @@
# Optional public waitlist capture endpoint used by /dashboard/waitlist.
# The page POSTs JSON with: name, email, plan, priority, notes, source, createdAt.
# Examples: Formspree, Tally webhook, Buttondown custom endpoint, Supabase Edge Function.
# Vestige ships a Supabase Edge Function for this — see docs/launch/waitlist-setup.md.
# Format: https://<project-ref>.functions.supabase.co/waitlist-join
# (Formspree / Tally / Buttondown endpoints also work — anything that accepts the POST.)
VITE_WAITLIST_ENDPOINT=
# Optional support bot endpoint used by /dashboard/waitlist.

View file

@ -0,0 +1,54 @@
<script lang="ts">
// Mounts the full-viewport NodeEngine (edge -> converge -> EXPLODE -> reform
// -> loop) as the fullscreen landing hero. Reseeds per visitor.
import { onMount, onDestroy } from 'svelte';
import { NodeEngine } from '$lib/hero/nodeEngine';
interface Props {
seed?: number;
reducedMotion?: boolean;
onTextBeat?: (isText: boolean) => void;
}
let { seed = 1234, reducedMotion = false, onTextBeat }: Props = $props();
let host = $state<HTMLDivElement | undefined>(undefined);
let engine: NodeEngine | null = null;
onMount(() => {
if (host) {
try {
const qs = new URLSearchParams(window.location.search);
const fp = qs.get('phase');
const fs = qs.get('shape');
const forcePhase = fp !== null ? parseFloat(fp) : undefined;
const forceShape = fs !== null ? parseInt(fs, 10) : undefined;
engine = new NodeEngine(host, { seed, reducedMotion, forcePhase, forceShape, onTextBeat });
} catch (e) {
console.warn('[hero] NodeEngine failed to boot:', e);
}
}
});
onDestroy(() => {
engine?.dispose();
engine = null;
});
</script>
<div class="node-stage" bind:this={host} aria-hidden="true"></div>
<style>
.node-stage {
position: fixed;
inset: 0;
z-index: 0;
width: 100vw;
height: 100vh;
height: 100svh;
}
.node-stage :global(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
</style>

View file

@ -0,0 +1,717 @@
// Full-viewport Node Engine — the legendary landing cinematic.
//
// ~40k GPU particles (200x200 two-FBO GPGPU sim) running one looping 4-beat
// cinematic that OWNS the entire viewport:
// STREAM particles fly in from beyond all four screen edges toward center
// EXPLODE they slam together and a one-shot radial blast fills the frame
// REFORM they spring onto a shape (brain -> graph constellation -> lattice)
// DISSOLVE they drift back out to the edge shell, seamlessly restarting
//
// Separate from Memory Cinema (src/lib/graph/cinema/*) which must NOT be touched.
// WebGL2 only, ships everywhere. Built from a researched spec
// (docs/launch/node-engine-spec.json).
import * as THREE from 'three';
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
const LOOP = 18.0; // seconds per full cinematic loop
// ---- shared GLSL noise (simplex + curl) ------------------------------------
const NOISE = /* glsl */ `
vec3 hash3(vec3 p){
p = vec3(dot(p,vec3(127.1,311.7,74.7)),dot(p,vec3(269.5,183.3,246.1)),dot(p,vec3(113.5,271.9,124.6)));
return -1.0+2.0*fract(sin(p)*43758.5453123);
}
float snoise(vec3 p){
vec3 i=floor(p),f=fract(p),u=f*f*(3.0-2.0*f);
return mix(mix(mix(dot(hash3(i+vec3(0,0,0)),f-vec3(0,0,0)),dot(hash3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
mix(dot(hash3(i+vec3(0,1,0)),f-vec3(0,1,0)),dot(hash3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
mix(mix(dot(hash3(i+vec3(0,0,1)),f-vec3(0,0,1)),dot(hash3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
mix(dot(hash3(i+vec3(0,1,1)),f-vec3(0,1,1)),dot(hash3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
}
vec3 snoiseVec3(vec3 p){ return vec3(snoise(p),snoise(p+vec3(17.1,9.2,3.3)),snoise(p+vec3(101.7,5.4,71.2))); }
float h11(float p){ return fract(sin(p*127.1)*43758.5453); }
float h1(float p){ return fract(sin(p*78.233)*12543.531); }
#define TAU 6.28318530718
// --- UNEXPECTED math shapes (researched, real parametric/ODE point clouds) ---
// Aizawa strange attractor: chaotic "thinking" sculpture, toroidal shell + axial spike.
vec3 shapeAizawa(float a, float b, float c, float S){
vec3 p = (vec3(fract(a*43.0+0.13), fract(b*91.7+0.41), fract(c*57.3+0.77)) - 0.5) * 0.12;
const float A=0.95, B=0.7, C=0.6, D=3.5, E=0.25, F=0.1, dt=0.01;
int iters = 260 + int(a*140.0);
for(int i=0;i<400;i++){
if(i>=iters) break;
float dx = (p.z - B)*p.x - D*p.y;
float dy = D*p.x + (p.z - B)*p.y;
float dz = C + A*p.z - p.z*p.z*p.z/3.0 - (p.x*p.x + p.y*p.y)*(1.0 + E*p.z) + F*p.z*p.x*p.x*p.x;
p += vec3(dx,dy,dz)*dt;
}
// the Aizawa body is centered around ~(0,0,0.7); shift z to center it in frame
p.z -= 0.7;
return p.xzy * (0.46 * S); // swap y/z so the axial spike stands upright
}
// Hopf fibration: ~12 interlocking rings, every ring links every other once.
vec3 shapeHopf(float a, float b, float c, float S){
float ringId = floor(a * 12.0);
float cosT = 2.0*fract(ringId*0.61803398875) - 1.0;
float baseTheta = acos(clamp(cosT,-1.0,1.0));
float basePhi = TAU * fract(ringId*0.7548776662);
vec3 bp = vec3(sin(baseTheta)*cos(basePhi), sin(baseTheta)*sin(basePhi), cos(baseTheta));
float t = TAU * b;
float k = 1.0 / sqrt(2.0*(1.0 + bp.z) + 1e-4);
vec4 P = vec4((1.0+bp.z)*cos(t), bp.x*sin(t)-bp.y*cos(t), bp.x*cos(t)+bp.y*sin(t), (1.0+bp.z)*sin(t)) * k;
vec3 q = P.xyz / (1.0 - P.w + 1e-3);
q += (vec3(fract(c*71.3),fract(c*131.7),fract(c*197.1))-0.5)*0.04;
return q * (0.28 * S);
}
// (p,q) torus knot inflated into a volumetric tube — a glowing self-tied rope.
vec3 shapeTorusKnot(float a, float b, float c, float S){
const float P = 3.0, Q = 7.0, R = 1.0, rMinor = 0.45, rTube = 0.16;
float t = a * TAU;
float cq = cos(Q*t), sq = sin(Q*t);
float ring = R + rMinor*cq;
vec3 Cc = vec3(ring*cos(P*t), ring*sin(P*t), rMinor*sq);
vec3 T = normalize(vec3(-rMinor*Q*sq*cos(P*t) - ring*P*sin(P*t),
-rMinor*Q*sq*sin(P*t) + ring*P*cos(P*t), rMinor*Q*cq));
vec3 N = normalize(cross(T, vec3(0.0,0.0,1.0)));
vec3 Bn = cross(T, N);
float ang = b * TAU; float rad = sqrt(c) * rTube;
return (Cc + rad*(cos(ang)*N + sin(ang)*Bn)) * (0.55 * S);
}
// DNA double helix: two strands + base-pair rungs.
vec3 shapeDNA(float a, float b, float c, float S){
const float TWISTS = 6.0, HEIGHT = 2.0, RADIUS = 0.55;
float yy = (a - 0.5) * HEIGHT;
float ang = a * TWISTS * TAU;
vec3 sA = vec3(cos(ang)*RADIUS, yy, sin(ang)*RADIUS);
vec3 sB = vec3(cos(ang+3.14159)*RADIUS, yy, sin(ang+3.14159)*RADIUS);
vec3 pos;
if(b < 0.42) pos = sA; else if(b < 0.84) pos = sB; else pos = mix(sA, sB, fract(b*53.0));
float rad = sqrt(c)*0.06; float ja = fract(c*131.7)*TAU, jb = fract(c*71.3)*3.14159;
pos += rad * vec3(sin(jb)*cos(ja), cos(jb), sin(jb)*sin(ja));
return pos * (0.62 * S);
}
// Spiral galaxy: 4 log-spiral arms + bright spherical core.
vec3 shapeGalaxy(float a, float b, float c, float S){
const float N_ARMS = 4.0, TWIST = 2.6;
float arm = floor(a * N_ARMS);
float t = sqrt(b); float r = t;
float jit = (fract(a*97.13) - 0.5) * 0.35 * (1.0 - t);
float ang = arm * TAU / N_ARMS + t * TWIST * TAU + jit;
float yy = (fract(c*53.7) - 0.5) * 0.14 * exp(-2.5*r);
vec3 pos = vec3(cos(ang)*r, yy, sin(ang)*r);
if(b < 0.06){
float rr = pow(fract(c*191.3), 0.5) * 0.12;
float th = fract(a*311.7)*TAU, ph = acos(2.0*fract(c*131.1)-1.0);
pos = vec3(rr*sin(ph)*cos(th), rr*cos(ph), rr*sin(ph)*sin(th));
}
// tilt the disc ~58deg around X so we see the spiral face, not edge-on
float ca = 0.53, sa = 0.85;
pos = vec3(pos.x, pos.y*ca - pos.z*sa, pos.y*sa + pos.z*ca);
return pos * (1.15 * S);
}
// Gielis superformula supershape: a spiky alien crystal sea-urchin.
float superR(float ang, float m, float n1, float n2, float n3){
float t = m * ang * 0.25;
float c1 = pow(abs(cos(t)), n2);
float c2 = pow(abs(sin(t)), n3);
return pow(c1 + c2 + 1e-6, -1.0/n1);
}
vec3 shapeSupershape(float a, float b, float c, float S){
const float PI = 3.14159265359;
float theta = (a*2.0 - 1.0) * PI;
float phi = (b - 0.5) * PI;
float m=7.0, n1=0.2, n2=1.7, n3=1.7;
float r1 = superR(theta, m, n1, n2, n3);
float r2 = superR(phi, m, n1, n2, n3);
vec3 pos = vec3(r1*cos(theta)*r2*cos(phi), r1*sin(theta)*r2*cos(phi), r2*sin(phi));
pos = normalize(pos) * pow(length(pos), 0.6);
pos *= (1.0 + (c - 0.5)*0.04);
return pos * (0.85 * S);
}
// TEXT beat: particles land where the launch-message mask alpha is high,
// spelling VESTIGE / JULY 14TH / SIGN UP NOW. Strays relax up the alpha
// gradient so the message reads crisp.
vec3 shapeText(float a, float b, float c, float S){
vec2 uv = vec2(a, b);
for(int i=0;i<5;i++){
float al = texture2D(uMask, uv).r;
if(al > 0.5) break;
float e = 1.0/256.0;
float gx = texture2D(uMask, uv+vec2(e,0.0)).r - texture2D(uMask, uv-vec2(e,0.0)).r;
float gy = texture2D(uMask, uv+vec2(0.0,e)).r - texture2D(uMask, uv-vec2(0.0,e)).r;
uv += normalize(vec2(gx,gy) + 1e-5) * 0.025;
}
float depth = (c - 0.5) * 0.08; // thin slab so it isn't perfectly flat
// map UV (0..1) to centered world; flip v (canvas is top-down); aspect-correct.
// Scale so the wide message fits the frame width (not height).
vec3 pos = vec3((uv.x - 0.5) * uMaskAspect, -(uv.y - 0.5), depth);
return pos * (0.62 * S);
}
// Procedural shape targets computed from the per-particle seed. Computed in
// shader (NOT sampled from a custom DataTexture, which renders black in GPGPU).
// 0=BRAIN 1=GRAPH 2=LATTICE 3=AIZAWA 4=HOPF 5=KNOT 6=DNA 7=GALAXY 8=SUPERSHAPE
vec3 shapeTarget(float seed, float shape, float S){
float a = h11(seed*3.1), b = h1(seed*7.7), c = h11(seed*13.3+2.0);
float ms = shape;
if (ms > 2.5){
if (ms < 3.5) return shapeAizawa(a,b,c,S);
if (ms < 4.5) return shapeHopf(a,b,c,S);
if (ms < 5.5) return shapeTorusKnot(a,b,c,S);
if (ms < 6.5) return shapeDNA(a,b,c,S);
if (ms < 7.5) return shapeGalaxy(a,b,c,S);
return shapeSupershape(a,b,c,S);
}
if (ms < 0.5){
// BRAIN: two lobes, SURFACE-weighted (r near 1) so it reads as a shell
// with structure, not a fuzzy filled ball. Sulci ridges from noise.
float r = mix(0.78, 1.0, pow(a, 0.5)); // most mass near the surface
float theta = b*6.2831853, phi = acos(2.0*c-1.0);
float lobe = (h11(seed*2.7) < 0.5) ? -1.0 : 1.0; // assign to a hemisphere
float x = abs(sin(phi)*cos(theta)) * lobe;
float y = cos(phi);
float z = sin(phi)*sin(theta);
// squash front-back, separate the two lobes, dimple the medial fissure
vec3 p = vec3(x*0.78 + lobe*0.42, y*0.95, z*1.18);
// sulci: fold the surface inward along noise ridges
float ridges = snoise(p*3.4 + seed) * 0.12;
p *= (1.0 + ridges);
// flatten the bottom a touch (brain stem region)
p.y *= (p.y < 0.0) ? 0.82 : 1.0;
return p * r * S;
} else if (ms < 1.5){
// GRAPH CONSTELLATION: fibonacci-sphere node OR edge filament
float M = 600.0;
float k = floor(a*M);
float yy = 1.0 - (k/(M-1.0))*2.0; float rr = sqrt(max(0.0,1.0-yy*yy));
float t = 2.39996323*k;
vec3 nodeA = vec3(cos(t)*rr, yy, sin(t)*rr)*S*1.2;
if (b < 0.4) return nodeA + (snoiseVec3(vec3(seed))*0.06*S); // bright node
float k2 = floor(c*M);
float y2 = 1.0-(k2/(M-1.0))*2.0; float r2 = sqrt(max(0.0,1.0-y2*y2)); float t2=2.39996323*k2;
vec3 nodeB = vec3(cos(t2)*r2, y2, sin(t2)*r2)*S*1.2;
return mix(nodeA, nodeB, h1(seed*5.0)); // edge filament
} else {
// NEURAL LATTICE: hash-jittered 3D grid + struts
float G = 6.0;
vec3 cell = floor(vec3(a,b,c)*G);
vec3 center = (cell+0.5)/G*2.0*S - S;
float strut = h11(seed*17.0);
if (strut < 0.4){
float ax = floor(h1(seed*19.0)*3.0);
float m = (h11(seed*23.0)-0.5)*(2.0*S/G);
if (ax<0.5) center.x += m; else if (ax<1.5) center.y += m; else center.z += m;
} else {
center += (snoiseVec3(vec3(seed*2.0))*0.3*(2.0*S/G));
}
return center;
}
}
vec3 curlNoise(vec3 p){
const float e=0.1; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);
vec3 px0=snoiseVec3(p-dx),px1=snoiseVec3(p+dx);
vec3 py0=snoiseVec3(p-dy),py1=snoiseVec3(p+dy);
vec3 pz0=snoiseVec3(p-dz),pz1=snoiseVec3(p+dz);
float x=py1.z-py0.z-pz1.y+pz0.y, y=pz1.x-pz0.x-px1.z+px0.z, z=px1.y-px0.y-py1.x+py0.x;
return normalize(vec3(x,y,z)/(2.0*e)+1e-6);
}
float hash11(float p){ return fract(sin(p*127.1)*43758.5453); }
`;
const POSITION_SHADER = /* glsl */ `
void main(){
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 pT = texture2D(texturePosition, uv);
vec3 vel = texture2D(textureVelocity, uv).xyz;
// velocity is a per-frame delta (proven Codrops GPGPU pattern): add raw,
// NOT vel*dt — otherwise particles crawl at 1/60th speed and never form.
gl_FragColor = vec4(pT.xyz + vel, pT.w); // carry seed in .w
}
`;
const VELOCITY_SHADER = /* glsl */ `
uniform float uTime, uPhaseT, uDt, uBlastTime, uDebug, uShape, uScale, uMaskAspect;
uniform sampler2D texOrigin;
uniform sampler2D uMask;
${NOISE}
void main(){
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 pT = texture2D(texturePosition, uv);
vec3 pos = pT.xyz; float seed = pT.w;
vec3 vel = texture2D(textureVelocity, uv).xyz;
vec4 oT = texture2D(texOrigin, uv);
vec3 originPos = oT.xyz; float perimT = oT.w;
vec3 shapePos = shapeTarget(seed, uShape, uScale);
float wStream = 1.0 - smoothstep(0.18, 0.21, uPhaseT);
float pulse = smoothstep(0.200,0.215,uPhaseT) * (1.0 - smoothstep(0.215,0.245,uPhaseT));
float wReform = smoothstep(0.235,0.55,uPhaseT) * (1.0 - smoothstep(0.80,0.86,uPhaseT));
float wDissolve = smoothstep(0.82,1.00,uPhaseT);
// Velocity is a PER-FRAME delta (Codrops GPGPU pattern): forces are small,
// position adds raw velocity, damping keeps it stable.
// STREAM: staggered release toward center
float delay = 0.10*hash11(seed*91.7) + 0.08*perimT;
float act = smoothstep(0.0, 0.06, uPhaseT - delay);
vec3 toCenter = -pos; float dC = length(toCenter);
vec3 dirC = toCenter/max(dC,1e-4);
float tConv = clamp(uPhaseT/0.20, 0.0, 1.0);
float pull = pow(tConv, 3.0);
vel += dirC * (0.004 + 0.02*pull) * act * wStream * smoothstep(0.0,6.0,dC);
// EXPLODE: one-shot gaussian radial blast
float age = uTime - uBlastTime;
float gate = exp(-age*age*60.0);
float r = max(length(pos),1e-4);
vec3 outDir = pos/r;
float falloff = 1.0/(1.0 + r*r*4.0);
vel += outDir * 1.4 * falloff * gate;
vel += snoiseVec3(pos*31.0) * 0.02 * gate;
// REFORM/HOLD: spring onto shape (per-frame delta -> small k, critically damped).
float wHold = smoothstep(0.235, 0.42, uPhaseT) * (1.0 - smoothstep(0.80, 0.86, uPhaseT));
float localProg = smoothstep(delay, delay+0.30, (uPhaseT-0.235)/0.30);
float kAttract = mix(0.0, 0.10, wHold) * localProg;
vec3 toShape = shapePos - pos;
vel += toShape * kAttract;
// DISSOLVE: drift back to edge shell (== next-loop spawn -> seamless)
vec3 toEdge = originPos - pos;
vel += toEdge * (0.04 * wDissolve);
vel += normalize(pos+1e-4) * 0.01 * wDissolve;
// curl turbulence: strong on stream/blast/dissolve, NEAR ZERO once formed.
float turb = (0.012*wStream + 0.006*gate + 0.014*wDissolve) * (1.0 - wHold*0.96) + 0.0002;
vel += curlNoise(pos*0.30 + uTime*0.10) * turb;
// damping: heavy on hold so the shape SETTLES crisp; lighter during motion.
float damping = mix(0.90, 0.55, pulse) * mix(1.0, 0.82, wHold);
vel *= damping;
float v = length(vel); if (v > 0.6) vel *= 0.6/v;
// DIAGNOSTIC: uDebug=1 ignores all phase logic and just springs to target.
// If the brain forms with this, the pipeline works and phase logic is the bug.
if (uDebug > 0.5) {
// spring to procedural BRAIN with HARDCODED scale 5.0 (rules out uScale uniform).
vec3 tgt = shapeTarget(seed, 0.0, 5.0);
vec3 d = tgt - pos;
vel = d * 0.12;
vel *= 0.86;
}
gl_FragColor = vec4(vel, 1.0);
}
`;
const RENDER_VERT = /* glsl */ `
uniform sampler2D texturePosition;
uniform sampler2D textureVelocity;
uniform float uSize, uDpr, uPhaseT, uTime;
attribute vec2 reference;
varying float vSpeed; varying float vSeed;
void main(){
vec4 pT = texture2D(texturePosition, reference);
vec3 pos = pT.xyz; vSeed = pT.w;
vec3 vel = texture2D(textureVelocity, reference).xyz;
vSpeed = length(vel);
vec4 mv = modelViewMatrix * vec4(pos,1.0);
float breathe = 1.0 + 0.05*sin(uTime*1.4)*step(0.55,uPhaseT)*step(uPhaseT,0.82);
float stretch = 1.0 + clamp(vSpeed*0.6, 0.0, 2.5);
gl_PointSize = uSize * uDpr * breathe * stretch / max(-mv.z, 0.1);
gl_Position = projectionMatrix * mv;
}
`;
const RENDER_FRAG = /* glsl */ `
precision highp float;
uniform vec3 uViolet, uCyan, uEmerald;
uniform vec2 uTextCenter, uResolution;
varying float vSpeed; varying float vSeed;
void main(){
vec2 q = gl_PointCoord - 0.5;
float d2 = dot(q,q);
if (d2 > 0.25) discard;
// bright core + soft glowing halo so each particle reads as a luminous orb
float a = exp(-d2*16.0) + 0.5*exp(-d2*4.0);
// RICH 5-stop spectrum across the seed: magenta -> violet -> blue -> cyan -> emerald.
// Each particle holds its hue (spatially separated) instead of averaging to one color.
vec3 magenta = vec3(0.95, 0.25, 0.85);
vec3 violet = vec3(0.55, 0.35, 1.00);
vec3 blue = vec3(0.25, 0.45, 1.00);
vec3 cyan = vec3(0.15, 0.85, 0.95);
vec3 emerald = vec3(0.25, 0.95, 0.55);
float s = vSeed;
vec3 col;
if (s < 0.25) col = mix(magenta, violet, s/0.25);
else if (s < 0.50) col = mix(violet, blue, (s-0.25)/0.25);
else if (s < 0.75) col = mix(blue, cyan, (s-0.50)/0.25);
else col = mix(cyan, emerald,(s-0.75)/0.25);
// fast particles flare WARM gold (energy), not blue-white — keeps color alive.
vec3 gold = vec3(1.0, 0.75, 0.35);
col = mix(col, gold, clamp(vSpeed*0.5, 0.0, 0.55));
col *= 1.7; // luminous but not blown-out
vec2 sUv = gl_FragCoord.xy / uResolution;
float textMask = smoothstep(0.0, 0.20, length(sUv - uTextCenter));
float alpha = a * (0.7 + 0.3*textMask);
gl_FragColor = vec4(col * alpha, alpha);
}
`;
export interface NodeEngineOptions {
seed?: number;
reducedMotion?: boolean;
/** Debug: freeze the loop at a fixed phase in [0,1) to inspect a single beat. */
forcePhase?: number;
/** Debug: force a specific shape index 0..8 to inspect one shape. */
forceShape?: number;
/** Called when the active beat is/ isn't the particle TEXT message. */
onTextBeat?: (isText: boolean) => void;
}
export class NodeEngine {
private renderer: THREE.WebGLRenderer;
private scene = new THREE.Scene();
private camera: THREE.PerspectiveCamera;
private composer: EffectComposer;
private gpu: GPUComputationRenderer;
private posVar!: ReturnType<GPUComputationRenderer['addVariable']>;
private velVar!: ReturnType<GPUComputationRenderer['addVariable']>;
private points!: THREE.Points;
private material!: THREE.ShaderMaterial;
private targets: THREE.DataTexture[] = [];
private clock = new THREE.Clock();
private raf = 0;
private host: HTMLElement;
private TEX: number;
private N: number;
private reduced: boolean;
private disposed = false;
private uBlastTime = -999;
private prevPhase = 0;
private shapeIndex = 0;
private frustum = new THREE.Vector2(20, 12);
private forcePhase: number | undefined;
private forceShape: number | undefined;
private rotAmt = 1;
private onTextBeat: ((isText: boolean) => void) | undefined;
private lastTextState = false;
constructor(host: HTMLElement, opts: NodeEngineOptions = {}) {
this.host = host;
this.reduced = !!opts.reducedMotion;
this.forcePhase = opts.forcePhase;
this.forceShape = opts.forceShape;
this.onTextBeat = opts.onTextBeat;
const lowEnd = window.devicePixelRatio > 2.2 || (navigator.hardwareConcurrency || 8) <= 4;
this.TEX = lowEnd ? 140 : 200;
this.N = this.TEX * this.TEX;
const seed = (opts.seed ?? 1234) % 100000;
const w = host.clientWidth || 1, h = host.clientHeight || 1;
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x05050f, 0);
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.setSize(w, h);
host.appendChild(this.renderer.domElement);
this.camera = new THREE.PerspectiveCamera(55, w / h, 0.1, 100);
this.camera.position.set(0, 0, 14);
this.computeFrustum();
const originTex = this.buildOriginTexture(seed);
this.gpu = new GPUComputationRenderer(this.TEX, this.TEX, this.renderer);
const pos0 = this.gpu.createTexture();
const vel0 = this.gpu.createTexture();
// start everyone on the edge shell, with seed in .w
(pos0.image.data as Float32Array).set(originTex.image.data as Float32Array);
for (let i = 0; i < this.N; i++) (pos0.image.data as Float32Array)[i * 4 + 3] = (i / this.N);
this.posVar = this.gpu.addVariable('texturePosition', POSITION_SHADER, pos0);
this.velVar = this.gpu.addVariable('textureVelocity', VELOCITY_SHADER, vel0);
this.gpu.setVariableDependencies(this.posVar, [this.posVar, this.velVar]);
this.gpu.setVariableDependencies(this.velVar, [this.posVar, this.velVar]);
const pu = this.posVar.material.uniforms; pu.uDt = { value: 0 };
const vu = this.velVar.material.uniforms;
vu.uTime = { value: 0 }; vu.uPhaseT = { value: 0 }; vu.uDt = { value: 0 }; vu.uBlastTime = { value: -999 };
vu.texOrigin = { value: originTex };
vu.uShape = { value: this.forceShape ?? 0 };
vu.uScale = { value: this.frustum.y * 0.42 };
vu.uDebug = { value: this.forcePhase !== undefined && this.forcePhase < 0 ? 1 : 0 };
const mask = this.buildTextMask(['VESTIGE', 'JULY 14TH', 'SIGN UP NOW']);
vu.uMask = { value: mask.texture };
vu.uMaskAspect = { value: mask.aspect };
const err = this.gpu.init();
if (err) console.warn('[nodeEngine] gpgpu init:', err);
this.buildPoints(seed);
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(w, h), 0.6, 0.5, 0.6);
this.composer.addPass(bloom);
this.onResize = this.onResize.bind(this);
window.addEventListener('resize', this.onResize);
this.animate();
}
private computeFrustum() {
const vFOV = (this.camera.fov * Math.PI) / 180;
const h = 2 * Math.tan(vFOV / 2) * Math.abs(this.camera.position.z);
const w = h * this.camera.aspect;
this.frustum.set(w, h);
}
// Build an alpha mask of the launch message (3 rows) on a canvas. The shader
// samples it: particles land where text alpha is high, spelling the message.
private buildTextMask(lines: string[]): { texture: THREE.Texture; aspect: number } {
const W = 1024, H = 384;
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d')!;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const rowH = H / lines.length;
for (let i = 0; i < lines.length; i++) {
// size each line to fill the width nicely
const line = lines[i];
let size = Math.floor(rowH * 0.78);
ctx.font = `900 ${size}px Inter, Arial, sans-serif`;
// shrink to fit if too wide
while (ctx.measureText(line).width > W * 0.92 && size > 10) {
size -= 2; ctx.font = `900 ${size}px Inter, Arial, sans-serif`;
}
ctx.fillText(line, W / 2, rowH * (i + 0.5));
}
const tex = new THREE.CanvasTexture(cv);
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
this.renderer.initTexture(tex);
return { texture: tex, aspect: W / H };
}
// edge-spawn shell: each particle off-screen on one of 4 edges, perimeterT in .w
private buildOriginTexture(seed: number): THREE.DataTexture {
const data = new Float32Array(this.N * 4);
let s = seed + 1;
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
const hw = this.frustum.x * 0.5 * 1.18, hh = this.frustum.y * 0.5 * 1.18;
const camZ = Math.abs(this.camera.position.z);
for (let i = 0; i < this.N; i++) {
const edge = Math.floor(rnd() * 4);
const t = rnd();
let x = 0, y = 0;
if (edge === 0) { x = -hw; y = (t * 2 - 1) * hh; }
else if (edge === 1) { x = hw; y = (t * 2 - 1) * hh; }
else if (edge === 2) { y = hh; x = (t * 2 - 1) * hw; }
else { y = -hh; x = (t * 2 - 1) * hw; }
const z = (rnd() - 0.4) * camZ * 0.6;
const perimT = (edge + t) / 4;
data[i * 4] = x; data[i * 4 + 1] = y; data[i * 4 + 2] = z; data[i * 4 + 3] = perimT;
}
const tex = new THREE.DataTexture(data, this.TEX, this.TEX, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter;
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
this.renderer.initTexture(tex); // force GPU upload (three.js #15882)
return tex;
}
private dataTex(fill: (i: number, out: Float32Array, o: number) => void): THREE.DataTexture {
const data = new Float32Array(this.N * 4);
for (let i = 0; i < this.N; i++) fill(i, data, i * 4);
const tex = new THREE.DataTexture(data, this.TEX, this.TEX, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter;
tex.wrapS = THREE.ClampToEdgeWrapping; tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false;
tex.needsUpdate = true;
// FORCE GPU upload now. A procedurally-built DataTexture passed as a custom
// sampler2D into a GPUComputationRenderer pass reads BLACK until uploaded
// (three.js #15882). initTexture uploads it before the first compute().
this.renderer.initTexture(tex);
return tex;
}
// three target shapes the cloud reforms into
private buildTargets(seed: number) {
let s = seed * 1.7 + 5;
const rnd = () => { s = Math.sin(s) * 43758.5453; return s - Math.floor(s); };
const scale = this.frustum.y * 0.42;
// A: BRAIN — two squashed hemispheres + sulci wrinkle, volumetric fill
const brain = this.dataTex((i, out, o) => {
const r = Math.cbrt(rnd()); // volumetric (denser core)
const theta = rnd() * Math.PI * 2;
const phi = Math.acos(2 * rnd() - 1);
let x = Math.sin(phi) * Math.cos(theta);
let y = Math.cos(phi) * 0.92;
let z = Math.sin(phi) * Math.sin(theta) * 1.18;
x += Math.sign(x) * 0.34; // split into two lobes
const wrinkle = 1 + 0.07 * Math.sin(8 * x) * Math.sin(7 * y);
out[o] = x * r * scale * wrinkle;
out[o + 1] = y * r * scale * wrinkle;
out[o + 2] = z * r * scale * wrinkle;
out[o + 3] = rnd() < 0.45 ? 1 : 0;
});
// B: GRAPH CONSTELLATION — Fibonacci-sphere nodes + edge filaments
const M = 600, GA = Math.PI * (3 - Math.sqrt(5));
const nodes: [number, number, number][] = [];
for (let k = 0; k < M; k++) {
const y = 1 - (k / (M - 1)) * 2, rr = Math.sqrt(1 - y * y), t = GA * k;
nodes.push([Math.cos(t) * rr * scale * 1.2, y * scale * 1.2, Math.sin(t) * rr * scale * 1.2]);
}
const constellation = this.dataTex((i, out, o) => {
if (rnd() < 0.35) {
const n = nodes[Math.floor(rnd() * M)];
out[o] = n[0] + (rnd() - 0.5) * 0.4; out[o + 1] = n[1] + (rnd() - 0.5) * 0.4; out[o + 2] = n[2] + (rnd() - 0.5) * 0.4;
out[o + 3] = 1;
} else {
const a = nodes[Math.floor(rnd() * M)], b = nodes[Math.floor(rnd() * M)];
const m = rnd();
out[o] = a[0] + (b[0] - a[0]) * m; out[o + 1] = a[1] + (b[1] - a[1]) * m; out[o + 2] = a[2] + (b[2] - a[2]) * m;
out[o + 3] = 0;
}
});
// C: NEURAL LATTICE — hash-jittered 3D grid
const G = 7, cell = (scale * 2) / G;
const lattice = this.dataTex((i, out, o) => {
const cx = Math.floor(rnd() * G), cy = Math.floor(rnd() * G), cz = Math.floor(rnd() * G);
const onStrut = rnd() < 0.4;
let x = (cx + 0.5) * cell - scale, y = (cy + 0.5) * cell - scale, z = (cz + 0.5) * cell - scale;
if (onStrut) {
const axis = Math.floor(rnd() * 3), m = rnd();
if (axis === 0) x += (m - 0.5) * cell;
else if (axis === 1) y += (m - 0.5) * cell;
else z += (m - 0.5) * cell;
out[o + 3] = 0;
} else {
x += (rnd() - 0.5) * 0.3 * cell; y += (rnd() - 0.5) * 0.3 * cell; z += (rnd() - 0.5) * 0.3 * cell;
out[o + 3] = 1;
}
out[o] = x; out[o + 1] = y; out[o + 2] = z;
});
this.targets = [brain, constellation, lattice];
}
private buildPoints(seed: number) {
const refs = new Float32Array(this.N * 2);
// texel CENTERS (+0.5) so render samples exactly the texel the sim writes.
// Using texel corners (i/TEX) reads a neighbor -> particles never move.
for (let i = 0; i < this.N; i++) {
refs[i * 2] = ((i % this.TEX) + 0.5) / this.TEX;
refs[i * 2 + 1] = (Math.floor(i / this.TEX) + 0.5) / this.TEX;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(this.N * 3), 3));
geo.setAttribute('reference', new THREE.BufferAttribute(refs, 2));
const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1;
this.material = new THREE.ShaderMaterial({
uniforms: {
texturePosition: { value: null }, textureVelocity: { value: null },
uSize: { value: 38 }, uDpr: { value: Math.min(window.devicePixelRatio, 2) },
uPhaseT: { value: 0 }, uTime: { value: 0 },
uViolet: { value: new THREE.Color(0x6366f1) }, uCyan: { value: new THREE.Color(0x22d3ee) },
uEmerald: { value: new THREE.Color(0x34d399) },
uTextCenter: { value: new THREE.Vector2(0.5, 0.46) }, uResolution: { value: new THREE.Vector2(w, h) }
},
transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending,
vertexShader: RENDER_VERT, fragmentShader: RENDER_FRAG
});
this.points = new THREE.Points(geo, this.material);
this.points.frustumCulled = false;
// Push the whole cloud DOWN so shapes cut off near the top, leaving a clean
// band at the very top of the viewport for the fixed launch sign.
this.points.position.y = -this.frustum.y * 0.16;
this.scene.add(this.points);
}
private onResize() {
const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1;
this.renderer.setSize(w, h); this.composer.setSize(w, h);
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.computeFrustum();
(this.material.uniforms.uResolution.value as THREE.Vector2).set(w, h);
}
private animate = () => {
if (this.disposed) return;
this.raf = requestAnimationFrame(this.animate);
const dt = Math.min(this.clock.getDelta(), 1 / 30);
const t = this.clock.elapsedTime;
let phase = (t % LOOP) / LOOP;
if (this.reduced) phase = 0.66; // freeze in HOLD on the brain
if (this.forcePhase !== undefined) phase = this.forcePhase; // debug freeze
// loop wrap -> advance shape + fresh blast time (unless a shape is forced)
if (this.forceShape === undefined && !this.reduced && this.prevPhase > 0.9 && phase < 0.1) {
this.shapeIndex = (this.shapeIndex + 1) % 9;
(this.velVar.material.uniforms as any).uShape.value = this.shapeIndex;
}
// fire the blast once when crossing into the SLAM window
if (!this.reduced && this.prevPhase < 0.2 && phase >= 0.2) {
this.uBlastTime = t;
}
this.prevPhase = phase;
const vu = this.velVar.material.uniforms as any;
vu.uTime.value = t; vu.uPhaseT.value = phase; vu.uDt.value = dt; vu.uBlastTime.value = this.uBlastTime;
(this.posVar.material.uniforms as any).uDt.value = dt;
this.gpu.compute();
this.material.uniforms.texturePosition.value = this.gpu.getCurrentRenderTarget(this.posVar).texture;
this.material.uniforms.textureVelocity.value = this.gpu.getCurrentRenderTarget(this.velVar).texture;
this.material.uniforms.uPhaseT.value = phase;
this.material.uniforms.uTime.value = t;
// 3D rotation for MATH shapes (mesmerizing); but TEXT beats (even index)
// must stay flat-on and upright so the message is readable. Ease the
// rotation to 0 during text beats.
// 3D rotation so every shape reads volumetrically (a static galaxy/knot
// looks flat; a slowly turning one is mesmerizing).
this.points.rotation.y = t * 0.18 * (this.reduced ? 0 : 1);
this.points.rotation.x = Math.sin(t * 0.13) * 0.22 * (this.reduced ? 0 : 1);
this.composer.render();
};
dispose() {
this.disposed = true;
cancelAnimationFrame(this.raf);
window.removeEventListener('resize', this.onResize);
this.material?.dispose();
this.points?.geometry.dispose();
this.targets.forEach((t) => t.dispose());
this.composer?.dispose();
this.renderer?.dispose();
if (this.renderer?.domElement?.parentNode === this.host) this.host.removeChild(this.renderer.domElement);
}
}

View file

@ -0,0 +1,148 @@
<script lang="ts">
// Ambient outer field: god-ray glow radiating from the center (so the outer
// frame looks CAUSED BY the storm, not decorated) + a parallax starfield that
// fills the corners with quiet depth. Pure 2D canvas + CSS, engine-agnostic,
// sits BEHIND the WebGPU/WebGL storm. ~one cheap canvas, holds 60fps.
import { onMount, onDestroy } from 'svelte';
interface Props {
seed?: number;
reducedMotion?: boolean;
}
let { seed = 1234, reducedMotion = false }: Props = $props();
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
let raf = 0;
let disposed = false;
type Star = { x: number; y: number; z: number; r: number; tw: number; hue: number };
let stars: Star[] = [];
let w = 0, h = 0, dpr = 1;
let pointer = { x: 0.5, y: 0.5 };
function mulberry32(a: number) {
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function build() {
if (!canvas) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
const rnd = mulberry32(seed || 1);
// density rises toward the EDGES (rejection sample away from center)
const count = Math.floor((w * h) / 9000);
stars = [];
let guard = 0;
while (stars.length < count && guard < count * 6) {
guard++;
const x = rnd(), y = rnd();
const dx = x - 0.5, dy = y - 0.5;
const edge = Math.min(1, Math.hypot(dx, dy) * 2); // 0 center -> 1 corner
if (rnd() > edge * 0.85 + 0.06) continue; // keep more near edges
const z = 0.2 + rnd() * 0.8; // depth -> parallax + size
const hue = [262, 190, 152][Math.floor(rnd() * 3)]; // violet/cyan/emerald
stars.push({ x, y, z, r: (0.4 + rnd() * 1.4) * z, tw: rnd() * Math.PI * 2, hue });
}
}
function draw(t: number) {
if (disposed || !canvas) return;
raf = requestAnimationFrame(draw);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cx = w / 2, cy = h * 0.46;
const time = reducedMotion ? 0 : t * 0.001;
// --- god-ray glow: violet/cyan/emerald light radiating from the storm ---
const breathe = 0.85 + 0.15 * Math.sin(time * 0.6);
const maxR = Math.hypot(w, h) * 0.62 * breathe;
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR);
g.addColorStop(0.0, 'rgba(120, 90, 240, 0.22)');
g.addColorStop(0.22, 'rgba(60, 120, 230, 0.14)');
g.addColorStop(0.5, 'rgba(40, 180, 160, 0.07)');
g.addColorStop(1.0, 'rgba(5, 6, 12, 0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
// faint rotating light shafts for the "rays" read
if (!reducedMotion) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(time * 0.04);
const shafts = 7;
for (let i = 0; i < shafts; i++) {
ctx.rotate((Math.PI * 2) / shafts);
const sg = ctx.createLinearGradient(0, 0, maxR, 0);
sg.addColorStop(0, 'rgba(90,140,235,0.05)');
sg.addColorStop(1, 'rgba(5,6,12,0)');
ctx.fillStyle = sg;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(maxR, -maxR * 0.06);
ctx.lineTo(maxR, maxR * 0.06);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// --- parallax starfield filling the corners ---
const px = (pointer.x - 0.5), py = (pointer.y - 0.5);
for (const s of stars) {
const driftX = reducedMotion ? 0 : Math.sin(time * 0.05 * s.z + s.tw) * 6 * s.z;
const sx = s.x * w + driftX - px * 40 * s.z;
const sy = s.y * h - py * 40 * s.z;
const tw = reducedMotion ? 0.7 : 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(time * 1.5 + s.tw));
ctx.beginPath();
ctx.fillStyle = `hsla(${s.hue}, 80%, 75%, ${0.55 * tw})`;
ctx.arc(sx, sy, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
function onPointer(e: PointerEvent) {
pointer.x = e.clientX / window.innerWidth;
pointer.y = e.clientY / window.innerHeight;
}
function onResize() { build(); }
onMount(() => {
build();
raf = requestAnimationFrame(draw);
window.addEventListener('resize', onResize);
if (!reducedMotion) window.addEventListener('pointermove', onPointer, { passive: true });
});
onDestroy(() => {
disposed = true;
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
window.removeEventListener('pointermove', onPointer);
});
</script>
<canvas class="ambient" bind:this={canvas} aria-hidden="true"></canvas>
<style>
.ambient {
position: fixed;
inset: 0;
z-index: 0;
width: 100vw;
height: 100vh;
height: 100svh;
display: block;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,138 @@
<script lang="ts">
// Fullscreen living hero. Uses the REAL Memory Cinema WebGPU storm engine
// (CinemaSandbox) where WebGPU is available, and falls back to the WebGL
// NeuralFlow field otherwise. Never modifies Memory Cinema itself.
import { onMount, onDestroy } from 'svelte';
import * as THREE from 'three';
import type { CinemaSandbox } from '$lib/graph/cinema/sandbox';
import type { SemanticRole } from '$lib/graph/cinema/storm';
import { NeuralFlow } from '$lib/landing/neuralFlow';
interface Props {
/** Deterministic seed so each visitor's hero is one of one. */
seed?: number;
reducedMotion?: boolean;
}
let { seed = 1234, reducedMotion = false }: Props = $props();
let host = $state<HTMLDivElement | undefined>(undefined);
let sandbox: CinemaSandbox | null = null;
let fallback: NeuralFlow | null = null;
let raf = 0;
let usingWebGPU = $state(false);
let last = 0;
// Cinema's three storm "worlds". We cycle them slowly so the hero keeps
// transforming (the explode -> reform look) without the full Cinema director.
const ROLES: SemanticRole[] = ['anchor', 'connection', 'contradiction'];
let roleIndex = 0;
const ORIGIN = new THREE.Vector3(0, 0, 0);
// pointer for the fallback's liquid cursor
function onPointerMove(e: PointerEvent) {
if (!fallback || !host) return;
const r = host.getBoundingClientRect();
const nx = ((e.clientX - r.left) / r.width) * 2 - 1;
const ny = -(((e.clientY - r.top) / r.height) * 2 - 1);
fallback.setCursor(nx, ny);
}
async function bootSandbox(): Promise<boolean> {
if (!host) return false;
try {
const { CinemaSandbox, isWebGPUSupported } = await import('$lib/graph/cinema/sandbox');
if (!isWebGPUSupported()) return false;
sandbox = new CinemaSandbox(host);
await sandbox.boot();
// Kick the first world: the storm forms from its initial cloud.
sandbox.transitionTo('anchor', ORIGIN, 'I', 0);
if (!reducedMotion) sandbox.setFlythrough(0.45);
return true;
} catch (e) {
console.warn('[hero] WebGPU sandbox unavailable, using WebGL fallback:', e);
sandbox?.dispose();
sandbox = null;
return false;
}
}
function bootFallback() {
if (!host) return;
fallback = new NeuralFlow(host, { seed, reducedMotion });
window.addEventListener('pointermove', onPointerMove, { passive: true });
}
// camera orbit for the sandbox: slow auto-rotate so the storm breathes; the
// sandbox re-clamps distance + looks at origin every frame.
let camAngle = 0;
function driveSandboxCamera(dt: number) {
if (!sandbox) return;
const cam = sandbox.cameraRef;
camAngle += dt * 0.06 * (reducedMotion ? 0 : 1);
const radius = 58;
cam.position.set(Math.sin(camAngle) * radius, 14 + Math.sin(camAngle * 0.5) * 6, Math.cos(camAngle) * radius);
}
let roleTimer = 0;
async function loop(now: number) {
raf = requestAnimationFrame(loop);
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (sandbox) {
driveSandboxCamera(dt);
// cycle storm worlds every ~7s for the perpetual transform
roleTimer += dt;
if (roleTimer > 7 && !reducedMotion) {
roleTimer = 0;
roleIndex = (roleIndex + 1) % ROLES.length;
sandbox.transitionTo(ROLES[roleIndex], ORIGIN, 'I', roleIndex);
}
try {
await sandbox.render(dt);
} catch {
/* one bad frame must not kill the loop */
}
}
// the WebGL fallback runs its own internal rAF, so nothing to do here
}
onMount(() => {
(async () => {
usingWebGPU = await bootSandbox();
if (usingWebGPU) {
last = performance.now();
raf = requestAnimationFrame(loop);
} else {
bootFallback();
}
})();
});
onDestroy(() => {
cancelAnimationFrame(raf);
window.removeEventListener('pointermove', onPointerMove);
sandbox?.dispose();
fallback?.dispose();
sandbox = null;
fallback = null;
});
</script>
<div class="hero-stage" bind:this={host} aria-hidden="true"></div>
<style>
.hero-stage {
position: fixed;
inset: 0;
z-index: 0;
width: 100vw;
height: 100vh;
height: 100svh;
}
.hero-stage :global(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
</style>

View file

@ -0,0 +1,174 @@
<script lang="ts">
// Living neural launch sign: VESTIGE / JULY 14TH / SIGN UP NOW built from REAL
// grown dendrites (space colonization on the letterforms) with synapse bulbs,
// multi-tier bloom, energy burst, and draw-on growth. Not glow-on-text.
import { onMount } from 'svelte';
import { growSign, type DendriteSign } from '$lib/landing/dendriteGen';
let sign = $state<DendriteSign | null>(null);
let reduced = $state(false);
onMount(() => {
reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
// grow the dendrites once after fonts are ready (so glyph metrics are right)
const grow = () => {
try {
sign = growSign();
} catch (e) {
console.warn('[neural-sign] grow failed:', e);
}
};
if (document.fonts?.ready) {
document.fonts.load('900 150px Inter').then(() => document.fonts.ready).then(grow).catch(grow);
} else {
grow();
}
});
// energy-burst streaks (radial, behind the letters)
const streaks = Array.from({ length: 30 }, (_, i) => i);
const cx = 500;
const cy = 90;
</script>
<div class="neural-sign" class:reduced aria-label="Vestige launches July 14th, sign up now">
{#if sign}
<svg
viewBox={`0 0 ${sign.width} ${sign.height}`}
class="sign-svg"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<filter id="ns-bloom" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur in="SourceAlpha" stdDeviation="1.6" result="b1" />
<feColorMatrix in="b1" values="0 0 0 0 .22 0 0 0 0 1 0 0 0 0 .62 0 0 0 1 0" result="g1" />
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="b2" />
<feColorMatrix in="b2" values="0 0 0 0 .13 0 0 0 0 .82 0 0 0 0 1 0 0 0 .85 0" result="g2" />
<feGaussianBlur in="SourceAlpha" stdDeviation="12" result="b3" />
<feColorMatrix in="b3" values="0 0 0 0 .70 0 0 0 0 .42 0 0 0 0 1 0 0 0 .6 0" result="g3" />
<feMerge>
<feMergeNode in="g3" /><feMergeNode in="g2" /><feMergeNode in="g1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<linearGradient id="ns-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#b388ff" />
<stop offset="45%" stop-color="#22d3ee" />
<stop offset="75%" stop-color="#39ff9d" />
<stop offset="100%" stop-color="#b388ff" />
</linearGradient>
</defs>
<!-- energy burst behind -->
<g class="streaks" filter="url(#ns-bloom)">
{#each streaks as i}
{@const ang = (i / streaks.length) * Math.PI * 2}
<line
x1={cx + Math.cos(ang) * 36}
y1={cy + Math.sin(ang) * 22}
x2={cx + Math.cos(ang) * (240 + (i % 5) * 28)}
y2={cy + Math.sin(ang) * (140 + (i % 5) * 16)}
stroke="url(#ns-grad)"
stroke-width={1 + (i % 3)}
class="streak"
style={`--i:${i}`}
/>
{/each}
</g>
<!-- the grown dendrites -->
<g filter="url(#ns-bloom)">
<g class="dendrites">
{#each sign.paths as p (p.d)}
<path d={p.d} stroke={p.col} stroke-width={p.w} stroke-linecap="round" />
{/each}
</g>
<g class="synapses">
{#each sign.synapses as s, i (i)}
<circle cx={s.x} cy={s.y} r={s.r} fill="#cffff0" class="syn" style={`--i:${i}`} />
{/each}
</g>
</g>
</svg>
{/if}
</div>
<style>
.neural-sign {
position: fixed;
top: clamp(0.1rem, 1vh, 0.8rem);
left: 50%;
transform: translateX(-50%);
z-index: 3;
width: min(680px, 94vw);
pointer-events: none;
}
.sign-svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
/* PERF: per-path stroke-dashoffset animation on 5700 paths tanks FPS to ~12
(SVG dashoffset is not GPU-compositable; full paint per path per frame).
Instead fade in the WHOLE dendrite group with ONE GPU-compositable opacity
transition. Static dendrites + one group fade = 120fps. */
.dendrites {
animation: ns-grow 1.6s ease-out both;
}
@keyframes ns-grow {
from {
opacity: 0;
transform: scale(0.92);
transform-origin: 50% 30%;
}
to {
opacity: 1;
transform: scale(1);
}
}
/* synapse nodes fire */
.syn {
transform-origin: center;
animation: ns-fire 2.6s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.073s);
}
@keyframes ns-fire {
0%,
100% {
opacity: 0.5;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.6);
}
}
/* energy streaks shimmer */
.streak {
opacity: 0.3;
animation: ns-streak 3.4s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.07s);
}
@keyframes ns-streak {
0%,
100% {
opacity: 0.15;
}
50% {
opacity: 0.6;
}
}
.reduced .dendrites {
animation: none;
}
.reduced .syn,
.reduced .streak {
animation: none;
}
</style>

View file

@ -0,0 +1,196 @@
// Grows real neural dendrites from text glyphs via space colonization, in the
// browser (canvas + fonts available), and returns SVG-ready path/synapse data.
// Runs once on mount (~0.7s for 3 lines), deterministic via a seeded RNG so the
// same text always grows the same neural sign.
export interface DendritePath {
d: string;
col: string;
w: number;
len: number;
depth: number;
}
export interface Synapse {
x: number;
y: number;
r: number;
}
export interface DendriteSign {
paths: DendritePath[];
synapses: Synapse[];
width: number;
height: number;
}
// deterministic PRNG (mulberry32) so the sign is stable across loads
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface Node {
x: number;
y: number;
parent: number;
w: number;
}
function growLine(text: string, fontPx: number, iters: number, rand: () => number) {
const SEG = 2.2,
ATTRACT = 14,
KILL = 4,
PAD = 40;
const meas = document.createElement('canvas').getContext('2d')!;
meas.font = `900 ${fontPx}px Inter, Arial, sans-serif`;
const tw = Math.ceil(meas.measureText(text).width);
const W = tw + PAD * 2;
const H = Math.ceil(fontPx * 1.5);
const cv = document.createElement('canvas');
cv.width = W;
cv.height = H;
const ctx = cv.getContext('2d')!;
ctx.font = `900 ${fontPx}px Inter, Arial, sans-serif`;
ctx.fillStyle = '#fff';
ctx.textBaseline = 'middle';
ctx.fillText(text, PAD, H / 2);
const data = ctx.getImageData(0, 0, W, H).data;
const aAt = (x: number, y: number) =>
x < 0 || y < 0 || x >= W || y >= H ? 0 : data[(y * W + x) * 4 + 3];
const attractors: [number, number][] = [];
const inside: [number, number][] = [];
for (let y = 0; y < H; y += 2) {
for (let x = 0; x < W; x += 2) {
if (aAt(x, y) > 128) {
inside.push([x, y]);
attractors.push([x, y]);
const edge =
aAt(x + 2, y) <= 128 || aAt(x - 2, y) <= 128 || aAt(x, y + 2) <= 128 || aAt(x, y - 2) <= 128;
if (edge && rand() < 0.22) {
attractors.push([x + (rand() - 0.5) * 22, y + (rand() - 0.5) * 22]);
}
}
}
}
if (!inside.length) return { nodes: [] as Node[], W, H };
const bands: Record<number, [number, number]> = {};
for (const [x, y] of inside) {
const b = (x / 16) | 0;
if (!bands[b] || y > bands[b][1]) bands[b] = [x, y];
}
const nodes: Node[] = [];
for (const k in bands) nodes.push({ x: bands[k][0], y: bands[k][1], parent: -1, w: 1 });
let live = attractors.slice();
for (let it = 0; it < iters && live.length; it++) {
const infCount = new Array(nodes.length).fill(0);
const acc: [number, number][] = nodes.map(() => [0, 0]);
for (const a of live) {
let best = -1,
bd = ATTRACT * ATTRACT;
for (let i = 0; i < nodes.length; i++) {
const dx = nodes[i].x - a[0],
dy = nodes[i].y - a[1];
const d = dx * dx + dy * dy;
if (d < bd) {
bd = d;
best = i;
}
}
if (best >= 0) {
acc[best][0] += a[0] - nodes[best].x;
acc[best][1] += a[1] - nodes[best].y;
infCount[best]++;
}
}
const fresh: Node[] = [];
for (let i = 0; i < nodes.length; i++) {
if (!infCount[i]) continue;
const L = Math.hypot(acc[i][0], acc[i][1]) || 1;
fresh.push({
x: nodes[i].x + (SEG * acc[i][0]) / L + (rand() - 0.5) * 1.2,
y: nodes[i].y + (SEG * acc[i][1]) / L + (rand() - 0.5) * 1.2,
parent: i,
w: 1
});
}
if (!fresh.length) break;
const before = nodes.length;
for (const f of fresh) nodes.push(f);
live = live.filter((a) => {
for (let i = before; i < nodes.length; i++) {
const dx = nodes[i].x - a[0],
dy = nodes[i].y - a[1];
if (dx * dx + dy * dy < KILL * KILL) return false;
}
return true;
});
}
for (let i = nodes.length - 1; i > 0; i--) {
if (nodes[i].parent >= 0) nodes[nodes[i].parent].w += nodes[i].w * 0.045;
}
return { nodes, W, H };
}
/** Grow the full 3-line launch sign. Returns SVG-ready data centered to width 1000. */
export function growSign(): DendriteSign {
const rand = rng(0x5e57); // fixed seed -> stable sign
const palette = ['#39ff9d', '#22d3ee', '#b388ff'];
const specs = [
{ text: 'VESTIGE', px: 150, targetW: 940, iters: 600 },
{ text: 'JULY 14TH', px: 90, targetW: 620, iters: 400 },
{ text: 'SIGN UP NOW', px: 78, targetW: 700, iters: 400 }
];
const VBW = 1000;
const lines = specs.map((s) => {
const res = growLine(s.text, s.px, s.iters, rand);
const scale = s.targetW / Math.max(1, res.W);
return { res, scale, scaledH: res.H * scale };
});
const VBH = lines.reduce((a, l) => a + l.scaledH, 0);
const paths: DendritePath[] = [];
const synapses: Synapse[] = [];
let yCursor = 0;
let colBase = 0;
for (const line of lines) {
const { res, scale, scaledH } = line;
const offX = (VBW - res.W * scale) / 2;
const offY = yCursor;
const childCount = new Array(res.nodes.length).fill(0);
for (const n of res.nodes) if (n.parent >= 0) childCount[n.parent]++;
for (let i = 0; i < res.nodes.length; i++) {
const n = res.nodes[i];
if (n.parent < 0) continue;
const p = res.nodes[n.parent];
const x1 = offX + n.x * scale,
y1 = offY + n.y * scale;
const x2 = offX + p.x * scale,
y2 = offY + p.y * scale;
const w = Math.max(0.5, Math.min(4.5, n.w * scale * 0.5));
const len = Math.hypot(x2 - x1, y2 - y1);
paths.push({
d: `M${x1.toFixed(1)} ${y1.toFixed(1)}L${x2.toFixed(1)} ${y2.toFixed(1)}`,
col: palette[(i + colBase) % 3],
w: Math.round(w * 10) / 10,
len: Math.round(len + 1),
depth: Math.round((i / res.nodes.length) * 30)
});
// synapse at junctions/tips — sparse, so the pulse animation stays cheap
// (animating thousands of SVG nodes kills FPS; ~40 per line is plenty).
if ((childCount[i] === 0 || childCount[i] >= 2) && rand() < 0.08) {
synapses.push({ x: Math.round(x1), y: Math.round(y1), r: Math.round((w * 0.9 + 1.4) * 10) / 10 });
}
}
yCursor += scaledH;
colBase += 1;
}
return { paths, synapses, width: VBW, height: Math.round(VBH) };
}

View file

@ -0,0 +1,420 @@
// Living Brain Neural Flow — a GPGPU particle organism.
//
// 65,536 particles simulated on the GPU via a TWO-variable GPUComputationRenderer
// (position + velocity, ping-ponged). Flow runs TANGENT to a two-lobe brain SDF
// so particles glide along the cortex instead of dispersing into a starfield.
// The cursor parts the field like liquid (speed-gated repulsion + signed swirl)
// and it heals back to the brain via a Hooke spring. Color is physical thin-film
// iridescence (spectral_zucconi6), not HSV. An entrance timeline inhales chaos
// into a dense, breathing, iridescent brain.
//
// Built from a researched spec (docs/launch/living-brain-hero-spec.json).
// WebGL2, ships everywhere. Degrades to 16,384 particles on low-end.
import * as THREE from 'three';
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
export interface NeuralFlowOptions {
seed?: number;
reducedMotion?: boolean;
}
// ---- shared GLSL: brain SDF + curl-of-SDF-modulated-noise (divergence free) --
const GLSL_COMMON = /* glsl */ `
vec3 hash3(vec3 p){
p = vec3(dot(p,vec3(127.1,311.7,74.7)),
dot(p,vec3(269.5,183.3,246.1)),
dot(p,vec3(113.5,271.9,124.6)));
return -1.0 + 2.0*fract(sin(p)*43758.5453123);
}
float snoise(vec3 p){
vec3 i = floor(p); vec3 f = fract(p);
vec3 u = f*f*(3.0-2.0*f);
return mix(mix(mix(dot(hash3(i+vec3(0,0,0)),f-vec3(0,0,0)),
dot(hash3(i+vec3(1,0,0)),f-vec3(1,0,0)),u.x),
mix(dot(hash3(i+vec3(0,1,0)),f-vec3(0,1,0)),
dot(hash3(i+vec3(1,1,0)),f-vec3(1,1,0)),u.x),u.y),
mix(mix(dot(hash3(i+vec3(0,0,1)),f-vec3(0,0,1)),
dot(hash3(i+vec3(1,0,1)),f-vec3(1,0,1)),u.x),
mix(dot(hash3(i+vec3(0,1,1)),f-vec3(0,1,1)),
dot(hash3(i+vec3(1,1,1)),f-vec3(1,1,1)),u.x),u.y),u.z);
}
float sdEllipsoid(vec3 p, vec3 r){ float k0=length(p/r); float k1=length(p/(r*r)); return k0*(k0-1.0)/k1; }
float brainSDF(vec3 p){
float L = sdEllipsoid(p-vec3(-0.32,0.0,0.0), vec3(0.85,0.95,1.15));
float R = sdEllipsoid(p-vec3( 0.32,0.0,0.0), vec3(0.85,0.95,1.15));
float d = min(L,R);
d += 0.06*sin(8.0*p.x)*sin(7.0*p.y)*sin(6.0*p.z); // sulci/gyri wrinkles
return d;
}
vec3 sdfNormal(vec3 p){
const vec2 e = vec2(1.0,-1.0)*0.004;
return normalize(e.xyy*brainSDF(p+e.xyy)+e.yyx*brainSDF(p+e.yyx)+
e.yxy*brainSDF(p+e.yxy)+e.xxx*brainSDF(p+e.xxx));
}
vec3 potential(vec3 p, float t){ return sdfNormal(p)*snoise(p*1.6 + vec3(0.0,0.0,t*0.15)); }
vec3 curlNoise(vec3 p, float t){
const float e=0.012; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);
float x=potential(p+dy,t).z-potential(p-dy,t).z-(potential(p+dz,t).y-potential(p-dz,t).y);
float y=potential(p+dz,t).x-potential(p-dz,t).x-(potential(p+dx,t).z-potential(p-dx,t).z);
float z=potential(p+dx,t).y-potential(p-dx,t).y-(potential(p+dy,t).x-potential(p-dy,t).x);
return vec3(x,y,z)/(2.0*e);
}
// center-weighted reseed point on the brain shell from a uv hash
vec3 shellPoint(vec2 uv, float seed){
vec3 h = hash3(vec3(uv*97.0, seed));
float rad = pow(fract(h.x*0.5+0.5), 1.5);
float theta = (h.y*0.5+0.5)*6.2831853;
float phi = acos(2.0*(h.z*0.5+0.5)-1.0);
vec3 p = vec3(sin(phi)*cos(theta), sin(phi)*sin(theta)*0.85, cos(phi));
p *= mix(0.25, 1.25, rad);
p.x += sign(h.x)*0.32; // bias toward a lobe
p -= sdfNormal(p)*brainSDF(p)*0.85; // snap onto the shell
return p;
}
`;
// ---- VELOCITY shader: the 4-force liquid + brain physics --------------------
const VELOCITY_SHADER = /* glsl */ `
uniform float uTime, uDt, uForm;
uniform vec2 uMouse, uMouseVel;
uniform float uMouseSpeed, uAspect, uCalm;
${GLSL_COMMON}
void main(){
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 P = texture2D( texturePosition, uv );
vec4 V = texture2D( textureVelocity, uv );
vec3 pos = P.xyz; vec3 vel = V.xyz;
vec3 accel = vec3(0.0);
float pulse = 0.5 + 0.5*sin(uTime*2.0);
// (4) ambient SDF-tangent brain flow + gentle shell restoring (loose, so the
// cloud keeps visible structure instead of collapsing to a dense membrane)
vec3 flow = curlNoise(pos, uTime) * (1.4 * (1.0 - uCalm*0.85));
float restore = (1.8 + 1.0*pulse) * uForm;
flow += -sdfNormal(pos) * brainSDF(pos) * restore;
accel += flow;
// (3) HEAL: spring back toward this particle's brain-home
vec3 home = shellPoint(uv, 3.0);
accel += (home - pos) * (0.012 * uForm);
// (1)+(2) liquid cursor: speed-gated gaussian repulsion + signed swirl
vec2 q = (pos.xy - uMouse); q.x *= uAspect;
float fall = exp(-dot(q,q) / (0.10));
vec2 rdir = normalize(pos.xy - uMouse + 1e-5);
accel.xy += rdir * fall * 0.9 * uMouseSpeed;
vec2 tangent = vec2(-rdir.y, rdir.x);
float swirlSign = sign(uMouseVel.x*rdir.y - uMouseVel.y*rdir.x);
accel.xy += tangent * swirlSign * fall * 0.9 * uMouseSpeed;
vel = (vel + accel*uDt) * 0.82;
float sp = length(vel);
float maxSpeed = 3.0;
if (sp > maxSpeed) vel *= maxSpeed/sp;
gl_FragColor = vec4(vel, length(flow)); // .w carries curl magnitude for color
}
`;
// ---- POSITION shader: integrate + life/reseed -------------------------------
const POSITION_SHADER = /* glsl */ `
uniform float uTime, uDt, uForm;
${GLSL_COMMON}
void main(){
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 P = texture2D( texturePosition, uv );
vec4 V = texture2D( textureVelocity, uv );
vec3 pos = P.xyz; float life = P.w;
pos += V.xyz * uDt;
life -= uDt * 0.025;
// reseed if it died OR wandered too far from the brain (keeps the cloud bounded)
if (life < 0.0 || brainSDF(pos) > 1.8) {
pos = shellPoint(uv, floor(uTime*0.7));
life = 2.0 + fract(sin(dot(uv,vec2(12.9,78.2)))*43758.5)*3.0;
}
gl_FragColor = vec4(pos, life);
}
`;
const RENDER_VERT = /* glsl */ `
uniform sampler2D texturePosition;
uniform sampler2D textureVelocity;
uniform float uSize, uForm;
attribute vec2 aRef;
varying float vCurl;
varying float vSpeed;
varying vec3 vViewN;
varying vec3 vViewDir;
${GLSL_COMMON}
void main(){
vec4 P = texture2D(texturePosition, aRef);
vec4 V = texture2D(textureVelocity, aRef);
vec3 pos = P.xyz;
vCurl = V.w;
vSpeed = length(V.xyz);
vec3 n = sdfNormal(pos);
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
vViewN = normalize(normalMatrix * n);
vViewDir = normalize(-mv.xyz);
float dist = -mv.z;
gl_PointSize = uSize * (0.6 + 2.0*clamp(vSpeed*0.6,0.04,1.0)) * (300.0/dist) * uForm;
gl_Position = projectionMatrix * mv;
}
`;
const RENDER_FRAG = /* glsl */ `
precision highp float;
varying float vCurl;
varying float vSpeed;
varying vec3 vViewN;
varying vec3 vViewDir;
uniform float uTime;
// verified spectral fit (Zucconi 6) wavelength[400..700] -> RGB
vec3 bump3y(vec3 x, vec3 yo){ vec3 y = 1.0 - x*x; return max(y - yo, 0.0); }
vec3 spectral_zucconi6(float w){
float x = clamp((w-400.0)/300.0, 0.0, 1.0);
const vec3 c1=vec3(3.54585104,2.93225262,2.41593945);
const vec3 x1=vec3(0.69549072,0.49228336,0.27699880);
const vec3 y1=vec3(0.02312639,0.15225084,0.52607955);
const vec3 c2=vec3(3.90307140,3.21182957,3.96587128);
const vec3 x2=vec3(0.11748627,0.86755042,0.66077860);
const vec3 y2=vec3(0.84897130,0.88445281,0.73949448);
return bump3y(c1*(x-x1), y1) + bump3y(c2*(x-x2), y2);
}
void main(){
vec2 uv = gl_PointCoord - 0.5;
float d2 = dot(uv,uv);
if (d2 > 0.25) discard;
// sharper core + soft halo so particles read as distinct glowing points
float soft = exp(-d2 * 14.0) + 0.35*exp(-d2 * 4.0);
float pulse = 0.5 + 0.5*sin(uTime*2.0);
float cosTheta = abs(dot(vViewDir, vViewN));
float thickness = 280.0 + vCurl*120.0 + 60.0*pulse;
float w = clamp(2.0*1.35*thickness*cosTheta, 400.0, 700.0);
vec3 irid = spectral_zucconi6(w);
float fres = pow(1.0 - clamp(dot(vViewN, vViewDir), 0.0, 1.0), 2.0);
vec3 indigo = vec3(0.20, 0.10, 0.55);
// blend toward full iridescence more readily so the violet/cyan/emerald show
vec3 col = mix(indigo, irid, 0.35 + 0.65*fres);
float brightness = 0.45 + 1.9*fres + vSpeed*0.6;
// MUCH lower per-particle energy: additive of 65k must stay see-through,
// a glittering constellation, not a solid white ball.
float energy = soft * (0.05 + 0.12*clamp(vSpeed,0.0,1.0));
gl_FragColor = vec4(col * brightness, energy);
}
`;
export class NeuralFlow {
private renderer: THREE.WebGLRenderer;
private scene = new THREE.Scene();
private camera: THREE.PerspectiveCamera;
private composer: EffectComposer;
private gpu: GPUComputationRenderer;
private posVar: ReturnType<GPUComputationRenderer['addVariable']>;
private velVar: ReturnType<GPUComputationRenderer['addVariable']>;
private points!: THREE.Points;
private material!: THREE.ShaderMaterial;
private clock = new THREE.Clock();
private raf = 0;
private host: HTMLElement;
private size: number;
private calm: number;
private disposed = false;
private mouse = new THREE.Vector2(0.5, 0.5);
private pmouse = new THREE.Vector2(0.5, 0.5);
private mouseVel = new THREE.Vector2(0, 0);
private mouseSpeed = 0;
private form = 0; // entrance progress 0..1
constructor(host: HTMLElement, opts: NeuralFlowOptions = {}) {
this.host = host;
this.calm = opts.reducedMotion ? 1 : 0;
const seed = (opts.seed ?? 1234) % 1000;
// perf: drop to 128 on low-end
const lowEnd = window.devicePixelRatio > 2.2 || navigator.hardwareConcurrency <= 4;
this.size = lowEnd ? 128 : 256;
const w = host.clientWidth || 1;
const h = host.clientHeight || 1;
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
this.renderer.setSize(w, h);
this.renderer.setClearColor(0x04050a, 0);
host.appendChild(this.renderer.domElement);
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
this.camera.position.set(0, 0, 6.4);
this.gpu = new GPUComputationRenderer(this.size, this.size, this.renderer);
const pos0 = this.gpu.createTexture();
const vel0 = this.gpu.createTexture();
this.fillInitial(pos0, vel0, seed);
this.posVar = this.gpu.addVariable('texturePosition', POSITION_SHADER, pos0);
this.velVar = this.gpu.addVariable('textureVelocity', VELOCITY_SHADER, vel0);
this.gpu.setVariableDependencies(this.posVar, [this.posVar, this.velVar]);
this.gpu.setVariableDependencies(this.velVar, [this.posVar, this.velVar]);
const pu = this.posVar.material.uniforms;
pu.uTime = { value: 0 }; pu.uDt = { value: 0 }; pu.uForm = { value: 0 };
const vu = this.velVar.material.uniforms;
vu.uTime = { value: 0 }; vu.uDt = { value: 0 }; vu.uForm = { value: 0 };
vu.uMouse = { value: new THREE.Vector2(0.5, 0.5) };
vu.uMouseVel = { value: new THREE.Vector2(0, 0) };
vu.uMouseSpeed = { value: 0 };
vu.uAspect = { value: w / h };
vu.uCalm = { value: this.calm };
const err = this.gpu.init();
if (err) console.warn('[neuralflow] gpgpu init:', err);
this.buildPoints();
this.composer = new EffectComposer(this.renderer);
this.composer.addPass(new RenderPass(this.scene, this.camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(w, h), 0.5, 0.5, 0.82);
this.composer.addPass(bloom);
this.onResize = this.onResize.bind(this);
window.addEventListener('resize', this.onResize);
// entrance: ease form 0->1 over ~1.6s
this.clock.start();
this.animate();
}
private fillInitial(pos: THREE.DataTexture, vel: THREE.DataTexture, seed: number) {
const p = pos.image.data as Float32Array;
const v = vel.image.data as Float32Array;
let s = seed * 9301 + 49297;
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
for (let i = 0; i < p.length; i += 4) {
// start as a wide scattered cloud; the entrance pulls it into the brain
const r = 3.5 + rnd() * 4.0;
const th = rnd() * Math.PI * 2;
const ph = Math.acos(2 * rnd() - 1);
p[i] = r * Math.sin(ph) * Math.cos(th);
p[i + 1] = r * Math.sin(ph) * Math.sin(th);
p[i + 2] = r * Math.cos(ph);
p[i + 3] = rnd() * 2; // life
v[i] = 0; v[i + 1] = 0; v[i + 2] = 0; v[i + 3] = 0;
}
}
private buildPoints() {
const count = this.size * this.size;
const refs = new Float32Array(count * 2);
for (let i = 0; i < count; i++) {
refs[i * 2] = (i % this.size) / this.size;
refs[i * 2 + 1] = Math.floor(i / this.size) / this.size;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * 3), 3));
geo.setAttribute('aRef', new THREE.BufferAttribute(refs, 2));
this.material = new THREE.ShaderMaterial({
uniforms: {
texturePosition: { value: null },
textureVelocity: { value: null },
uSize: { value: (this.host.clientHeight || 800) * 0.0014 },
uForm: { value: 0 },
uTime: { value: 0 }
},
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
vertexShader: RENDER_VERT,
fragmentShader: RENDER_FRAG
});
this.points = new THREE.Points(geo, this.material);
this.points.frustumCulled = false;
this.scene.add(this.points);
}
setCursor(nx: number, ny: number) {
// nx, ny in -1..1; store as 0..1 mouse + velocity
const mx = nx * 0.5 + 0.5;
const my = ny * 0.5 + 0.5;
this.mouse.set(mx, my);
}
private onResize() {
const w = this.host.clientWidth || 1;
const h = this.host.clientHeight || 1;
this.renderer.setSize(w, h);
this.composer.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
(this.velVar.material.uniforms as any).uAspect.value = w / h;
this.material.uniforms.uSize.value = h * 0.0016;
}
private animate = () => {
if (this.disposed) return;
this.raf = requestAnimationFrame(this.animate);
const dt = Math.min(this.clock.getDelta(), 0.033);
const t = this.clock.elapsedTime;
// entrance: easeOutExpo to 1 over ~1.6s
const target = 1;
const ease = 1 - Math.pow(2, -10 * Math.min(t / 1.6, 1));
this.form = this.form + (target * ease - this.form) * 0.2;
// mouse velocity (EMA) + idle decay -> heal
const dx = (this.mouse.x - this.pmouse.x);
const dy = (this.mouse.y - this.pmouse.y);
const raw = Math.hypot(dx, dy);
this.mouseSpeed = this.mouseSpeed * 0.85 + raw * 12 * 0.15;
this.mouseSpeed *= 0.92;
this.mouseVel.set(dx, dy);
this.pmouse.copy(this.mouse);
const vu = this.velVar.material.uniforms as any;
vu.uTime.value = t; vu.uDt.value = dt; vu.uForm.value = this.form;
vu.uMouse.value.copy(this.mouse);
vu.uMouseVel.value.copy(this.mouseVel);
vu.uMouseSpeed.value = Math.min(this.mouseSpeed, 2.5);
const pu = this.posVar.material.uniforms as any;
pu.uTime.value = t; pu.uDt.value = dt; pu.uForm.value = this.form;
this.gpu.compute();
this.material.uniforms.texturePosition.value = this.gpu.getCurrentRenderTarget(this.posVar).texture;
this.material.uniforms.textureVelocity.value = this.gpu.getCurrentRenderTarget(this.velVar).texture;
this.material.uniforms.uForm.value = this.form;
this.material.uniforms.uTime.value = t;
this.points.rotation.y = t * 0.05 * (1 - this.calm);
this.points.rotation.x = Math.sin(t * 0.04) * 0.12;
this.composer.render();
};
dispose() {
this.disposed = true;
cancelAnimationFrame(this.raf);
window.removeEventListener('resize', this.onResize);
this.material?.dispose();
this.points?.geometry.dispose();
this.composer?.dispose();
this.renderer?.dispose();
if (this.renderer?.domElement?.parentNode === this.host) {
this.host.removeChild(this.renderer.domElement);
}
}
}

View file

@ -0,0 +1,183 @@
// Phantom Brain — deterministic "seed-from-identity" generative memory graph.
//
// The viral artifact engine: any string (GitHub handle, email, a typed memory)
// deterministically produces a unique, believable memory graph. Same input ->
// same brain, every time, on every device (no RNG, no backend). This is what
// makes the share artifact one-of-one per visitor while the page stays static.
//
// Output matches the dashboard's GraphNode/GraphEdge contract so it feeds the
// real MemoryCinema + 3D graph components unchanged.
import type { GraphNode, GraphEdge } from '$types';
// ---- deterministic hashing (FNV-1a 32-bit) + seeded PRNG (mulberry32) -------
function fnv1a(str: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- believable memory vocabulary, themed by node type ----------------------
const NODE_TYPES = ['decision', 'fact', 'preference', 'pattern', 'insight', 'identity'] as const;
const FRAGMENTS: Record<string, string[]> = {
decision: [
'Chose Postgres over Mongo for the ledger',
'Migrated auth to short-lived JWTs',
'Adopted a monorepo with pnpm workspaces',
'Picked Rust for the hot path',
'Standardized on trunk-based deploys',
'Moved embeddings on-device for privacy'
],
fact: [
'The API rate-limits at 600 req/min',
'Staging mirrors prod with seeded data',
'CI runs the full suite in 4 minutes',
'The retry budget is 3 with jitter',
'Feature flags live in the edge config',
'Cold starts dropped to 40ms after the rewrite'
],
preference: [
'Prefers small, reviewable PRs',
'Always writes the test first',
'Hates implicit any',
'Likes results over exceptions',
'Wants logs structured, never printf',
'Trusts source over memory'
],
pattern: [
'Reaches for a state machine when flows branch',
'Caches at the edge, invalidates on write',
'Wraps external calls in a circuit breaker',
'Names things for what they do, not how',
'Pushes side effects to the boundary',
'Treats deletes as tombstones, never hard'
],
insight: [
'The flaky test was a clock, not the code',
'Most latency was one N+1 query',
'The bug only reproduced under real load',
'The slow path was JSON, not the DB',
'Two services were fighting the same lock',
'The memory leak was an unclosed stream'
],
identity: [
'Ships fast, refuses to ship broken',
'Builds for the developer who comes after',
'Optimizes for the next maintainer',
'Treats taste as a feature',
'Defaults to the ambitious version',
'Owns the whole vertical slice'
]
};
const TAGS = [
'architecture', 'auth', 'performance', 'ci', 'database', 'privacy',
'refactor', 'incident', 'design', 'workflow', 'security', 'launch'
];
export interface PhantomBrain {
seed: string;
nodes: GraphNode[];
edges: GraphEdge[];
stats: {
memories: number;
connections: number;
topConcept: string;
dominantType: string;
};
}
/**
* Build a deterministic phantom brain from any identity string.
* @param identity GitHub handle, email, or a typed memory anything.
* @param size node count (default scales nicely for the hero, 28).
*/
export function seedPhantomBrain(identity: string, size = 28): PhantomBrain {
const clean = identity.trim().toLowerCase() || 'anonymous-builder';
const rng = mulberry32(fnv1a(`vestige:${clean}`));
const pick = <T>(arr: readonly T[]): T => arr[Math.floor(rng() * arr.length)];
const now = Date.now();
const typeCounts: Record<string, number> = {};
const nodes: GraphNode[] = [];
for (let i = 0; i < size; i++) {
const type = pick(NODE_TYPES);
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
const ageMs = Math.floor(rng() * 1000 * 60 * 60 * 24 * 120); // up to ~120 days
const created = new Date(now - ageMs).toISOString();
// Older memories decay; a few are freshly reinforced.
const retention = Math.max(0.12, Math.min(1, 1 - (ageMs / (1000 * 60 * 60 * 24 * 140)) + (rng() - 0.5) * 0.3));
const tagCount = 1 + Math.floor(rng() * 2);
const tags = Array.from({ length: tagCount }, () => pick(TAGS));
nodes.push({
id: `n${i}`,
label: pick(FRAGMENTS[type]),
type,
retention: Math.round(retention * 100) / 100,
tags: [...new Set(tags)],
createdAt: created,
updatedAt: created,
isCenter: i === 0
});
}
// Edges: a connected backbone (so the graph never fragments) plus seeded
// cross-links weighted toward shared tags / strong memories.
const edges: GraphEdge[] = [];
const edgeKey = new Set<string>();
const addEdge = (a: number, b: number, type: string) => {
if (a === b) return;
const k = a < b ? `${a}-${b}` : `${b}-${a}`;
if (edgeKey.has(k)) return;
edgeKey.add(k);
edges.push({
source: `n${a}`,
target: `n${b}`,
weight: Math.round((0.3 + rng() * 0.7) * 100) / 100,
type
});
};
// backbone
for (let i = 1; i < size; i++) addEdge(i, Math.floor(rng() * i), 'relates_to');
// cross-links — denser for a richer constellation
const extra = Math.floor(size * 1.4);
for (let i = 0; i < extra; i++) {
addEdge(Math.floor(rng() * size), Math.floor(rng() * size), pick(['supports', 'contradicts', 'relates_to', 'supersedes']));
}
const dominantType = Object.entries(typeCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'fact';
// Top concept = most-seen tag.
const tagFreq: Record<string, number> = {};
for (const n of nodes) for (const t of n.tags) tagFreq[t] = (tagFreq[t] ?? 0) + 1;
const topConcept = Object.entries(tagFreq).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'architecture';
return {
seed: clean,
nodes,
edges,
stats: {
memories: nodes.length,
connections: edges.length,
topConcept,
dominantType
}
};
}

View file

@ -21,71 +21,18 @@ import type {
} from '$types';
const BASE = '/api';
const DASHBOARD_TOKEN_STORAGE_KEY = 'vestige.dashboard.token';
const DASHBOARD_TOKEN_HEADER = 'X-Vestige-Dashboard-Token';
async function fetcher<T>(path: string, options?: RequestInit): Promise<T> {
const headers = withDashboardAuthHeaders(options?.headers);
const res = await fetch(`${BASE}${path}`, {
...options,
headers
headers: { 'Content-Type': 'application/json' },
...options
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
throw new Error(
`API ${res.status}: dashboard auth failed. Open Vestige from the CLI or set VESTIGE_AUTH_TOKEN.`
);
}
throw new Error(`API ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
}
function withDashboardAuthHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers);
if (!next.has('Content-Type')) {
next.set('Content-Type', 'application/json');
}
const token = getDashboardAuthToken();
if (token) {
next.set(DASHBOARD_TOKEN_HEADER, token);
next.set('Authorization', `Bearer ${token}`);
}
return next;
}
export function getDashboardAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(DASHBOARD_TOKEN_STORAGE_KEY);
}
export function installDashboardTokenFromLocation(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
if (!hash) return false;
const params = new URLSearchParams(hash);
const token = params.get('vestige_token') ?? params.get('token');
if (!token) return false;
window.localStorage.setItem(DASHBOARD_TOKEN_STORAGE_KEY, token);
params.delete('vestige_token');
params.delete('token');
const remainingHash = params.toString();
const nextUrl = `${window.location.pathname}${window.location.search}${
remainingHash ? `#${remainingHash}` : ''
}`;
window.history.replaceState(null, document.title, nextUrl);
return true;
}
async function downloadJson(path: string, filename: string): Promise<void> {
const res = await fetch(`${BASE}${path}`, {
headers: withDashboardAuthHeaders()
});
const res = await fetch(`${BASE}${path}`);
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);

View file

@ -1,6 +1,5 @@
import { writable, derived } from 'svelte/store';
import type { VestigeEvent } from '$types';
import { getDashboardAuthToken } from './api';
const MAX_EVENTS = 200;
@ -24,11 +23,9 @@ function createWebSocketStore() {
let reconnectAttempts = 0;
function connect(url?: string) {
const baseWsUrl = url || (window.location.port === '5173'
const wsUrl = url || (window.location.port === '5173'
? `ws://${window.location.hostname}:3927/ws`
: `ws://${window.location.host}/ws`);
const token = getDashboardAuthToken();
const wsUrl = token ? withToken(baseWsUrl, token) : baseWsUrl;
if (ws?.readyState === WebSocket.OPEN) return;
@ -110,12 +107,6 @@ function createWebSocketStore() {
};
}
function withToken(url: string, token: string): string {
const next = new URL(url, window.location.href);
next.searchParams.set('token', token);
return next.toString();
}
export const websocket = createWebSocketStore();
// Derived stores for specific event types

View file

@ -20,7 +20,6 @@
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import Icon, { type IconName } from '$lib/components/Icon.svelte';
import { initTheme } from '$stores/theme';
import { installDashboardTokenFromLocation } from '$stores/api';
let { children } = $props();
let showCommandPalette = $state(false);
@ -29,14 +28,14 @@
let dashboardPath = $derived(
$page.url.pathname.startsWith(base) ? $page.url.pathname.slice(base.length) || '/' : $page.url.pathname
);
let isMarketingRoute = $derived(dashboardPath === '/waitlist' || dashboardPath.startsWith('/waitlist/'));
let isMarketingRoute = $derived(
dashboardPath === '/waitlist' ||
dashboardPath.startsWith('/waitlist/') ||
dashboardPath === '/launch' ||
dashboardPath.startsWith('/launch/')
);
onMount(() => {
// Vestige opens the dashboard with the auth token in the URL fragment.
// Install it into localStorage (and scrub it from the address bar) BEFORE
// any API call or WebSocket connect, or the now-authenticated /api and /ws
// endpoints reject every request.
installDashboardTokenFromLocation();
if (!isMarketingRoute) {
websocket.connect();
}

View file

@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from 'svelte';
import MemoryCinema from '$lib/components/MemoryCinema.svelte';
import HeroNodeEngine from '$lib/hero/HeroNodeEngine.svelte';
import AmbientField from '$lib/landing/AmbientField.svelte';
import NeuralSign from '$lib/landing/NeuralSign.svelte';
import { seedPhantomBrain, type PhantomBrain } from '$lib/landing/phantomBrain';
// ---- the phantom brain: seeded from whatever the visitor gives us --------
let identity = $state('');
let brain = $state<PhantomBrain>(seedPhantomBrain('vestige'));
let hasSeeded = $state(false);
let heroSeed = $state(1234);
function seedToNumber(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0) % 100000;
}
function reseed(value: string) {
brain = seedPhantomBrain(value || 'vestige');
hasSeeded = value.trim().length > 0;
heroSeed = seedToNumber(value || 'vestige');
}
// ---- waitlist capture ----------------------------------------------------
type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
let email = $state('');
let submitState = $state<SubmitState>('idle');
let submitMessage = $state('');
const waitlistEndpoint = import.meta.env.VITE_WAITLIST_ENDPOINT as string | undefined;
async function join(e: SubmitEvent) {
e.preventDefault();
if (!email.includes('@')) {
submitState = 'error';
submitMessage = 'Enter an email so we can send your invite.';
return;
}
submitState = 'submitting';
submitMessage = '';
const payload = {
email: email.trim(),
name: identity.trim(),
plan: 'solo',
priority: 'sync',
source: 'vestige-launch',
createdAt: new Date().toISOString()
};
if (!waitlistEndpoint) {
// No endpoint wired yet — still give the visitor the win locally.
submitState = 'success';
submitMessage = `You're on the list. Your brain is one of one.`;
return;
}
try {
const res = await fetch(waitlistEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`Endpoint returned ${res.status}`);
submitState = 'success';
submitMessage = `You're on the list. Your brain is one of one.`;
} catch (err) {
submitState = 'error';
submitMessage = err instanceof Error ? err.message : 'Could not reach the waitlist.';
}
}
let mounted = $state(false);
let prefersReducedMotion = $state(false);
onMount(() => {
mounted = true;
prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
});
</script>
<svelte:head>
<title>Vestige · give your agent a brain you can watch think</title>
<meta
name="description"
content="Local-first memory for AI coding agents. Watch your agent remember, forget, and leave a receipt, rendered as a living brain."
/>
</svelte:head>
<main class="landing">
<!-- HERO: the live, seeded brain IS the product -->
<section class="hero">
<!-- Ambient outer field: god-ray glow radiating from the storm + parallax
starfield filling the corners, so the whole page feels alive edge to edge. -->
{#if mounted}
<AmbientField seed={heroSeed} reducedMotion={prefersReducedMotion} />
{/if}
<!-- Full-viewport node engine: particles stream in from the edges, slam
together and EXPLODE at center, reform into brain / graph / lattice,
then dissolve back out and loop. Owns the whole screen. -->
{#if mounted}
{#key heroSeed}
<HeroNodeEngine seed={heroSeed} reducedMotion={prefersReducedMotion} />
{/key}
{/if}
<!-- Radial readability mask: darkens toward the edges so the outer field
can be vivid while the headline core stays pristine. -->
<div class="readability-mask" aria-hidden="true"></div>
<!-- The full Memory Cinema overlay launches on demand from the CTA below. -->
{#if mounted}
<MemoryCinema nodes={brain.nodes} edges={brain.edges} centerId="n0" />
{/if}
<!-- Living neural launch sign at the very top -->
{#if mounted}
<NeuralSign />
{/if}
<div class="hero-overlay">
<div class="hud">
<span class="hud-dot"></span>
<span>{brain.stats.memories} memories</span>
<span class="hud-sep">·</span>
<span>{brain.stats.connections} connections</span>
<span class="hud-sep">·</span>
<span>150,000 GPU particles</span>
</div>
<h1 class="manifesto">
Your agent forgets everything.<br />
Your memory should be <em>yours</em>. Local, and beautiful.
</h1>
<p class="sub">
Vestige is a local-first memory for AI coding agents. Watch it remember,
forget, and leave a receipt, rendered as a brain you can fly through.
</p>
<div class="seed-row">
<input
class="seed-input"
placeholder="your github handle, or a memory…"
bind:value={identity}
oninput={(e) => reseed((e.target as HTMLInputElement).value)}
autocomplete="off"
spellcheck="false"
/>
<span class="seed-hint">
{hasSeeded ? `this brain is seeded from "${brain.seed}". one of one.` : 'type anything to seed your own brain'}
</span>
</div>
{#if submitState === 'success'}
<div class="success">
<strong>{submitMessage}</strong>
<p>We'll email you before launch. Top concept in your brain: <em>{brain.stats.topConcept}</em>.</p>
</div>
{:else}
<form class="capture" onsubmit={join}>
<input
class="email-input"
type="email"
placeholder="you@dev.com"
bind:value={email}
autocomplete="email"
/>
<button class="cta" type="submit" disabled={submitState === 'submitting'}>
{submitState === 'submitting' ? 'Joining…' : 'Claim your brain'}
</button>
</form>
{#if submitState === 'error'}
<p class="err">{submitMessage}</p>
{/if}
{/if}
</div>
</section>
</main>
<style>
:global(body) {
margin: 0;
background: #05060a;
}
.landing {
color: #e8eaf2;
font-family:
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.hero {
position: relative;
min-height: 100vh;
min-height: 100svh;
overflow: hidden;
background: radial-gradient(120% 120% at 50% 0%, #0b1020 0%, #05060a 60%);
}
.readability-mask {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
/* transparent core protects the headline, darkens into the vivid edges */
background: radial-gradient(
ellipse 70% 60% at 50% 46%,
transparent 0%,
transparent 32%,
rgba(5, 6, 12, 0.55) 78%,
rgba(5, 6, 12, 0.82) 100%
);
}
.hero-overlay {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 100vh;
min-height: 100svh;
padding: clamp(1.5rem, 5vw, 4rem);
gap: 1.5rem;
pointer-events: none;
background: radial-gradient(80% 60% at 50% 55%, rgba(5, 6, 10, 0.55) 0%, rgba(5, 6, 10, 0) 70%);
}
.hero-overlay > * {
pointer-events: auto;
}
.hud {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #8b93b0;
background: rgba(12, 16, 28, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 0.45rem 1rem;
backdrop-filter: blur(8px);
}
.hud-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 10px #34d399;
animation: pulse 2s ease-in-out infinite;
}
.hud-sep {
opacity: 0.4;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.manifesto {
font-size: clamp(2rem, 6vw, 4.25rem);
line-height: 1.05;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
max-width: 16ch;
text-wrap: balance;
text-shadow: 0 2px 40px rgba(5, 6, 10, 0.8);
}
.manifesto em {
font-style: italic;
background: linear-gradient(120deg, #a78bfa, #22d3ee, #34d399);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.sub {
font-size: clamp(1rem, 2vw, 1.2rem);
color: #aab2cc;
max-width: 52ch;
margin: 0;
line-height: 1.5;
text-shadow: 0 2px 30px rgba(5, 6, 10, 0.9);
}
.seed-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
width: min(440px, 90vw);
}
.seed-input {
width: 100%;
text-align: center;
font-size: 0.95rem;
padding: 0.7rem 1rem;
border-radius: 12px;
border: 1px solid rgba(167, 139, 250, 0.3);
background: rgba(12, 16, 28, 0.6);
color: #e8eaf2;
backdrop-filter: blur(8px);
outline: none;
transition: border-color 0.2s;
}
.seed-input:focus {
border-color: rgba(167, 139, 250, 0.8);
}
.seed-hint {
font-size: 0.78rem;
color: #7c84a0;
min-height: 1.2em;
}
.capture {
display: flex;
gap: 0.5rem;
width: min(440px, 90vw);
}
.email-input {
flex: 1;
font-size: 1rem;
padding: 0.85rem 1.1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(12, 16, 28, 0.7);
color: #e8eaf2;
outline: none;
backdrop-filter: blur(8px);
}
.email-input:focus {
border-color: rgba(34, 211, 238, 0.7);
}
.cta {
font-size: 1rem;
font-weight: 600;
padding: 0.85rem 1.4rem;
border-radius: 12px;
border: none;
cursor: pointer;
color: #05060a;
background: linear-gradient(120deg, #a78bfa, #22d3ee);
white-space: nowrap;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 6px 24px rgba(34, 211, 238, 0.25);
}
.cta:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 32px rgba(34, 211, 238, 0.4);
}
.cta:disabled {
opacity: 0.6;
cursor: default;
}
.success {
max-width: 46ch;
}
.success strong {
font-size: 1.25rem;
display: block;
margin-bottom: 0.4rem;
}
.success p {
color: #aab2cc;
margin: 0;
}
.success em {
color: #34d399;
font-style: normal;
font-weight: 600;
}
.err {
color: #fca5a5;
font-size: 0.85rem;
margin: 0;
}
</style>

View file

@ -0,0 +1,5 @@
// The launch landing page is a client-only WebGL experience (live 3D memory
// graph + Memory Cinema). Disable SSR so Three.js never runs during prerender;
// the page is still prerendered as a static shell that hydrates on the client.
export const ssr = false;
export const prerender = true;

View file

@ -12,11 +12,6 @@ pub mod static_files;
pub mod websocket;
use axum::Router;
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use std::net::SocketAddr;
use std::sync::Arc;
@ -30,8 +25,6 @@ use crate::cognitive::CognitiveEngine;
use state::AppState;
use vestige_core::Storage;
const DASHBOARD_TOKEN_HEADER: &str = "x-vestige-dashboard-token";
/// Build the axum router with all dashboard routes
pub fn build_router(
storage: Arc<Storage>,
@ -54,12 +47,30 @@ pub fn build_router_with_event_tx(
}
fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
let origin_strings = dashboard_allowed_origins(port);
let origins: Vec<HeaderValue> = origin_strings
.iter()
.map(|origin| origin.parse::<HeaderValue>().expect("valid origin"))
.collect();
let state = state.with_dashboard_allowed_origins(origin_strings);
#[allow(unused_mut)]
let mut origins = vec![
format!("http://127.0.0.1:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
format!("http://localhost:{}", port)
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
];
// SvelteKit dev server — only in debug builds
#[cfg(debug_assertions)]
{
origins.push(
"http://localhost:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
);
origins.push(
"http://127.0.0.1:5173"
.parse::<axum::http::HeaderValue>()
.expect("valid origin"),
);
}
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
@ -72,7 +83,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::HeaderName::from_static(DASHBOARD_TOKEN_HEADER),
]);
// Security: restrict WebSocket connections to localhost only (prevents cross-site WS hijacking)
@ -202,10 +212,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
.layer(
ServiceBuilder::new()
.concurrency_limit(50)
.layer(middleware::from_fn_with_state(
state.clone(),
require_dashboard_auth,
))
.layer(cors)
.layer(csp)
.layer(x_frame_options)
@ -218,92 +224,6 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
(router, state)
}
fn dashboard_allowed_origins(port: u16) -> Vec<String> {
#[allow(unused_mut)]
let mut origins = vec![
format!("http://127.0.0.1:{}", port),
format!("http://localhost:{}", port),
];
// SvelteKit dev server — only in debug builds.
#[cfg(debug_assertions)]
{
origins.push("http://localhost:5173".to_string());
origins.push("http://127.0.0.1:5173".to_string());
}
origins
}
async fn require_dashboard_auth(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
if !path.starts_with("/api/") || request.method() == Method::OPTIONS {
return next.run(request).await;
}
let headers = request.headers();
if let Err((status, message)) = validate_dashboard_origin(headers, &state) {
return (status, message).into_response();
}
if let Err((status, message)) = validate_dashboard_token(headers, &state) {
return (status, message).into_response();
}
next.run(request).await
}
fn validate_dashboard_origin(
headers: &HeaderMap,
state: &AppState,
) -> Result<(), (StatusCode, &'static str)> {
if let Some(fetch_site) = headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
&& fetch_site == "cross-site"
{
return Err((
StatusCode::FORBIDDEN,
"Cross-site dashboard request rejected",
));
}
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return Ok(());
};
if state.is_allowed_dashboard_origin(origin) {
Ok(())
} else {
Err((StatusCode::FORBIDDEN, "Dashboard origin not allowed"))
}
}
fn validate_dashboard_token(
headers: &HeaderMap,
state: &AppState,
) -> Result<(), (StatusCode, &'static str)> {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.or_else(|| {
headers
.get(DASHBOARD_TOKEN_HEADER)
.and_then(|value| value.to_str().ok())
})
.ok_or((StatusCode::UNAUTHORIZED, "Missing dashboard auth token"))?;
if state.is_valid_dashboard_token(token) {
Ok(())
} else {
Err((StatusCode::FORBIDDEN, "Invalid dashboard auth token"))
}
}
/// Start the dashboard HTTP server (blocking — use in CLI mode)
pub async fn start_dashboard(
storage: Arc<Storage>,
@ -317,11 +237,7 @@ pub async fn start_dashboard(
info!("Dashboard starting at http://127.0.0.1:{}", port);
if open_browser {
let url = format!(
"http://127.0.0.1:{}/dashboard#vestige_token={}",
port,
_state.dashboard_token_fragment_value()
);
let url = format!("http://127.0.0.1:{}", port);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = open::that(&url);

View file

@ -3,12 +3,10 @@
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, broadcast};
use tracing::warn;
use vestige_core::Storage;
use super::events::VestigeEvent;
use crate::cognitive::CognitiveEngine;
use subtle::ConstantTimeEq;
/// Broadcast channel capacity — how many events can buffer before old ones drop.
const EVENT_CHANNEL_CAPACITY: usize = 1024;
@ -20,8 +18,6 @@ pub struct AppState {
pub cognitive: Option<Arc<Mutex<CognitiveEngine>>>,
pub event_tx: broadcast::Sender<VestigeEvent>,
pub start_time: Instant,
dashboard_token: Arc<str>,
dashboard_allowed_origins: Arc<Vec<String>>,
}
impl AppState {
@ -33,8 +29,6 @@ impl AppState {
cognitive,
event_tx,
start_time: Instant::now(),
dashboard_token: load_dashboard_token().into(),
dashboard_allowed_origins: Arc::new(Vec::new()),
}
}
@ -54,68 +48,12 @@ impl AppState {
cognitive,
event_tx,
start_time: Instant::now(),
dashboard_token: load_dashboard_token().into(),
dashboard_allowed_origins: Arc::new(Vec::new()),
}
}
/// Attach the exact origins allowed to drive the dashboard API and WS.
pub fn with_dashboard_allowed_origins(mut self, origins: Vec<String>) -> Self {
self.dashboard_allowed_origins = Arc::new(origins);
self
}
/// Return true when a dashboard token matches the shared Vestige auth token.
pub fn is_valid_dashboard_token(&self, token: &str) -> bool {
let expected = self.dashboard_token.as_bytes();
let candidate = token.as_bytes();
candidate.len() == expected.len() && candidate.ct_eq(expected).unwrap_u8() == 1
}
/// Return true when an Origin header exactly matches the configured dashboard origins.
pub fn is_allowed_dashboard_origin(&self, origin: &str) -> bool {
self.dashboard_allowed_origins
.iter()
.any(|allowed| allowed == origin)
}
/// Percent-encode the dashboard token for use inside a URL fragment.
pub fn dashboard_token_fragment_value(&self) -> String {
percent_encode_fragment_value(self.dashboard_token.as_ref())
}
/// Emit an event to all connected clients.
pub fn emit(&self, event: VestigeEvent) {
// Ignore send errors (no receivers connected)
let _ = self.event_tx.send(event);
}
}
fn load_dashboard_token() -> String {
match crate::protocol::auth::get_or_create_auth_token() {
Ok(token) => token,
Err(err) => {
warn!(
"Could not load persisted auth token for dashboard; using process-local token: {}",
err
);
uuid::Uuid::new_v4().to_string()
}
}
}
fn percent_encode_fragment_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
encoded.push('%');
encoded.push_str(&format!("{:02X}", byte));
}
}
}
encoded
}

View file

@ -3,46 +3,35 @@
//! Clients connect to `/ws` and receive all VestigeEvents as JSON.
//! Also sends heartbeats every 5 seconds with system stats.
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use tokio::sync::broadcast;
use tracing::{debug, warn};
use super::events::VestigeEvent;
use super::state::AppState;
#[derive(Debug, Deserialize)]
pub struct WsAuthQuery {
token: Option<String>,
}
/// WebSocket upgrade handler — GET /ws
/// Validates token + Origin header to prevent cross-site WebSocket hijacking.
/// Validates Origin header to prevent cross-site WebSocket hijacking.
pub async fn ws_handler(
headers: HeaderMap,
Query(query): Query<WsAuthQuery>,
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
let Some(token) = query.token.as_deref() else {
warn!("Rejected WebSocket connection without dashboard token");
return StatusCode::UNAUTHORIZED.into_response();
};
if !state.is_valid_dashboard_token(token) {
warn!("Rejected WebSocket connection with invalid dashboard token");
return StatusCode::FORBIDDEN.into_response();
}
// Validate Origin header (browsers always send it for WebSocket upgrades).
// Non-browser clients (curl, wscat) won't have Origin; they still need the token.
// Non-browser clients (curl, wscat) won't have Origin — allowed since localhost-only.
match headers.get("origin").and_then(|v| v.to_str().ok()) {
Some(origin) => {
if !state.is_allowed_dashboard_origin(origin) {
let allowed =
origin.starts_with("http://127.0.0.1:") || origin.starts_with("http://localhost:");
#[cfg(debug_assertions)]
let allowed =
allowed || origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173";
if !allowed {
warn!("Rejected WebSocket connection from origin: {}", origin);
return StatusCode::FORBIDDEN.into_response();
}

View file

@ -0,0 +1,55 @@
# Vestige bleeding-edge landing page — verified 2025/2026 research
Synthesized from a 6-lens web scour (web-tech, award landings, viral waitlists,
why-devs-share, interactive heroes, shareable artifacts) + adversarial
fact-check. Every item below was verified as genuinely current for late 2025 /
2026 and shippable on a static SvelteKit + GitHub Pages site (Svelte 5, Kit 2,
adapter-static, Three.js r172, Tailwind v4, Vite 6).
## The throughline (what all 6 lenses agree on)
The 2025/2026 winning waitlist for a skeptical dev audience is:
**a live in-page product embed as the hero + a manifesto + a SHAREABLE ARTIFACT
made of the visitor's own data + real (not fake) scarcity.**
Vestige is uniquely positioned: its artifact (a cinematic render of a memory
graph) is more shareable than any generic "#1,204 in line" OG card — and it's
already built.
## Bleeding-edge web tech (shippable, static)
- **Three.js WebGPURenderer + TSL (r171+), now 100% browsers** — Safari 26 shipped WebGPU Sep 2025. First time you can ship WebGPU-first to everyone (with WebGL2 fallback from the SAME TSL source). Headline claim: "150,000 GPU-compute particles, one shader, every browser."
- **TSL compute particles 50k350k @ framerate** — net-new in 2025/2026; ~150x over CPU. Memory Cinema's 150k sits dead-center in the sweet spot.
- **TSL post-processing (r183 RenderPipeline)** — runtime-swappable bloom/dotScreen; glow the dream geometry, hot-swap effects per scene.
- **Paper Shaders (zero-dep canvas shaders)** — cheap mesh/grain gradient backdrops below the fold so the heavy WebGPU canvas is only the hero.
- **Real-time ASCII / dithering / halftone post-FX** — a defining late-2025/2026 aesthetic (Codrops "Efecto" Jan 2026). Run the Agent Black Box through a dithered/CRT pass so an agent's "thoughts" read off a screen.
- **CSS scroll-driven animations (`animation-timeline: scroll()/view()`)** — JS-free scrollytelling, Interop 2026 target. Drive all non-canvas reveals off this so the GPU budget stays for the hero.
- **View Transitions API (same-document Baseline Oct 2025)** — morph hero → graph → dream → Black Box on scroll, app-like, off-main-thread.
- **Motion (motion.dev), MIT, WAAPI off-thread** — 2.56x faster than GSAP, no Webflow license issue. Use for all DOM/UI motion so it never janks the canvas.
## What makes award-winning dev landings 1-of-1 (2025/2026)
- **Product UI rendered INSIDE the WebGPU pass** (Igloo Inc, @pmndrs/uikit) — labels/wordmark as SDF text in the same pass as particles, sharing grain/aberration/depth. DOM-over-canvas looks "pasted on"; this doesn't.
- **Scroll = camera = narrative** (worldbuilding, not parallax) with offscreen render-pass culling for perf.
- **Cursor-reactive POST-processing warp** (whole composited frame bends toward cursor; scroll velocity smears particles) — generic "spotlight follows cursor" is 2022.
- **Live spec-sheet hero / big real numbers, NOT logo soup** (Evil Martians 100-page study) — the 2026 dev crowd distrusts "trusted by" walls. Lead with live HUD: "561 stars · 43 countries · 150,000 particles."
- **Kinetic/variable-font typography** driven by scroll velocity (wordmark "tightens" as you dive into the graph).
- **Switchable one-canvas, three-lens hero** — Vestige has exactly 3 surfaces: Dream (Cinema) / Graph (force-directed) / Black Box (agent replay). One canvas, three toggles.
## Viral waitlist mechanics (20242026, verified)
- **The share artifact = the product's OUTPUT, not an OG counter card** — Suno song / v0 gen / Lovable URL. For Vestige: a **6-second rendered loop of the VISITOR'S OWN seeded memory graph** flying through Cinema's dream worlds.
- **Manus tradeable invite codes** — "scarcity that creates content"; access itself is the artifact. Give each signup 23 unique founder codes that unlock a rendered Cinema world.
- **Robinhood skip-the-line position jump** — now table-stakes SKELETON; layer the novel artifact on top. Tie milestones to Vestige-native unlocks (new dream geometry / longer render), NOT "free month."
- **REAL scarcity beats fake** — Superhuman's own builder now warns against fake scarcity; deceptive.design lists it as a dark pattern. Frame the cap truthfully: "each Cinema render is real WebGPU work; seats open in batches as render capacity scales" (literally true).
- **Police leaderboard fraud from minute one**#1 self-inflicted failure with a taste-driven HN/X crowd. Require GitHub OAuth or email verification before a referral counts; dedupe by verified identity.
- **Manifesto-first, capture-second** (Arc Browser pattern) — "Your agent forgets everything. Your memory should be yours, local, and beautiful." then scroll INTO the live demo.
- **Compounding milestone unlocks** — each referral milestone spawns a NEW shareable (new dream world / longer render: "I just unlocked the gyroid world"), not one-shot share-to-join.
## Personalized shareable artifact — how to build it (all client-side, fits local-first)
- **Raycast Wrapped 2025 pattern: fully client-side "Copy as Image" from LOCAL data** — exactly Vestige's local-first positioning; a server OG pipeline would CONTRADICT it.
- **WebGPU/WebGL canvas → media via `captureStream()` + `MediaRecorder`** (works for Three.js AND WebGPU) — MemoryCinema's sandbox already renders to a canvas host with camera-only fallback. Wire capture for a shareable clip.
- **`canvas.toBlob` → PNG** for the still card; **HTML-in-Canvas `drawElement()`** (Chromium 147+) to composite a stat overlay onto the live canvas where supported.
- **Seed-from-identity deterministic art** — on the waitlist (no real memory yet) seed a one-of-one "phantom brain" from the visitor's GitHub handle / email hash / typed memory. Unique before they do anything.
- **Build-time dynamic OG** via Satori + resvg-js (or @ethercorps/sveltekit-og) so the shared LINK preview is a stunning Cinema frame, even though the page is static.
## The two pieces that CANNOT be static (the only hosted bits)
1. **Referral state + position counter** — needs a tiny API/edge fn (GetWaitlist/LaunchList, or our own Supabase fn — already started).
2. **Per-user OG/share-card** if done server-side — but the Raycast pattern lets us do it CLIENT-side instead, staying local-first.
WebGPU caveat: default-on in Safari Sep 2025 / Firefox Jul 2025 — always ship the WebGL2/Canvas/pre-baked WebP fallback so share never silently breaks.

View file

@ -0,0 +1,36 @@
{
"concept": "A 65,536-particle GPU field that inhales the word \"VESTIGE\" out of chaos, exhales it into a dense, breathing, iridescent brain whose bright tracts trace real memory-graph pathways, then lets the cursor part the cortex like liquid and watch it heal \u2014 a living second brain that is a function of Sam's data, not a copyable starfield.",
"entranceAnimation": "Timeline driven by one CPU uniform uFormProgress (GSAP easeOutExpo, 0->1 over 0.0-1.2s) plus uDissolve and a word/brain target swap. Beat 0 (0-0.9s): the same 65k points are seeded onto a canvas-rendered \"VESTIGE\" wordmask (pick random pixels where alpha>0.5 -> targetA texture); a critically-damped spring (k ramps 0->14*uFormProgress, damping 0.86 so it overshoots ~3% and settles) snaps the chaos cloud into legible, screenshot-able text while low-amp curl keeps it alive. Beat 1 (0.9-1.4s): uDissolve 0->1 disintegrates the letters left-to-right via a Perlin-remapped per-glyph mask (step(uDissolve, remap)) with wind+rise+curl drift \u2014 the IDENTICAL particles are conserved, never respawned, so the dust that was \"V\" becomes specific neurons. Beat 2 (1.4-3.0s): the freed particles re-target the brain point cloud (targetB = baked memory-graph projection) through the same spring; a per-particle simplex-of-target-position delay (delay = (snoise(targetPos*scale)*0.5+0.5)*0.6) makes formation SWEEP across the cortex like a propagating thought instead of popping as a blob, with a size-pop (sin(p*PI)*1.8) and an emissive dissolve-frontier band (uEdgeBoost>1.0) that UnrealBloom catches as a moving rim of neural ignition. At t=3s the brain is formed, breathing, and the first idle synapse fires.",
"brainCoreSpec": "Confine an analytic two-lobe brain SDF and make flow run TANGENT to it (curl of an SDF-gradient-modulated noise potential is divergence-free, so streamlines parallel the shell \u2014 particles glide along the cortex, never pierce it).\n\nfloat sdEllipsoid(vec3 p, vec3 r){ float k0=length(p/r); float k1=length(p/(r*r)); return k0*(k0-1.0)/k1; }\nfloat brainSDF(vec3 p){\n float L = sdEllipsoid(p-vec3(-0.32,0.0,0.0), vec3(0.85,0.95,1.15));\n float R = sdEllipsoid(p-vec3( 0.32,0.0,0.0), vec3(0.85,0.95,1.15));\n float d = min(L,R);\n d += 0.06*sin(8.0*p.x)*sin(7.0*p.y)*sin(6.0*p.z); // sulci/gyri wrinkles\n return d;\n}\nvec3 sdfNormal(vec3 p){ const vec2 e=vec2(1.0,-1.0)*0.004;\n return normalize(e.xyy*brainSDF(p+e.xyy)+e.yyx*brainSDF(p+e.yyx)+e.yxy*brainSDF(p+e.yxy)+e.xxx*brainSDF(p+e.xxx)); }\nvec3 potential(vec3 p){ return sdfNormal(p)*snoise(p*1.6 + uTime*0.15); } // tangential when curl'd\nvec3 curlNoise(vec3 p){ const float e=0.012; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);\n float x=potential(p+dy).z-potential(p-dy).z-(potential(p+dz).y-potential(p-dz).y);\n float y=potential(p+dz).x-potential(p-dz).x-(potential(p+dx).z-potential(p-dx).z);\n float z=potential(p+dx).y-potential(p-dx).y-(potential(p+dy).x-potential(p-dy).x);\n return vec3(x,y,z)/(2.0*e); }\n\nDENSITY (the anti-starfield): each particle carries life in the FBO .w; decrement, and on death reseed onto the shell with center-weighted radius rad=pow(hash(uv),1.5) (mix(0.2,1.0,rad)), then snap p -= sdfNormal(p)*brainSDF(p)*0.85. Additive overlap of thousands of short-lived particles funneling through the same sulcus builds visible glowing neural tracts and a dense core \u2014 not evenly spaced dots. For the moat, replace the procedural shell seed with the baked memory-graph: targetB texture = PCA/UMAP(memory_embeddings)->[-1,1]^3, per-particle density weighted by importance_score, color by recency, so the densest bright pathways ARE Sam's most-connected memories.\n\nBREATHING: float pulse=0.5+0.5*sin(uTime*2.0); restore=6.0+3.0*pulse (shell tightens on systole); flow += -sdfNormal(p)*brainSDF(p)*restore; and film thickness += 60.0*pulse so hue breathes with the beat (~0.32 Hz heartbeat).",
"liquidCursorSpec": "Four forces in ONE velocity FBO pass, all gated by pointer SPEED (not position) so a still cursor barely perturbs and a fast swipe carves a healing hole. JS feeds uMouse (UV pos), uMouseVel (delta), uMouseSpeed (EMA-smoothed): const dx=mx-pmx,dy=my-pmy; let raw=hypot(dx*aspect,dy); speed=speed*0.85+raw*0.15; speed*=0.92 (idle decay->heal); uMouseSpeed=min(speed*gain,cap). On no-pointer (mobile), drive a Lissajous ghost cursor (mx=0.5+0.3*cos(t*0.7), my=0.5+0.3*sin(t*1.1)) so the brain parts itself.\n\nIn the velocity FS, accumulate into accel then velocity-Verlet integrate:\nvec2 q=(pos.xy-uMouse); q.x*=uAspect; float fall=exp(-dot(q,q)/(uRadius*uRadius)); // Gaussian splat, soft edge\nvec2 rdir=normalize(pos.xy-uMouse+1e-5);\naccel.xy += rdir*fall*uPushK*uMouseSpeed; // (1) repulsion splat, uPushK~0.0023..0.02\nvec2 tangent=vec2(-rdir.y,rdir.x); // 90deg rotate == cross with +Z\nfloat swirlSign=sign(uMouseVel.x*rdir.y - uMouseVel.y*rdir.x);\naccel.xy += tangent*swirlSign*fall*uSwirlK*uMouseSpeed; // (2) vortex swirl, uSwirlK~1x uPushK -> flick spins eddy\naccel.xyz += (uTargetPos - pos.xyz)*uSpringK; // (3) HEAL: Hooke pull to brain-home, uSpringK~0.003..0.02\naccel.xyz += curlNoise(pos)*uFlowK; // (4) always-on ambient brain flow (the SDF curl above)\nvel.xyz = (vel.xyz + accel*uDt) * uDamping; // damping 0.7..0.92; crit-damp target ~1-2*sqrt(uSpringK)\nvel.xy *= min(1.0, uMaxSpeed/length(vel.xy)); // per-axis clamp = stable at any dt\n// position FS: pos.xyz += vel.xyz*uDt;\n\nThe heal returns to uTargetPos = the brain/memory-graph home texture, so it re-forms the actual graph, not a generic sphere. Combining soft speed-gated splat + signed swirl + brain-home spring + ambient SDF-curl in one Verlet pass is what reads as parting LIQUID with surface tension; public demos ship only one of these.",
"colorSpec": "Physical thin-film structural color so it reads as living tissue/soap-film, not HSV rainbow dots. Constructive-interference wavelength w = 2*n*d*cos\u03b8 where film thickness d is DRIVEN by curl/velocity magnitude (fast neural traffic = thicker film = shifted hue) and \u03b8 is the view angle vs the SDF normal \u2014 so color is a function of MOTION and VIEW, shimmering as it pulses and parallaxes. Convert wavelength->RGB with the verified spectral_zucconi6 six-parabola fit (NOT naive HSV):\n\nvec3 bump3y(vec3 x, vec3 yo){ vec3 y=1.0-x*x; return max(y-yo,0.0); }\nvec3 spectral_zucconi6(float w){ float x=clamp((w-400.0)/300.0,0.0,1.0);\n const vec3 c1=vec3(3.54585104,2.93225262,2.41593945), x1=vec3(0.69549072,0.49228336,0.27699880), y1=vec3(0.02312639,0.15225084,0.52607955);\n const vec3 c2=vec3(3.90307140,3.21182957,3.96587128), x2=vec3(0.11748627,0.86755042,0.66077860), y2=vec3(0.84897130,0.88445281,0.73949448);\n return bump3y(c1*(x-x1),y1)+bump3y(c2*(x-x2),y2); }\n// render frag:\nfloat cosTheta = abs(dot(vViewDir, vSdfNormal));\nfloat thickness = 280.0 + vCurlMag*180.0 + 60.0*pulse; // nm; curlMag from sim, breathes with heartbeat\nfloat w = clamp(2.0*1.35*thickness*cosTheta, 400.0, 700.0); // n=1.35 film IOR\nvec3 iridescent = spectral_zucconi6(w);\n\nFRESNEL RIM (backlit-organ silhouette): float fres=pow(1.0-clamp(dot(vSdfNormal,vViewDir),0.0,1.0),2.5); vec3 rim=mix(vec3(0.15,0.10,0.45),iridescent,fres); float brightness=0.25+1.6*fres (edges ~7x brighter than core). Core stays indigo, grazing-angle silhouette flares into the full spectrum tracing the sulci wrinkles. Because rim/iridescence both use the SAME analytic SDF normal as the sim, the glow precisely traces YOUR organic shape \u2014 a clone with different seeds blows out to white or reads sparse.",
"shaderChanges": [
"POSITION FBO shader: change from `position += velocity` Euler to velocity-Verlet half-step \u2014 read updated velocity texture, `pos.xyz += vel.xyz * uDt` only; carry life in .w, decrement by uDelta*0.05, and on life<0 call sampleShellPoint(uv) to reseed center-weighted onto the brain SDF shell (this is what creates density turnover and bright tracts).",
"ADD a dedicated VELOCITY FBO shader (GPUComputationRenderer two-variable position+velocity dependency, matching the house pattern): accumulate the 4 forces (Gaussian speed-gated repulsion splat, signed tangential swirl, brain-home Hooke spring to uTargetPos, ambient SDF-curl) into accel, then `vel = (vel + accel*uDt)*uDamping` with a per-axis speed clamp. Add the breathing term `-sdfNormal(p)*brainSDF(p)*(6.0+3.0*pulse)` and the firing-ripple impulse from uEvent.",
"ADD brainSDF/sdfNormal/potential/curlNoise GLSL (replacing the freefloating box curl) so flow is tangent to the cortex \u2014 this is the single biggest starfield->organ move; reuse the same sdfNormal in BOTH the velocity sim and the render vertex shader.",
"RENDER vertex shader: pass vSdfNormal=sdfNormal(pos), vViewDir=normalize(cameraPosition-worldPos), vCurlMag=length(flow) (write curl magnitude from sim into an unused channel), vAlpha=clamp(speed*1.2,0.04,0.8); gl_PointSize = uSize*(0.6+2.0*vAlpha)*(300.0/-mvPosition.z) so near/fast particles dominate density. Add per-particle staggered-reveal delay from simplex(targetPos) and size-pop sin(p*PI) for the entrance.",
"RENDER fragment shader: compute spectral_zucconi6 thin-film iridescence (thickness from vCurlMag+pulse), Fresnel rim mix, soft gaussian sprite `float soft=exp(-3.0*dot(uv,uv)); if(dot(uv,uv)>1.0) discard;`, premultiplied low energy `vec3 col=rim*brightness; float energy=soft*vAlpha*0.6; gl_FragColor=vec4(col*energy, energy)` so additive OVERLAP builds dense colored mass instead of blowing out to white. Add the dissolve-frontier emissive band (uEdgeBoost>1.0) for the entrance sweep.",
"MATERIAL (JS): THREE.ShaderMaterial { blending:AdditiveBlending, depthTest:true, depthWrite:false, transparent:true } \u2014 order-independent additive sum, no per-frame CPU depth sort (keeps 65k at 60fps).",
"UnrealBloom retune in scene.ts: strength ~0.85, radius ~0.55, threshold ~0.62 \u2014 threshold high enough that only accumulated sulci hot-spots + rim bloom into a continuous membrane while the dim shell stays as fine texture (not a lens flare)."
],
"buildSteps": [
"Create apps/dashboard/src/lib/graph/neuralFlow.ts as the WebGL2 GPGPU hero (separate from CPU particles.ts and from the WebGPU Memory Cinema storm.ts \u2014 DO NOT touch MemoryCinema/cinema/*). Import GPUComputationRenderer from 'three/examples/jsm/misc/GPUComputationRenderer.js' (confirmed present at three@0.172).",
"Set up GPUComputationRenderer with 256x256 = 65,536 particles, two variables texturePosition (xyz+life) and texturevelocity (xyz+curlMag); wire setVariableDependencies([position,velocity]) both ways. Seed initial positions as a scattered curl cloud and bake targetA (VESTIGE wordmask via offscreen canvas, alpha>0.5 pixels) + targetB (brain SDF shell points, or for real users a precomputed PCA/UMAP DataTexture of memory_embeddings with importance_score density).",
"Author the position + velocity GLSL fragment shaders per shaderChanges: brainSDF/curlNoise block, 4-force velocity-Verlet accumulation, center-weighted reseed, breathing pulse, firing-ripple uEvent.",
"Author the render ShaderMaterial (vertex passes SDF normal/viewDir/curlMag/staggered-delay; fragment does spectral_zucconi6 + Fresnel rim + soft sprite + premultiplied energy). Set AdditiveBlending, depthWrite:false. Attach to a THREE.Points reading the position texture.",
"Wire the entrance timeline: GSAP (or rAF lerp) drives uFormProgress 0->1 (easeOutExpo 1.2s), uDissolve for the word->dust beat, and a target swap A->B at ~1.4s; expose uEdgeBoost for the dissolve-frontier glow. Land at formed+breathing+first-fire by t=3s.",
"Wire the input layer: pointermove -> uMouse/uMouseVel/uMouseSpeed with EMA(0.85/0.15) + idle decay(0.92) + aspect correction; no-pointer -> Lissajous ghost cursor. Wire firing events: on Vestige retrieve/create (or a Poisson timer weighted by importance_score) set uEvent=vec4(nodePos, uTime) so a bright iridescent ripple travels the cortex.",
"Retune UnrealBloomPass in scene.ts (strength 0.85 / radius 0.55 / threshold 0.62). Mount neuralFlow as the hero, swap out / keep the legacy starfield behind a feature flag.",
"Add graceful degradation: detect WebGL2; on low-end (devicePixelRatio cap, frame-time probe) drop to 128x128=16,384 particles and skip the second glow-halo sprite; ensure static-shippable (no server, all textures baked at build or hydrated from a shipped JSON of memory-graph coords). Verify 60fps live in the running app and capture proof.",
"Run gates: pnpm --filter @vestige/dashboard check && build; load the page and confirm formation hook, liquid cursor, and 60fps via the running app before claiming done."
],
"perfNotes": [
"65,536 = 256x256 FBO is the sweet spot; depthWrite:false + AdditiveBlending gives order-independent transparency so NO per-frame CPU depth sort (a sort at 65k would blow the frame budget). Keep this \u2014 it is the 60fps enabler.",
"brainSDF is evaluated multiple times per particle (sdfNormal does 4 SDF taps, curlNoise does 6 potential evals = up to ~24 SDF calls/particle/frame). Keep the SDF cheap (2 ellipsoids + one sin product); do NOT add raymarched detail. If frame-time spikes, drop curlNoise epsilon taps from 6 to a 3-tap variant (al-ro style cross(grad0,grad1)).",
"Reuse the SAME sdfNormal result between sim and render by writing it to a spare texture channel rather than recomputing in the vertex shader, if profiling shows the vertex SDF eval is hot.",
"velocity-Verlet + per-axis speed clamp (vel *= min(1.0, uMaxSpeed/length(vel))) is mandatory: naive Euler explodes the spring+repulsion on dt spikes (tab refocus / slow frame) and breaks graceful degradation. Pass real dt, clamped to ~33ms max.",
"Degrade path: WebGL2-only (ships everywhere per three@0.172); on devicePixelRatio>2 or measured <50fps, halve to 128x128 (16,384), drop the glow-halo second sprite, and lower bloom radius \u2014 keeps the brain alive on mobile/low-end.",
"Bloom is the most expensive post pass; threshold 0.62 limits the bloomed pixel count to rim+hot tracts only. Do not lower threshold below ~0.55 or the whole field blooms and both perf and the colored-density read collapse to white.",
"Premultiplied low per-particle energy (soft*vAlpha*0.6) is load-bearing for BOTH look and perf: it keeps additive overlap building hue instead of saturating, so you don't need HDR tonemapping gymnastics to keep the iridescence visible in dense zones."
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,12 @@
{
"concept": "A NEW self-contained full-viewport WebGL 'node engine' for the Vestige landing hero, in its own files under apps/dashboard/src/lib/hero/ (do NOT touch src/lib/graph/cinema -- that is the protected Memory Cinema). It is a THREE.Points cloud of ~40,000 particles (200x200 GPGPU sim texture) driven by two ping-pong FBOs (position + velocity) via GPUComputationRenderer (verified present at three/examples/jsm/misc/GPUComputationRenderer.js in r172; EffectComposer/UnrealBloomPass already used in src/lib/graph/scene.ts). ONE looping normalized clock uPhaseT in [0,1) runs the whole 4-beat cinematic and the SAME velocity/position shaders survive all beats -- only a per-phase FORCE MIX changes. Particles spawn OFF-screen on the true frustum rectangle (all four edges/corners, aspect-tracked), stream inward on curl-noise tendrils (Beat 1), accelerate into a critically-damped center slam and get a one-frame gaussian radial blast impulse = the EXPLODE plus an expanding screen-space shockwave post pass (Beat 2), then spring onto a per-particle shape target (brain -> memory-graph constellation -> neural lattice, cycled each loop) with a staggered per-particle wipe (Beat 3), breathe, and dissolve back to the edge-spawn shell to seamlessly restart (Beat 4). Rendered additively with velocity-stretched soft sprites; per-particle base color is kept LOW (x0.38) so dense overlap stays violet/cyan/emerald instead of clipping to white, then UnrealBloom with a threshold ABOVE the particle base so only hot cores bloom. Headline is a separate DOM/CSS layer on top with a radial scrim, so center can be busy yet never washes the text. Palette matches the dashboard (violet 0x6366f1/0xa855f7, cyan ~0x22d3ee, emerald ~0x34d399, near-black 0x05050f). Reduced-motion + 3 perf tiers + DPR clamp <=2 + FPS watchdog keep it 60fps and static-shippable on GitHub Pages (pure WebGL2). Cited demos: Codrops 'Crafting a Dreamy Particle Effect with Three.js and GPGPU' (Dec 2024) two-pass spring/damp core; Three.js Journey 'Particles Morphing Shader' + github.com/MisterPrada/morph-particles staggered morph; Codrops 'Surface Sampling in Three.js' (2021) MeshSurfaceSampler targets; cabbibo/glsl-curl-noise divergence-free curl; Halisavakis shockwave post; Loopspeed FBO multi-target mix.",
"timeline": "ONE uniform drives everything. JS each frame: const LOOP = 18.0; uPhaseT = (clock.getElapsedTime() % LOOP) / LOOP; At wrap (detect prevPhase > phase) advance shapeIndex = (shapeIndex+1)%3 and bind the next target texture to texTarget, and (re)set uBlastTime so the gaussian gate is fresh next cycle.\n\nPhase windows on uPhaseT (18s loop -> seconds in parens):\n 0.00-0.20 STREAM (0.0-3.6s): particles released from edges in hash-staggered waves; high uFlow curl, low uAttract, target = converge point vec3(0). Activation act = smoothstep(0, 0.5, mod(uTime - delay, LOOP)).\n 0.20-0.235 SLAM/EXPLODE (3.6-4.23s): convergence pull peaks (ease-in pow(tConv,3)); the instant uPhaseT crosses 0.20 JS sets uBlastTime = uTime ONCE; velocity pass adds gaussian one-shot radial impulse pulse=exp(-age*age*60); uDamping drops 0.85->0.60; uAttract ~0.\n 0.235-0.55 REFORM (4.23-9.9s): uExplode decays to 0, uAttract ramps 0.002->0.06 toward texTarget, curl turb fades 0.6->0.02; per-particle localProg = smoothstep(delay, delay+0.4, (uPhaseT-0.235)/0.315) gives the assembling wipe.\n 0.55-0.82 HOLD/BREATHE (9.9-14.76s): uAttract high, curl ~0.03 shimmer, gl_PointSize *= 1.0+0.05*sin(uTime*1.4); shape sits still and alive.\n 0.82-1.00 DISSOLVE (14.76-18.0s): uAttract->0, uFlow up, gentle outward bias + curl pushes particles to the edge-spawn shell (texOrigin); since dissolve target == next-loop spawn shell, the loop is SEAMLESS (no cut).\n\nDerive all weights inside the sim shader with smoothstep windows (ease-in/out, no pops):\n float wStream = 1.0 - smoothstep(0.18, 0.21, uPhaseT);\n float pulse = smoothstep(0.200, 0.215, uPhaseT) * (1.0 - smoothstep(0.215, 0.245, uPhaseT));\n float wReform = smoothstep(0.235, 0.55, uPhaseT) * (1.0 - smoothstep(0.80, 0.86, uPhaseT));\n float wDissolve = smoothstep(0.82, 1.00, uPhaseT);\n float uAttract = mix(0.002, 0.06, wReform);\n float uFlow = mix(0.35, 0.05, wReform) + wDissolve*0.40 + wStream*0.10;\n float uDamping = mix(0.86, 0.60, pulse);\n float uExplode = pulse * 6.0;\nImportant detail (MiniMax skill): use mod(uTime - delay, LOOP) for per-particle phase, NOT floor(uTime/LOOP)*LOOP (the latter adds a one-cycle startup dead time).",
"cameraAndViewport": "Single full-bleed canvas pinned behind the hero: CSS position:fixed/absolute inset:0; width:100vw; height:100vh; z-index:0; the H1/sub/CTA sit in a sibling layer z-index:10 with pointer-events passing through the canvas. The canvas element gets display:block so there is no inline-gap, and the renderer fills clientWidth x clientHeight.\n\nRenderer/camera setup:\n renderer = new THREE.WebGLRenderer({ antialias:false, alpha:true, powerPreference:'high-performance' });\n renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // HARD DPR clamp -- #1 cause of <60fps on a full-frame canvas\n renderer.setClearColor(0x05050f, 1);\n renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; // saturates the dense core instead of whiting out\n const camera = new THREE.PerspectiveCamera(55, aspect, 0.1, 100); camera.position.set(0,0,14);\n\nFit-to-viewport (the cure for the 'contained ball in the middle' failure): compute the world half-extents the camera actually sees at the particle plane z=0 and spawn/dissolve on a rectangle slightly LARGER than that frustum:\n const vFOV = camera.fov*Math.PI/180;\n const h = 2*Math.tan(vFOV/2)*Math.abs(camera.position.z); // visible height at z=0\n const w = h*camera.aspect; // visible width at z=0\n uFrustum.value.set(w, h); // fed to sim + render so dissolve targets scale with the frame\nSpawn shell half-extents = frustum*0.625 then pushed out *1.15 so streaks ENTER from beyond the edge (point sprites are culled by center, so spawning just past +/-1 avoids edge-pop). Spread target z across [-camZ*0.6, +camZ*0.4] so the frame reads volumetric (near/far size-attenuation glow gradient), not a flat sheet. Recompute w/h/uFrustum and renderer size on every resize (ResizeObserver on the container). Keep PerspectiveCamera (NOT ortho) so gl_PointSize = uSize*uDpr / -mvPosition.z gives the depth glow gradient. Coverage comes from frustum math, never from zoom.",
"gpgpuDesign": "GPUComputationRenderer with TWO variables, RGBA32F, sized TEX x TEX (TEX=200 -> 40,000 particles).\n\nTextures / channels:\n texturePosition: rgb = world xyz, a = per-particle SEED in [0,1] (baked once, used for delay/role/jitter).\n textureVelocity: rgb = world velocity, a = spare (store role or local-progress cache).\n texOrigin (DataTexture, static uniform): rgb = the edge-spawn shell point for this particle, a = perimeterT in [0,1) for clockwise sweep. Used as STREAM source and DISSOLVE destination.\n texTarget (DataTexture, static uniform, swapped per loop): rgb = this particle's slot in the current shape (brain/constellation/lattice), a = node-vs-edge role flag (0=edge particle dim/small, 1=node particle bright/large).\n uRandom is not needed separately -- seed lives in position.a.\n\nDependencies: positionVar.material depends on [positionVar, velocityVar]; velocityVar.material depends on [positionVar, velocityVar]. Set wrapS/wrapT = ClampToEdgeWrapping, minFilter/magFilter = NearestFilter on ALL FBOs and DataTextures (texel = particle; bilinear would smear neighbors).\n\nGeometry for render: a BufferGeometry of TEX*TEX dummy vertices, each carrying a 'reference' vec2 attribute = the uv into the sim textures: reference[i] = ( (i%TEX)/TEX, floor(i/TEX)/TEX ). Plus an 'aCorner' is NOT needed if you render as gl_Points (cheaper, chosen here); velocity-stretch is done by modulating gl_PointSize + an elongated sprite mask in the fragment, which is enough at 40k and avoids 4x the vertex work of expanded quads.\n\nInit (fillTexture): position init = copy of texOrigin (everyone starts at the edge shell); velocity init = vec3(0); position.a = Math.random() seed. computeRenderer.init() then check getError().\n\nPer frame order: 1) set uniforms (uTime, uPhaseT, uBlastTime). 2) gpuCompute.compute(). 3) read velocityVar/positionVar current render targets via getCurrentRenderTarget(var).texture, assign to render material uniforms texturePosition/textureVelocity. 4) composer.render() (RenderPass -> shockwave ShaderPass -> UnrealBloom).",
"positionShader": "// === simPosition.frag (GPUComputationRenderer; 'resolution' + texturePosition/textureVelocity auto-injected) ===\nuniform float uDt;\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution.xy;\n vec4 posTex = texture2D(texturePosition, uv);\n vec3 pos = posTex.xyz;\n float seed = posTex.a; // preserve per-particle seed across frames\n vec3 vel = texture2D(textureVelocity, uv).xyz;\n // Symplectic Euler: velocity pass already integrated forces this step.\n pos += vel * uDt;\n gl_FragColor = vec4(pos, seed); // carry seed forward untouched\n}\n\nNotes: uDt is the clamped frame delta (clamp to <=1/30 so a tab-stall never explodes the sim). No target logic lives here -- position is pure integration; all forces are in the velocity pass so damping/impulse/spring stay in one place (the Codrops Dec-2024 two-pass split).",
"velocityShader": "// === simVelocity.frag (GPUComputationRenderer) ===\n// Auto-injected by GPUComputationRenderer: texturePosition, textureVelocity, resolution.\nuniform float uTime, uPhaseT, uDt, uBlastTime;\nuniform vec2 uFrustum;\nuniform sampler2D texOrigin; // edge-spawn shell (STREAM src / DISSOLVE dst)\nuniform sampler2D texTarget; // current shape slot (brain/constellation/lattice)\n#define PI 3.14159265\n\n// ---- simplex noise snoise(vec3) : paste Ashima/webgl-noise 3D simplex here (standard 80-line impl) ----\nvec3 snoiseVec3(vec3 p){ return vec3( snoise(p),\n snoise(p+vec3(17.1, 9.2, 3.3)),\n snoise(p+vec3(101.7,5.4,71.2)) ); }\nvec3 curlNoise(vec3 p){ // cabbibo/glsl-curl-noise, divergence-free\n const float e=0.1; vec3 dx=vec3(e,0,0),dy=vec3(0,e,0),dz=vec3(0,0,e);\n vec3 px0=snoiseVec3(p-dx),px1=snoiseVec3(p+dx);\n vec3 py0=snoiseVec3(p-dy),py1=snoiseVec3(p+dy);\n vec3 pz0=snoiseVec3(p-dz),pz1=snoiseVec3(p+dz);\n float x=py1.z-py0.z-pz1.y+pz0.y;\n float y=pz1.x-pz0.x-px1.z+px0.z;\n float z=px1.y-px0.y-py1.x+py0.x;\n return normalize(vec3(x,y,z)/(2.0*e));\n}\nfloat hash11(float p){ return fract(sin(p*127.1)*43758.5453); }\n\nvoid main(){\n vec2 uv = gl_FragCoord.xy / resolution.xy;\n vec4 pT = texture2D(texturePosition, uv);\n vec3 pos = pT.xyz; float seed = pT.a;\n vec3 vel = texture2D(textureVelocity, uv).xyz;\n vec3 originPos = texture2D(texOrigin, uv).xyz;\n float perimT = texture2D(texOrigin, uv).a;\n vec4 tgtTex = texture2D(texTarget, uv);\n vec3 shapePos = tgtTex.xyz;\n\n // ---- per-phase weights (smoothstep windows, no pops) ----\n float wStream = 1.0 - smoothstep(0.18, 0.21, uPhaseT);\n float pulse = smoothstep(0.200, 0.215, uPhaseT) * (1.0 - smoothstep(0.215, 0.245, uPhaseT));\n float wReform = smoothstep(0.235, 0.55, uPhaseT) * (1.0 - smoothstep(0.80, 0.86, uPhaseT));\n float wDissolve = smoothstep(0.82, 1.00, uPhaseT);\n\n // ---- STREAM: hash-staggered release toward center, curl tendrils ----\n float delay = 0.10*hash11(seed*91.7) + 0.08*perimT; // random + clockwise sweep\n float act = smoothstep(0.0, 0.06, uPhaseT - delay); // 0 until this particle's turn\n vec3 toCenter = -pos; // center = vec3(0)\n float dC = length(toCenter);\n vec3 dirC = toCenter / max(dC, 1e-4);\n // ease-in convergence: slow drift -> violent collapse as we approach the slam (uPhaseT->0.20)\n float tConv = clamp(uPhaseT/0.20, 0.0, 1.0);\n float pull = pow(tConv, 3.0);\n vel += dirC * (0.01 + 0.12*pull) * act * wStream * smoothstep(0.0, 6.0, dC);\n\n // ---- EXPLODE: one-shot gaussian radial blast (the SLAM), core launches hardest ----\n float age = uTime - uBlastTime;\n float gate = exp(-age*age*60.0); // ~1 frame wide -> reads as impact not push\n vec3 fromC = pos;\n float r = max(length(fromC), 1e-4);\n vec3 outDir= fromC / r;\n float falloff = 1.0/(1.0 + r*r*4.0); // inverse-square-ish\n vel += outDir * 6.0 * falloff * gate;\n vel += (snoiseVec3(pos*31.0)) * 0.06 * gate; // jitter so the front is not a perfect sphere\n\n // ---- REFORM/HOLD: spring onto shape target with staggered per-particle wipe ----\n float localProg = smoothstep(delay, delay+0.40, (uPhaseT-0.235)/0.315); // assembling sweep\n float kAttract = mix(0.002, 0.06, wReform) * localProg;\n vec3 toShape = shapePos - pos; float dS = length(toShape);\n vel += (toShape / max(dS,1e-4)) * kAttract * dS; // Hookean: stronger when far\n\n // ---- DISSOLVE: fade spring, ramp curl + outward bias toward the edge shell ----\n vec3 toEdge = originPos - pos;\n vel += toEdge * (0.01 * wDissolve); // gently retarget to next-loop spawn\n vel += normalize(pos + 1e-4) * 0.004 * wDissolve; // outward bias fills viewport\n\n // ---- curl turbulence: strong on stream/explode, ~0 once formed (keeps brain crisp) ----\n float turb = (0.35*wStream + 0.18*gate + 0.40*wDissolve) + mix(0.04, 0.015, wReform);\n vel += curlNoise(pos*0.30 + uTime*0.10) * turb * uDt * 12.0;\n\n // ---- damping + velocity cap (stable slam) ----\n float damping = mix(0.86, 0.60, pulse);\n vel *= damping;\n float vmax = 3.0; float v = length(vel); if (v > vmax) vel *= vmax / v;\n\n gl_FragColor = vec4(vel, 1.0);\n}",
"renderShader": "// ===================== render VERTEX shader =====================\nuniform sampler2D texturePosition;\nuniform sampler2D textureVelocity;\nuniform float uSize, uDpr, uPhaseT, uTime;\nattribute vec2 reference; // uv into the sim textures (baked per vertex)\nvarying float vSpeed;\nvarying float vRole; // node(1) vs edge(0) from target.a -- but we pass seed-based hue too\nvarying float vSeed;\nvarying vec3 vWorld;\nvoid main(){\n vec4 posTex = texture2D(texturePosition, reference);\n vec3 pos = posTex.xyz; vSeed = posTex.a;\n vec3 vel = texture2D(textureVelocity, reference).xyz;\n vSpeed = length(vel);\n vWorld = pos;\n vec4 mv = modelViewMatrix * vec4(pos, 1.0);\n // breathe during HOLD (0.55-0.82) only\n float breathe = 1.0 + 0.05*sin(uTime*1.4) * step(0.55,uPhaseT) * step(uPhaseT,0.82);\n // velocity-stretch: fast edge streamers get bigger (comet feel); settled dots stay small\n float stretch = 1.0 + clamp(vSpeed*0.6, 0.0, 2.5);\n gl_PointSize = uSize * uDpr * breathe * stretch / max(-mv.z, 0.1);\n gl_Position = projectionMatrix * mv;\n}\n\n// ===================== render FRAGMENT shader =====================\nprecision highp float;\nuniform vec3 uViolet; // 0.39,0.40,0.95 (0x6366f1)\nuniform vec3 uCyan; // 0.13,0.83,0.93 (0x22d3ee)\nuniform vec3 uEmerald; // 0.20,0.83,0.60 (0x34d399)\nuniform vec2 uTextCenterNDC; // headline center in [0,1] screen uv (~0.5,0.5)\nuniform vec2 uResolution;\nvarying float vSpeed; varying float vSeed; varying vec3 vWorld;\nvoid main(){\n // soft round sprite -- NO hard disc, so additive overlap accumulates as glow not a white plate\n vec2 q = gl_PointCoord - 0.5;\n float d = length(q);\n float a = exp(-d*d*5.0); // gaussian falloff sprite\n if (a < 0.01) discard;\n // palette: split hue by seed so violet/cyan/emerald separate SPATIALLY (never average to white)\n vec3 col = mix(uViolet, uCyan, smoothstep(0.0,0.5,vSeed));\n col = mix(col, uEmerald, smoothstep(0.5,1.0,vSeed));\n // fast particles flash slightly hotter (toward cyan) so the slam reads energetic\n col = mix(col, uCyan, clamp(vSpeed*0.12, 0.0, 0.5));\n // LOW base so N-overlap stays in-gamut and HUED (this is the anti-white-out lever)\n col *= 0.38;\n // dim behind the headline bounding box so text stays legible even at the explosion peak\n vec2 sUv = gl_FragCoord.xy / uResolution;\n float textMask = smoothstep(0.0, 0.22, length(sUv - uTextCenterNDC));\n float alpha = a * (0.55 + 0.45*textMask);\n gl_FragColor = vec4(col * alpha, alpha); // AdditiveBlending, depthWrite:false\n}\n\nMaterial: new THREE.ShaderMaterial({ blending: THREE.AdditiveBlending, depthWrite:false, depthTest:false, transparent:true, uniforms:{...} }). Render to the composer; UnrealBloom strength 0.7, radius 0.45, THRESHOLD 0.55 -- the 0.38 base sits BELOW threshold so only hot cores/the slam bloom, keeping the center violet/cyan not a white disc. ACESFilmic tone mapping on the renderer finishes the anti-clip.",
"targetShapes": "Build THREE target DataTextures once at boot, all in the SAME texel order the particles read (texel i -> particle i), all RGBA FloatType, NearestFilter, ClampToEdge. Channel a = role (1=node bright/large, 0=edge/fill dim/small).\n\nSHAPE A -- BRAIN (organic, mesh-sampled): load a low-poly brain GLB (Draco) OR fall back to two merged icospheres squashed into hemispheres. Use three/addons/math/MeshSurfaceSampler:\n import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js';\n const sampler = new MeshSurfaceSampler(brainMesh).build();\n const p=new THREE.Vector3(), n=new THREE.Vector3();\n for(let i=0;i<N;i++){ sampler.sample(p,n); p.addScaledVector(n, -Math.random()*0.15); // push inward => volumetric fill\n data[i*4]=p.x*S; data[i*4+1]=p.y*S; data[i*4+2]=p.z*S; data[i*4+3]= Math.random()<0.5?1.0:0.0; }\n Scale S so the brain spans ~70% of uFrustum height.\n\nSHAPE B -- MEMORY-GRAPH CONSTELLATION (no mesh): Fibonacci-sphere nodes + edge particles painting connections.\n const GA=Math.PI*(3.0-Math.sqrt(5.0)); const M=600; // node count\n for(let k=0;k<M;k++){ const y=1-(k/(M-1))*2, r=Math.sqrt(1-y*y), t=GA*k; nodes[k]=new THREE.Vector3(Math.cos(t)*r,y,Math.sin(t)*r).multiplyScalar(R); }\n // precompute edges: connect a->b if dist<edgeRadius. Then assign particles:\n // ~35% NODE particles: target = nodes[k] (+ small jitter), role=1\n // ~65% EDGE particles: target = mix(nodes[a], nodes[b], seed) along a chosen edge, role=0\n This turns a point cloud into a READABLE network (nodes bright, filaments faint).\n\nSHAPE C -- NEURAL LATTICE (crystalline): hash-jittered 3D grid.\n cell = floor(gridIndex); center = (cell+0.5)*cellSize - half; jitter = (hash3(cell)-0.5)*0.55*cellSize;\n target = center + jitter; role: particles near a grid LINE (one axis ~integer) = node(1), interior = fill(0).\n Edge particles lerp along lattice struts (mix of two adjacent cell centers by seed) for visible connectivity.\n\nSWITCHING: keep targetTextures = [brainTex, constellationTex, latticeTex]. On loop wrap (prevPhase>phase) set velocityVar.material.uniforms.texTarget.value = targetTextures[++shapeIndex % 3] and renderMaterial likewise if it samples target. Because every particle keeps its texel index, the morph from edge-shell -> shape is clean point-to-point; no re-sort needed. (Loopspeed FBO multi-target mix pattern.)",
"buildSteps": null,
"perfNotes": null
}

View file

@ -0,0 +1,92 @@
{
"summary": "Scour the web for unexpected jaw-dropping procedural particle-morph shapes, return implementable GLSL/math for the Vestige hero",
"agentCount": 5,
"logs": [],
"result": {
"pick": {
"chosenShapes": [
{
"name": "Aizawa Strange Attractor (chaotic-thought butterfly)",
"whyChosen": "The single highest 'how did they DO that' shape. 40k particles self-organize from noise into a glowing toroidal shell pierced by an axial spike - a sculpture no primitive can fake. Deterministic chaos pools particles where the orbit lingers, giving free non-uniform nebula density that beats any Perlin shading. It is the literal 'watch your agent think' money shot: same seed = same orbit, yet the path looks unpredictable. Closed-form per-seed via fixed-step iteration, no CPU LUT or mesh needed.",
"glslSnippet": "// Aizawa attractor - iterate the ODE from a seed-jittered start.\n// Each particle lands on a different part of the same attracting set,\n// and varying the step count spreads density along the orbit.\nvec3 shapeAizawa(float a, float b, float c, float S){\n // tiny jittered start ball near origin (seeds decorrelated by hashing)\n vec3 p = (vec3(fract(a*43.0+0.13), fract(b*91.7+0.41), fract(c*57.3+0.77)) - 0.5) * 0.12;\n const float A=0.95, B=0.7, C=0.6, D=3.5, E=0.25, F=0.1, dt=0.01;\n int iters = 260 + int(a*140.0); // 260..400 -> spread along manifold\n for(int i=0;i<400;i++){\n if(i>=iters) break; // GLSL ES: constant loop bound + break\n float dx = (p.z - B)*p.x - D*p.y;\n float dy = D*p.x + (p.z - B)*p.y;\n float dz = C + A*p.z - p.z*p.z*p.z/3.0\n - (p.x*p.x + p.y*p.y)*(1.0 + E*p.z)\n + F*p.z*p.x*p.x*p.x;\n p += vec3(dx,dy,dz)*dt;\n }\n // attractor lives roughly in [-1.5,1.5]; 0.42*S fills ~70% of frustum height\n return p * (0.42 * S);\n // PALETTE: color by length(velocity) of the final step ->\n // violet (slow shell) -> cyan -> gold (fast axial spike).\n}",
"seedsNeeded": "a (drives iteration count + start jitter), b, c (start jitter). All three used.",
"scaleNote": "Attractor spans ~[-1.5,1.5]. Multiply by 0.42*S so the full body fills ~70% of frustum height. Cap loop at 400 for 40k @ 60fps; 260-400 iters per particle is the sweet spot for even density."
},
{
"name": "Hopf Fibration (linked memories, interlocking rings)",
"whyChosen": "The most 'premium math' object you can ship: nested Villarceau circles threaded through each other so every ring links every other exactly once, yet none collide. Reads as impossible 3D chainmail of light that rotates into self-similar tori. Devs who know it gasp; devs who don't think it's an alien artifact. Perfect on-brand metaphor for a fully-connected memory graph that never tangles. Pure closed-form from seeds, surface-coherent.",
"glslSnippet": "// Hopf fibration: seed -> a base point on S^2 (which fiber), then a phase\n// along that fiber circle; lift to S^3 and stereographically project to R^3.\n// Quantizing the base point to a few values yields a few BIG clean linked\n// rings (more legible than a full fill).\nvec3 shapeHopf(float a, float b, float c, float S){\n const float TAU = 6.28318530718;\n // quantize base point so we get ~12 bold interlocking rings, not mush\n float ringId = floor(a * 12.0);\n float cosT = 2.0*fract(ringId*0.61803398875) - 1.0; // base colatitude, spread\n float baseTheta= acos(clamp(cosT,-1.0,1.0));\n float basePhi = TAU * fract(ringId*0.7548776662);\n vec3 bp = vec3(sin(baseTheta)*cos(basePhi),\n sin(baseTheta)*sin(basePhi),\n cos(baseTheta)); // (x,y,z) on S^2\n float t = TAU * b; // phase along the fiber circle\n float k = 1.0 / sqrt(2.0*(1.0 + bp.z) + 1e-4);\n vec4 P = vec4((1.0+bp.z)*cos(t),\n bp.x*sin(t) - bp.y*cos(t),\n bp.x*cos(t) + bp.y*sin(t),\n (1.0+bp.z)*sin(t)) * k; // point on S^3\n vec3 q = P.xyz / (1.0 - P.w + 1e-3); // stereographic S^3 -> R^3\n // jitter slightly along c so each ring has a little tube thickness\n q += (vec3(fract(c*71.3),fract(c*131.7),fract(c*197.1))-0.5)*0.04;\n return q * (0.28 * S);\n}",
"seedsNeeded": "a (picks which of ~12 rings), b (phase along the fiber circle), c (tube-thickness jitter). All three used.",
"scaleNote": "Stereographic output ranges widely; 0.28*S tames it to ~70% frustum. The 1e-3 guard on (1-P.w) prevents the projection blowing up at the far pole. Use ~8-16 quantized rings for legibility - a full continuous fill reads as fog."
},
{
"name": "(p,q) Torus Knot tube (the reasoning loop)",
"whyChosen": "A glowing rope that winds a torus and ties itself into a perfect trefoil - clean, logo-grade, obviously computed-not-drawn. Inflating the core curve into a particle TUBE (Frenet frame + uniform disc fill) makes it read as a solid living rope under additive bloom, not a thin wire. Bump (p,q) each loop (2,3 -> 3,2 -> 3,4 -> 5,2) and the SAME code morphs through a family of distinct knots: free variety. On-brand as 'the agent threading recall back through prior context.'",
"glslSnippet": "// (p,q) torus knot, inflated into a volumetric tube via a Frenet-ish frame.\n// a -> position along the curve; b,c -> uniform fill of the tube cross-section.\nvec3 shapeTorusKnot(float a, float b, float c, float S){\n const float TAU = 6.28318530718;\n const float P = 3.0, Q = 7.0; // (3,7) braid; swap per loop for variety\n const float R = 1.0, rMinor = 0.45, rTube = 0.16;\n float t = a * TAU;\n float cq = cos(Q*t), sq = sin(Q*t);\n float ring = R + rMinor*cq;\n vec3 C = vec3(ring*cos(P*t), ring*sin(P*t), rMinor*sq); // core curve\n // analytic tangent dC/dt\n vec3 T = vec3(-rMinor*Q*sq*cos(P*t) - ring*P*sin(P*t),\n -rMinor*Q*sq*sin(P*t) + ring*P*cos(P*t),\n rMinor*Q*cq);\n T = normalize(T);\n vec3 N = normalize(cross(T, vec3(0.0,0.0,1.0)));\n vec3 B = cross(T, N);\n float ang = b * TAU;\n float rad = sqrt(c) * rTube; // sqrt -> uniform disc (no center clump)\n vec3 pos = C + rad*(cos(ang)*N + sin(ang)*B);\n return pos * (0.55 * S);\n}",
"seedsNeeded": "a (along the knot), b (tube angle), c (radial fill, sqrt for uniform disc). All three used.",
"scaleNote": "Knot body spans roughly [-1.45,1.45]; 0.55*S fills ~70% height. Keep P,Q coprime (3&7, 2&5, 3&4) or the curve degenerates. rTube=0.16 gives a solid rope; drop to 0.05 for a bare hypnotic wire."
},
{
"name": "DNA Double Helix (memory as encoded sequence)",
"whyChosen": "Instantly legible at thumbnail size - twin spiraling ribbons with rungs ladder-stepping between them, the universal 'intelligent / alive' signal. Photographs perfectly for a hero still and rotates beautifully. Provides recognizable, calm contrast to the abstract chaos/topology shapes (variety). Built-in palette story: strand A cyan, strand B violet, base-pair rungs gold. On-brand: event strand + interpretation strand bound by link base-pairs.",
"glslSnippet": "// DNA double helix: two antiparallel sugar-phosphate strands + base-pair rungs.\n// a -> height along the axis; b classifies particle into strandA / strandB / rung;\n// c -> radial thickness so strands read as ribbons, not lines.\nvec3 shapeDNA(float a, float b, float c, float S){\n const float TAU = 6.28318530718;\n const float TWISTS = 6.0; // full turns over the length\n const float HEIGHT = 2.0;\n const float RADIUS = 0.55;\n float yy = (a - 0.5) * HEIGHT;\n float ang = a * TWISTS * TAU;\n vec3 strandA = vec3(cos(ang)*RADIUS, yy, sin(ang)*RADIUS);\n vec3 strandB = vec3(cos(ang+3.14159)*RADIUS, yy, sin(ang+3.14159)*RADIUS);\n vec3 pos;\n if(b < 0.42){ // strand A (cyan)\n pos = strandA;\n } else if(b < 0.84){ // strand B (violet)\n pos = strandB;\n } else { // base-pair rung (gold), interpolate across the ladder\n float k = fract(b * 53.0);\n pos = mix(strandA, strandB, k);\n }\n // ribbon thickness: jitter in a small ball, sqrt for even fill\n float rad = sqrt(c) * 0.06;\n float ja = fract(c*131.7)*TAU, jb = fract(c*71.3)*3.14159;\n pos += rad * vec3(sin(jb)*cos(ja), cos(jb), sin(jb)*sin(ja));\n return pos * (0.62 * S);\n}",
"seedsNeeded": "a (height + twist angle), b (strandA / strandB / rung classifier), c (ribbon thickness jitter). All three used.",
"scaleNote": "Height = 2.0, radius = 0.55 -> bounding box ~1.1 wide x 2.0 tall. 0.62*S puts the tall axis at ~70% frustum height (this one is portrait-oriented, which frames well for a hero). Tune TWISTS=6 for a classic look; raise to 8-10 for a tighter coil."
},
{
"name": "Spiral Galaxy (galaxy of memories)",
"whyChosen": "The most universally read 'whoa' shape - 40k points snap into a barred spiral with a luminous gold core and trailing violet dust arms, like a Hubble plate assembling. Reads even at thumbnail size and needs zero math literacy from the viewer, so it converts the broadest audience. Exponential vertical falloff gives a real galactic-disc thinness, not a flat pancake. On-brand: each particle a stored memory orbiting the bright working-context core.",
"glslSnippet": "// Barred spiral galaxy: log-spiral arms + exponential disc thickness + bright core.\n// a -> which arm + per-arm scatter; b -> radius (sqrt for centre concentration);\n// c -> vertical disc jitter and core bulge.\nvec3 shapeGalaxy(float a, float b, float c, float S){\n const float TAU = 6.28318530718;\n const float N_ARMS = 4.0;\n const float TWIST = 2.6; // arm winding\n float arm = floor(a * N_ARMS);\n float t = sqrt(b); // 0..1 radial, sqrt concentrates centre\n float r = t;\n float jit = (fract(a*97.13) - 0.5) * 0.35 * (1.0 - t); // arms tighten outward\n float ang = arm * TAU / N_ARMS + t * TWIST * TAU + jit;\n // disc thickness: thick bulge at centre, thin at rim\n float yy = (fract(c*53.7) - 0.5) * 0.14 * exp(-2.5*r);\n vec3 pos = vec3(cos(ang)*r, yy, sin(ang)*r);\n // central bulge: a fraction of particles form the spherical core\n if(b < 0.06){\n float rr = pow(fract(c*191.3), 0.5) * 0.12;\n float th = fract(a*311.7)*TAU, ph = acos(2.0*fract(c*131.1)-1.0);\n pos = vec3(rr*sin(ph)*cos(th), rr*cos(ph), rr*sin(ph)*sin(th));\n }\n return pos * (1.30 * S);\n // PALETTE: gold core (small r) -> emerald mid-disc -> violet/cyan rim by r.\n}",
"seedsNeeded": "a (arm index + angular scatter), b (radius; <0.06 -> core bulge), c (disc thickness + core sphere jitter). All three used.",
"scaleNote": "Disc radius normalized to ~1.0; 1.30*S fills ~70% frustum width for the flat disc (it presents widescreen). Tilt the whole shape ~25deg on the engine side so the spiral reads as a disc, not a ring. exp(-2.5*r) keeps the rim a razor-thin sheet."
},
{
"name": "3D Superformula Supershape (the morph engine)",
"whyChosen": "ONE equation, four knobs -> starfish, sea-urchin, faceted crystal, blooming flower, bloated pollen. Because it is the same target with different params, you get an entire alien bestiary nearly free, and morphing BETWEEN parameter sets is buttery since the topology is shared - perfect connective tissue between the 'serious' shapes. A dev watching it shapeshift instantly realizes there is no mesh: it is pure procedural magic. On-brand: one generator, infinite forms = one memory engine producing emergent structure.",
"glslSnippet": "// Gielis superformula, spherical product of two superformulas (surface-sampled).\n// a -> longitude theta, b -> latitude phi, c -> thin surface-shell jitter.\n// Swap the (m,n1,n2,n3) set per loop to morph through the bestiary.\nfloat superR(float ang, float m, float n1, float n2, float n3){\n float t = m * ang * 0.25;\n float c1 = pow(abs(cos(t)), n2);\n float c2 = pow(abs(sin(t)), n3);\n return pow(c1 + c2 + 1e-6, -1.0/n1);\n}\nvec3 shapeSupershape(float a, float b, float c, float S){\n const float PI = 3.14159265359;\n float theta = (a*2.0 - 1.0) * PI; // -PI..PI\n float phi = (b - 0.5) * PI; // -PI/2..PI/2\n // spiky sea-urchin: m=7,n1=0.2,n2=1.7,n3=1.7\n // (crystal m=4,n1=60,n2=55,n3=30 | star m=5,n1=0.1,n2=1.7,n3=1.7)\n float m=7.0, n1=0.2, n2=1.7, n3=1.7;\n float r1 = superR(theta, m, n1, n2, n3);\n float r2 = superR(phi, m, n1, n2, n3);\n vec3 pos = vec3(r1*cos(theta) * r2*cos(phi),\n r1*sin(theta) * r2*cos(phi),\n r2*sin(phi));\n // tame extreme spikes so framing stays stable, then add a thin shell jitter\n pos = normalize(pos) * pow(length(pos), 0.6);\n pos *= (1.0 + (c - 0.5)*0.04);\n return pos * (0.85 * S);\n}",
"seedsNeeded": "a (longitude), b (latitude), c (surface-shell thickness). All three used. (superR is a free helper - paste once.)",
"scaleNote": "After the normalize()*pow(len,0.6) spike-taming the body sits in ~[-1.6,1.6]; 0.85*S -> ~70% height. The +1e-6 inside superR and abs() on cos/sin keep pow() real for odd exponents on negatives. Lerp the (m,n1,n2,n3) tuple over a loop for a living bloom."
},
{
"name": "VESTIGE text beat (baked-mask glyph cloud)",
"whyChosen": "A skeptical dev expects abstract blobs; spelling the product name out of 40k live particles is the single biggest 'wait, how' moment - recognizable signal emerging from noise - and it states the value prop in one beat (REMEMBER assembles, FORGET dissolves to dust). It also signals 'this team can actually drive the GPU.' The production-correct method is zero-rejection: bake every inside-pixel of the rasterized word into the position texture once on the CPU, so each particle already owns a guaranteed on-glyph target. The snippet is the SHADER-side fallback that rejection-samples a bound alpha mask when you prefer a pure-GPU path.",
"glslSnippet": "// TEXT beat. PREFERRED (zero rejection): on the CPU, draw the word to a canvas,\n// read alpha, collect every pixel with a>0.5 into inside[], and for particle i\n// write target = vec3((px/W-0.5)*S*aspect, -(py/H-0.5)*S, (hash-0.5)*depth)\n// straight into the position FBO. No per-frame cost.\n//\n// SHADER fallback below: sample a bound alpha mask uMask (NearestFilter) and\n// relax outside particles toward the glyph along the alpha gradient.\nuniform sampler2D uMask; // word rasterized to alpha; e.g. 1024x256\nuniform float uAspect; // mask width/height\nvec3 shapeText(float a, float b, float c, float S){\n const float DEPTH = 0.10;\n vec2 uv = vec2(a, b); // candidate glyph UV\n // a few relaxation steps: walk toward denser alpha so strays land on letters\n for(int i=0;i<4;i++){\n float al = texture2D(uMask, uv).a;\n if(al > 0.5) break;\n float e = 1.0/256.0;\n float gx = texture2D(uMask, uv+vec2(e,0.0)).a - texture2D(uMask, uv-vec2(e,0.0)).a;\n float gy = texture2D(uMask, uv+vec2(0.0,e)).a - texture2D(uMask, uv-vec2(0.0,e)).a;\n vec2 g = vec2(gx,gy);\n uv += normalize(g + 1e-5) * 0.03; // climb the alpha field\n }\n vec3 pos = vec3((uv.x - 0.5) * uAspect, // centre + correct aspect\n -(uv.y - 0.5), // flip: texture v is top-down\n (c - 0.5) * DEPTH); // slab depth so it isn't flat\n return pos * (0.95 * S);\n // PALETTE: glyph core gold/emerald; edge/stray particles cyan->violet,\n // driven by step(uProgress, noise) for the assemble/dissolve dust.\n}",
"seedsNeeded": "a, b (candidate glyph UV / which inside-pixel), c (slab depth). All three used. Requires uniforms uMask (alpha atlas of the word) + uAspect.",
"scaleNote": "0.95*S sizes the word to ~70% frustum width (text reads best wide). uAspect = mask_w/mask_h keeps letters un-stretched. The 4-step alpha-climb is a cheap fallback; for crisp edges and zero runtime cost, prefer the CPU-baked inside[] -> position-FBO path described in the header comment (the nicoptere/barradeau method)."
}
]
},
"sources": [
"https://www.songho.ca/opengl/gl_torus.html",
"https://www.algosome.com/articles/aizawa-attractor-chaos.html",
"https://en.wikipedia.org/wiki/Thomas%27_cyclically_symmetric_attractor",
"https://paulbourke.net/geometry/supershape/",
"https://analyticphysics.com/Higher%20Dimensions/Visualizing%20Calabi-Yau%20Manifolds.htm",
"https://nilesjohnson.net/hopf.html",
"https://mathworld.wolfram.com/KleinBottle.html",
"https://paulbourke.net/geometry/sphericalh/",
"https://www.shadertoy.com/view/tfdBDf",
"https://en.wikipedia.org/wiki/Lorenz_system",
"https://en.wikipedia.org/wiki/List_of_chaotic_maps",
"https://paulbourke.net/geometry/supershape/",
"https://kenbrakke.com/evolver/examples/periodic/gyroid/gyroid.html",
"https://en.wikipedia.org/wiki/Torus_knot",
"https://ykob.github.io/sketch-threejs/sketch/dna.html",
"https://extremelearning.com.au/how-to-evenly-distribute-points-on-a-sphere-more-effectively-than-the-canonical-fibonacci-lattice/",
"https://www.dynamicmath.xyz/strange-attractors/",
"https://en.wikipedia.org/wiki/Hopf_fibration",
"https://en.wikipedia.org/wiki/Gyroid",
"https://en.wikipedia.org/wiki/Torus_knot",
"https://paulbourke.net/geometry/supershape/",
"https://en.wikipedia.org/wiki/Forgetting_curve",
"https://en.wikipedia.org/wiki/Space_colonization",
"https://github.com/nicoptere/FBO and https://barradeau.com/blog/?p=621 (baked image->FBO positions); https://tympanus.net/codrops/2026/01/28/webgpu-gommage-effect-dissolving-msdf-text-into-dust-and-petals-with-three-js-tsl/ and https://github.com/Chlumsky/msdfgen (MSDF >0.5 inside test, Jan 2026)",
"https://www.algosome.com/articles/aizawa-attractor-chaos.html (Aizawa eqs); https://www.geeks3d.com/20140630/lorenz-attractor-butterfly-effect/ (Lorenz GLSL); https://github.com/BrutPitt/glChAoS.P (256M-particle GPU attractor reference)",
"https://paulbourke.net/geometry/supershape/ (exact 3D spherical-product eqs); https://github.com/Softwave/glsl-superformula (GLSL function); https://en.wikipedia.org/wiki/Superformula",
"https://en.wikipedia.org/wiki/Gyroid (G = sinXcosY+sinYcosZ+sinZcosX); https://alessandromastrofini.it/2022/03/29/gyroid-and-minimal-surfaces-for-cad/ (TPMS for CAD, gradient/level-set form)",
"https://paulbourke.net/geometry/sphericalh/ (r=f(theta,phi) spherical harmonic surface, the m0..m7 exponent form); https://github.com/sebh/HLSL-Spherical-Harmonics (shader SH functions)",
"https://www.4rknova.com/blog/2025/09/01/mandelbulb (2025 DE walkthrough, power-8); http://2008.sub.blue/projects/mandelbulb.html (Beddard, the canonical DE formula); https://en.wikipedia.org/wiki/Mandelbulb",
"https://www.songho.ca/opengl/gl_torus.html (torus knot + trefoil parametric); https://rwoodley.org/posts/12-TorusKnots.html (p,q math); https://threejs.org/docs/pages/TorusKnotGeometry.html"
]
}
}

View file

@ -0,0 +1,83 @@
# Waitlist capture — setup (10 minutes)
The public waitlist form (`/dashboard/waitlist`) is already built. It POSTs JSON
to `VITE_WAITLIST_ENDPOINT`. This guide stands up the storage backend: a
Supabase table + Edge Function that stores every signup and (optionally) emails a
confirmation.
Owned data, queryable Postgres, free tier handles launch-day volume.
## 1. Create the project
1. Create a project at <https://supabase.com> (free tier is fine for launch).
2. Grab the **Project Ref** (Settings → General) and the
**service-role key** (Settings → API). The service-role key is a secret —
never commit it or expose it client-side.
## 2. Deploy the schema + function
```sh
supabase link --project-ref <your-project-ref>
supabase db push # applies supabase/migrations/0001_waitlist.sql
supabase functions deploy waitlist-join # deploys supabase/functions/waitlist-join
```
## 3. Configure function secrets
`SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are injected automatically.
Email confirmation is optional — set these to enable it (reuses Resend):
```sh
supabase secrets set RESEND_API_KEY=<your-resend-key>
supabase secrets set WAITLIST_FROM_EMAIL="Vestige <waitlist@yourdomain>"
# Optional: lock CORS to your sites (comma-separated). Omit for "*".
supabase secrets set WAITLIST_ALLOWED_ORIGIN="https://samvallad33.github.io,https://vestige.dev"
```
## 4. Point the form at the function
The function URL looks like:
`https://<project-ref>.functions.supabase.co/waitlist-join`
Set it at build time for the dashboard:
```sh
# apps/dashboard/.env (or your Pages build env)
VITE_WAITLIST_ENDPOINT=https://<project-ref>.functions.supabase.co/waitlist-join
```
Rebuild the dashboard (`pnpm --filter @vestige/dashboard build`) and redeploy.
## 5. Verify
```sh
curl -X POST https://<project-ref>.functions.supabase.co/waitlist-join \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","name":"You","plan":"solo","priority":"sync"}'
# -> {"ok":true,"deduped":false}
# Re-run the same command:
# -> {"ok":true,"deduped":true} (idempotent; no duplicate row)
```
## 6. Export the list on launch day
In the Supabase SQL editor:
```sql
select email, name, plan, priority, notes, created_at
from public.waitlist
order by created_at desc;
```
Or download a CSV from the Table Editor. That list is the asset that converts
to paid over the weeks after the spike.
## Notes
- **RLS is on, anon can do nothing.** Only the service-role key inside the
function can insert. The public anon key (if ever used) has zero table access.
- **Honeypot + email validation** run both client-side (in the form) and
server-side (in the function).
- **Dedup** is enforced by a unique index on the lowercased email; a re-submit
returns `{ok:true,deduped:true}` instead of erroring or duplicating.

10
supabase/config.toml Normal file
View file

@ -0,0 +1,10 @@
# Supabase project config for the Vestige Pro waitlist capture.
# Link to a real project with: supabase link --project-ref <your-ref>
# Then deploy: supabase db push && supabase functions deploy waitlist-join
project_id = "vestige-waitlist"
[functions.waitlist-join]
# Public form posts here; no Supabase JWT required (we gate with the honeypot,
# email validation, and the service-role insert inside the function).
verify_jwt = false

View file

@ -0,0 +1,146 @@
// Vestige Pro waitlist capture — Supabase Edge Function (Deno).
//
// The public /dashboard/waitlist form POSTs JSON here. This function:
// 1. Handles CORS preflight (the form is served from a different origin).
// 2. Validates the email and rejects honeypot/bot submissions.
// 3. Inserts one row per email into public.waitlist (dedup on re-submit).
// 4. Optionally sends a confirmation email via Resend.
//
// Secrets (set with `supabase secrets set`, NEVER committed):
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY — auto-injected by Supabase
// RESEND_API_KEY — optional; if unset, skip email
// WAITLIST_FROM_EMAIL — optional; defaults below
// WAITLIST_ALLOWED_ORIGIN — optional CORS allowlist (comma-sep)
import { createClient } from "jsr:@supabase/supabase-js@2";
const DEFAULT_FROM = "Vestige <waitlist@vestige.dev>";
function corsHeaders(origin: string | null): HeadersInit {
const allow = (Deno.env.get("WAITLIST_ALLOWED_ORIGIN") ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// If an allowlist is configured and the origin isn't on it, fall back to the
// first allowed origin (so browsers see a definite, non-wildcard value).
const allowedOrigin =
allow.length === 0 ? "*" : (origin && allow.includes(origin) ? origin : allow[0]);
return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
"Content-Type": "application/json",
};
}
interface WaitlistPayload {
name?: string;
email?: string;
plan?: string;
priority?: string;
notes?: string;
source?: string;
companySite?: string; // honeypot — must be empty
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function sendConfirmation(email: string, name: string) {
const key = Deno.env.get("RESEND_API_KEY");
if (!key) return; // email is optional; capture still succeeds without it
const from = Deno.env.get("WAITLIST_FROM_EMAIL") ?? DEFAULT_FROM;
const greeting = name ? `Hi ${name},` : "Hi,";
try {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to: email,
subject: "You're on the Vestige Pro list",
text:
`${greeting}\n\nYou're on the Vestige Pro early-access list. ` +
`We'll email you before launch with your invite.\n\n— The Vestige team`,
}),
});
} catch (_err) {
// Never fail the signup because the email provider hiccuped.
}
}
Deno.serve(async (req) => {
const origin = req.headers.get("origin");
const headers = corsHeaders(origin);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ ok: false, error: "method_not_allowed" }), {
status: 405,
headers,
});
}
let body: WaitlistPayload;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
status: 400,
headers,
});
}
// Honeypot: real users never fill this hidden field. Pretend success so bots
// get no signal, but write nothing.
if (body.companySite && body.companySite.trim()) {
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
}
const email = (body.email ?? "").trim();
if (!EMAIL_RE.test(email)) {
return new Response(JSON.stringify({ ok: false, error: "invalid_email" }), {
status: 400,
headers,
});
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
// Insert; on email conflict, treat as already-joined (idempotent, friendly).
const row = {
email,
name: (body.name ?? "").trim() || null,
plan: (body.plan ?? "").trim() || null,
priority: (body.priority ?? "").trim() || null,
notes: (body.notes ?? "").trim() || null,
source: (body.source ?? "vestige-pro-waitlist").trim(),
user_agent: req.headers.get("user-agent")?.slice(0, 400) ?? null,
};
const { error } = await supabase.from("waitlist").insert(row);
if (error) {
// 23505 = unique_violation on email_norm → already on the list.
if (error.code === "23505") {
return new Response(JSON.stringify({ ok: true, deduped: true }), { status: 200, headers });
}
console.error("waitlist insert failed:", error);
return new Response(JSON.stringify({ ok: false, error: "storage_failed" }), {
status: 500,
headers,
});
}
// Fire-and-forget confirmation; the signup is already committed.
await sendConfirmation(email, row.name ?? "");
return new Response(JSON.stringify({ ok: true, deduped: false }), { status: 200, headers });
});

View file

@ -0,0 +1,37 @@
-- Vestige Pro waitlist capture.
--
-- One row per signup from the public /dashboard/waitlist form. The form POSTs
-- through the `waitlist-join` Edge Function (service-role), NOT directly to this
-- table, so Row-Level Security stays closed to anon clients while the function
-- can insert. We keep the table minimal and queryable for launch-day export.
create table if not exists public.waitlist (
id uuid primary key default gen_random_uuid(),
email text not null,
name text,
plan text, -- 'solo' | 'team' (free-form, not enforced)
priority text, -- 'sync' | 'team-memory' | 'postgres' | 'support'
notes text,
source text default 'vestige-pro-waitlist',
user_agent text,
-- Lowercased email for case-insensitive dedup. Generated so callers can't
-- desync it from `email`.
email_norm text generated always as (lower(trim(email))) stored,
created_at timestamptz not null default now()
);
-- One signup per email address. ON CONFLICT in the function turns a re-submit
-- into a friendly "already on the list" instead of a duplicate row or an error.
create unique index if not exists waitlist_email_norm_key
on public.waitlist (email_norm);
-- Newest-first export / dashboards.
create index if not exists waitlist_created_at_idx
on public.waitlist (created_at desc);
-- RLS ON, no anon policies: the anon/public key can do NOTHING here directly.
-- Only the service-role key used inside the Edge Function bypasses RLS to insert.
alter table public.waitlist enable row level security;
comment on table public.waitlist is
'Vestige Pro launch waitlist. Inserted only via the waitlist-join Edge Function (service-role). RLS blocks all anon access.';