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;