feat(launch): waitlist page, raw-WebGPU hero, supabase + vercel infra

The July 14 launch surface, previously uncommitted:
- /dashboard/launch raw-WebGPU particle "memory brain" hero (RawVestigeEngine,
  NeuralWordmark, dendrite sign) + DOM waitlist overlay with share/referral.
- Supabase waitlist client + migrations + welcome edge function; legacy waitlist
  archived under supabase/legacy.
- vercel.json deploy config, root-redirect env wiring, base-path config,
  graph-only route, Playwright launch verifiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-27 11:13:02 -05:00
parent 24ef2d504f
commit 6837198328
27 changed files with 5249 additions and 747 deletions

View file

@ -1,11 +1,24 @@
# Optional public waitlist capture endpoint used by /dashboard/waitlist.
# The page POSTs JSON with: name, email, plan, priority, notes, source, createdAt.
# 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=
# ── Vestige launch waitlist (Supabase) ───────────────────────────────────────
# Copy this file to `.env` (NOT committed) and fill in your Supabase values.
# The /dashboard/launch page writes signups straight to a `waitlist` table using
# the PUBLIC anon key + a Row-Level-Security "insert only" policy, so exposing the
# anon key in the browser is safe (anon can INSERT, cannot read the table).
#
# Get these from Supabase → Project Settings → API.
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=
# Optional support bot endpoint used by /dashboard/waitlist.
# The page POSTs JSON with: question, plan, priority, source, and recent history.
# If unset, the page uses the built-in deterministic onboarding FAQ.
# Optional: the public URL of the launch page, used to build shareable referral
# links (?ref=CODE) that work when sent off-device. Falls back to the current
# origin+path if unset (fine for local/preview). Set this for the deployed site.
VITE_PUBLIC_LAUNCH_URL=
# See docs/launch/waitlist-setup.md for the one SQL block to paste into Supabase
# (creates the table, the insert-only RLS policy, the referral RPCs, and the
# waitlist_count RPC), plus the optional Resend welcome-email function.
# ── Legacy (the older /dashboard/waitlist page) ──────────────────────────────
# Optional POST endpoint (Supabase Edge Function / Formspree / Tally). Unused by
# the new launch page above; kept for the legacy waitlist route.
VITE_WAITLIST_ENDPOINT=
VITE_SUPPORT_BOT_ENDPOINT=

View file

@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@supabase/supabase-js": "^2.108.2",
"three": "^0.172.0"
},
"devDependencies": {

View file

@ -1,7 +1,7 @@
<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.
// Living neural sign: VESTIGE built from REAL
// grown dendrites (space colonization on the letterforms) with a quiet bloom.
// Not glow-on-text.
import { onMount } from 'svelte';
import { growSign, type DendriteSign } from '$lib/landing/dendriteGen';
@ -25,13 +25,9 @@
}
});
// 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">
<div class="neural-sign" class:reduced aria-label="Vestige">
{#if sign}
<svg
viewBox={`0 0 ${sign.width} ${sign.height}`}
@ -52,31 +48,8 @@
<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">
@ -84,11 +57,6 @@
<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}
@ -130,45 +98,7 @@
}
}
/* 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

@ -1,6 +1,6 @@
// 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
// browser (canvas + fonts available), and returns SVG-ready path data.
// Runs once on mount, deterministic via a seeded RNG so the
// same text always grows the same neural sign.
export interface DendritePath {
@ -10,18 +10,18 @@ export interface DendritePath {
len: number;
depth: number;
}
export interface Synapse {
x: number;
y: number;
r: number;
}
export interface DendriteSign {
paths: DendritePath[];
synapses: Synapse[];
width: number;
height: number;
}
export interface GrowSignOptions {
fontPx?: number;
targetW?: number;
iters?: number;
}
// deterministic PRNG (mulberry32) so the sign is stable across loads
function rng(seed: number) {
let a = seed >>> 0;
@ -139,14 +139,17 @@ function growLine(text: string, fontPx: number, iters: number, rand: () => numbe
return { nodes, W, H };
}
/** Grow the full 3-line launch sign. Returns SVG-ready data centered to width 1000. */
export function growSign(): DendriteSign {
/** Grow the launch sign. Returns SVG-ready data centered to width 1000. */
export function growSign(options: GrowSignOptions = {}): 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 }
{
text: 'VESTIGE',
px: options.fontPx ?? 150,
targetW: options.targetW ?? 940,
iters: options.iters ?? 600
}
];
const VBW = 1000;
const lines = specs.map((s) => {
@ -157,15 +160,12 @@ export function growSign(): DendriteSign {
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;
@ -183,14 +183,9 @@ export function growSign(): DendriteSign {
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) };
return { paths, width: VBW, height: Math.round(VBH) };
}

View file

@ -0,0 +1,214 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { Component } from 'svelte';
interface Props {
seed?: number;
reducedMotion?: boolean;
syncTarget?: HTMLElement;
suppress?: boolean;
}
type EngineProps = {
seed?: number;
reducedMotion?: boolean;
class?: string;
syncTarget?: HTMLElement;
suppress?: boolean;
};
let { seed, reducedMotion = false, syncTarget, suppress = false }: Props = $props();
let Engine = $state<Component<EngineProps> | null>(null);
let loading = false;
let bridgeCanvas: HTMLCanvasElement | undefined;
let bridgeFrame = 0;
function liveEngineMode() {
if (typeof document === 'undefined') return null;
return document.querySelector('.raw-vestige-engine.live-engine')?.getAttribute('data-mode');
}
function scheduleEngineLoad() {
if (loading || Engine) return;
loading = true;
import('$lib/launch/RawVestigeEngine.svelte')
.then((module) => {
Engine = module.default as Component<EngineProps>;
})
.catch((error) => {
loading = false;
console.warn('[launch] visual engine failed to load:', error);
});
}
function drawBridge(now = performance.now()) {
const mode = liveEngineMode();
if (!bridgeCanvas || reducedMotion || mode === 'webgpu' || mode === 'fallback') return;
const rect = bridgeCanvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const width = Math.max(1, Math.floor((rect.width || window.innerWidth || 1) * dpr));
const height = Math.max(1, Math.floor((rect.height || window.innerHeight || 1) * dpr));
if (bridgeCanvas.width !== width || bridgeCanvas.height !== height) {
bridgeCanvas.width = width;
bridgeCanvas.height = height;
}
const ctx = bridgeCanvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = width / dpr;
const h = height / dpr;
const t = now * 0.001;
const bridgeSeed = seed ?? 20260625;
const cx = w * 0.5;
const cy = h * (w < 760 ? 0.42 : 0.46);
const span = Math.min(w, h) * (w < 760 ? 0.38 : 0.34);
ctx.clearRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < 140; i += 1) {
const phase = i * 2.399 + bridgeSeed * 0.0001;
const ring = (i % 7) / 7;
const z = Math.sin(t * 0.8 + phase) * 0.5 + 0.5;
const radius = span * (0.18 + ring * 0.86) * (0.74 + z * 0.36);
const angle = phase + t * (0.18 + ring * 0.08);
const squash = 0.42 + z * 0.24;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle * 1.23) * radius * squash;
const size = 1.1 + z * 2.3;
const hue = i % 3 === 0 ? '93,255,166' : i % 3 === 1 ? '54,240,255' : '185,140,255';
ctx.fillStyle = `rgba(${hue},${0.32 + z * 0.42})`;
ctx.shadowColor = `rgba(${hue},0.85)`;
ctx.shadowBlur = 10 + z * 16;
ctx.fillRect(x - size * 0.5, y - size * 0.5, size, size);
}
ctx.globalCompositeOperation = 'source-over';
ctx.shadowBlur = 0;
bridgeFrame = requestAnimationFrame(drawBridge);
}
onMount(() => {
drawBridge();
requestAnimationFrame(() => setTimeout(scheduleEngineLoad, 0));
});
onDestroy(() => {
if (typeof window !== 'undefined') cancelAnimationFrame(bridgeFrame);
});
</script>
<div class="instant-engine" aria-hidden="true">
<div class="instant-grid"></div>
<div class="instant-core"></div>
<canvas bind:this={bridgeCanvas} class="instant-canvas"></canvas>
<div class="instant-nodes"></div>
</div>
{#if Engine}
<Engine
{seed}
{reducedMotion}
class="live-engine"
{syncTarget}
{suppress}
/>
{/if}
<style>
.instant-engine {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
background:
linear-gradient(145deg, rgba(13, 255, 178, 0.12), transparent 34%),
linear-gradient(215deg, rgba(115, 120, 255, 0.14), transparent 40%),
#02030a;
opacity: 1;
transition: opacity 700ms ease;
}
.instant-grid,
.instant-core,
.instant-canvas,
.instant-nodes {
position: absolute;
inset: 0;
}
.instant-canvas {
width: 100%;
height: 100%;
display: block;
}
.instant-grid {
background-image:
linear-gradient(rgba(93, 255, 166, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(82, 230, 255, 0.07) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(180deg, transparent, #000 22%, #000 68%, transparent);
opacity: 0.42;
transform: perspective(700px) rotateX(64deg) translateY(22%);
transform-origin: 50% 72%;
}
.instant-core {
background:
conic-gradient(
from 220deg at 50% 39%,
transparent 0deg,
rgba(93, 255, 166, 0.24) 54deg,
rgba(54, 240, 255, 0.2) 116deg,
rgba(185, 140, 255, 0.18) 182deg,
transparent 255deg,
rgba(93, 255, 166, 0.14) 320deg,
transparent 360deg
);
filter: blur(18px) saturate(1.25);
opacity: 0.8;
animation: instant-turn 8s linear infinite;
}
.instant-nodes {
background-image:
radial-gradient(circle at 22% 34%, rgba(93, 255, 166, 0.88) 0 1px, transparent 2px),
radial-gradient(circle at 34% 46%, rgba(82, 230, 255, 0.9) 0 1px, transparent 2px),
radial-gradient(circle at 50% 29%, rgba(185, 140, 255, 0.88) 0 1px, transparent 2px),
radial-gradient(circle at 62% 44%, rgba(93, 255, 166, 0.86) 0 1px, transparent 2px),
radial-gradient(circle at 74% 33%, rgba(82, 230, 255, 0.86) 0 1px, transparent 2px),
radial-gradient(circle at 44% 57%, rgba(185, 140, 255, 0.76) 0 1px, transparent 2px),
radial-gradient(circle at 58% 62%, rgba(93, 255, 166, 0.72) 0 1px, transparent 2px);
opacity: 0.9;
filter: drop-shadow(0 0 8px rgba(82, 230, 255, 0.75));
}
:global(.raw-vestige-engine.live-engine) {
z-index: 1;
}
@keyframes instant-turn {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.instant-grid {
background-size: 34px 34px;
transform: perspective(520px) rotateX(66deg) translateY(26%);
}
.instant-core {
filter: blur(14px) saturate(1.2);
opacity: 0.72;
}
}
@media (prefers-reduced-motion: reduce) {
.instant-core {
animation: none;
}
}
</style>

View file

@ -0,0 +1,274 @@
<script lang="ts">
// VESTIGE built from REAL grown dendrites (space colonization on the
// letterforms via growSign) — the letters ARE organic neural branches we
// generate, not a font. Rendered as GLOWING neon dendrites with luminous
// synapse nodes, matching the live particle brain. The SHAPE itself is alive.
import { onMount } from 'svelte';
import { growSign, type DendriteSign } from '$lib/landing/dendriteGen';
type Detail = 'desktop' | 'mobile';
const SIGN_CACHE_PREFIX = 'vestige-launch-dendrite-sign-v3';
const NODE_COLORS = ['#7dffb0', '#52e6ff', '#c79bff', '#9b8cff'];
let sign = $state<DendriteSign | null>(null);
let nodes = $state<Array<{ x: number; y: number; r: number; c: string }>>([]);
let reduced = $state(false);
let drawn = $state(false);
let detail = $state<Detail>('desktop');
function cacheKey(nextDetail: Detail) {
return `${SIGN_CACHE_PREFIX}-${nextDetail}`;
}
function isDendriteSign(value: unknown): value is DendriteSign {
const candidate = value as DendriteSign | null;
return Boolean(
candidate &&
typeof candidate.width === 'number' &&
typeof candidate.height === 'number' &&
Array.isArray(candidate.paths) &&
candidate.paths.length > 0
);
}
function readCachedSign(key: string): DendriteSign | null {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
return isDendriteSign(parsed) ? parsed : null;
} catch {
return null;
}
}
function writeCachedSign(key: string, value: DendriteSign) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage may be unavailable in private mode */
}
}
function synapseNodes(value: DendriteSign) {
const pts: Array<{ x: number; y: number; r: number; c: string }> = [];
for (let i = 0; i < value.paths.length; i += 18) {
const m = value.paths[i].d.match(/M([\d.-]+) ([\d.-]+)L/);
if (!m) continue;
pts.push({
x: Number(m[1]),
y: Number(m[2]),
r: 1.1 + (i % 3) * 0.5,
c: NODE_COLORS[i % NODE_COLORS.length]
});
}
return pts;
}
function applySign(value: DendriteSign) {
sign = value;
nodes = synapseNodes(value);
drawn = true;
}
onMount(() => {
reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const coarsePointer = window.matchMedia?.('(pointer: coarse)').matches ?? false;
const narrowViewport = window.innerWidth <= 820;
detail = coarsePointer || narrowViewport ? 'mobile' : 'desktop';
if (detail === 'mobile') {
// Mobile signup must stay instantly interactive. The generated dendrite
// SVG is beautiful, but path growth/rendering can monopolize WebKit's
// main thread on a cold load. Keep the bridge wordmark and let the graph
// own mobile's first seconds.
return;
}
const key = cacheKey(detail);
const cached = readCachedSign(key);
if (cached) {
applySign(cached);
return;
}
const grow = () => {
try {
const s = growSign();
writeCachedSign(key, s);
applySign(s);
} catch (e) {
console.warn('[neural-wordmark] grow failed:', e);
}
};
requestAnimationFrame(grow);
});
</script>
<div class="neural-wordmark" class:mobile={detail === 'mobile'} class:reduced class:drawn aria-label="Vestige">
{#if sign}
<svg
viewBox={`0 0 ${sign.width} ${sign.height}`}
class="sign-svg"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<!-- iridescent neon gradient (green -> cyan -> violet) that flows -->
<linearGradient id="nw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5dffa6" />
<stop offset="30%" stop-color="#36f0ff" />
<stop offset="55%" stop-color="#6db0ff" />
<stop offset="80%" stop-color="#b98cff" />
<stop offset="100%" stop-color="#5dffa6" />
<animate attributeName="x1" values="0%;100%;0%" dur="7s" repeatCount="indefinite" />
<animate attributeName="x2" values="100%;200%;100%" dur="7s" repeatCount="indefinite" />
</linearGradient>
<!-- HEAVY neon bloom (matches the live particle glow) -->
<filter id="nw-bloom" x="-40%" y="-90%" width="180%" height="280%">
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="b0" />
<feColorMatrix
in="b0"
values="0 0 0 0 .55 0 0 0 0 1 0 0 0 0 .75 0 0 0 1 0"
result="g0"
/>
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="b1" />
<feColorMatrix
in="b1"
values="0 0 0 0 .30 0 0 0 0 .92 0 0 0 0 1 0 0 0 .9 0"
result="g1"
/>
<feGaussianBlur in="SourceAlpha" stdDeviation="10" result="b2" />
<feColorMatrix
in="b2"
values="0 0 0 0 .55 0 0 0 0 .50 0 0 0 0 1 0 0 0 .85 0"
result="g2"
/>
<feGaussianBlur in="SourceAlpha" stdDeviation="22" result="b3" />
<feColorMatrix
in="b3"
values="0 0 0 0 .42 0 0 0 0 1 0 0 0 0 .68 0 0 0 .6 0"
result="g3"
/>
<feMerge>
<feMergeNode in="g3" />
<feMergeNode in="g2" />
<feMergeNode in="g1" />
<feMergeNode in="g0" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- subtle point-glow for the synapse nodes (small + tight) -->
<filter id="nw-node" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur in="SourceGraphic" stdDeviation="1.4" result="nb" />
<feMerge>
<feMergeNode in="nb" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g class="bloom-wrap" filter={detail === 'mobile' ? undefined : 'url(#nw-bloom)'}>
<g class="dendrites" stroke="url(#nw-grad)">
{#each sign.paths as p (p.d)}
<path d={p.d} stroke-width={Math.max(p.w, 1.4)} stroke-linecap="round" />
{/each}
</g>
</g>
<!-- luminous synapse nodes (the bright dots in the reference) -->
<g class="synapses" filter="url(#nw-node)">
{#each nodes as n (n.x + '_' + n.y)}
<circle cx={n.x} cy={n.y} r={n.r} fill={n.c} />
{/each}
</g>
</svg>
{:else}
<span class="wordmark-bridge" aria-hidden="true">VESTIGE</span>
{/if}
</div>
<style>
.neural-wordmark {
width: min(760px, 94vw);
min-height: clamp(4.2rem, 16vw, 8.5rem);
margin: 0 auto;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.sign-svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.wordmark-bridge {
display: block;
font-size: clamp(2.45rem, 12vw, 7.8rem);
font-weight: 900;
line-height: 0.9;
letter-spacing: 0;
background: linear-gradient(92deg, #5dffa6 0%, #36f0ff 42%, #b98cff 78%, #5dffa6 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter:
drop-shadow(0 0 8px rgba(93, 255, 166, 0.55))
drop-shadow(0 0 26px rgba(82, 230, 255, 0.38));
}
/* one-shot grow-in (scale+fade), then a perpetual gentle breathing so the
neural SHAPE stays alive — no bouncing letters, the structure shimmers. */
.dendrites,
.synapses {
opacity: 0;
transform: scale(0.94);
transform-origin: 50% 42%;
transition:
opacity 1.2s ease,
transform 1.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.drawn .dendrites,
.drawn .synapses {
opacity: 1;
transform: scale(1);
}
.drawn .dendrites {
animation: nw-breathe 5.5s ease-in-out infinite 1.1s;
}
/* synapse nodes twinkle independently for that living-circuit feel */
.drawn .synapses {
animation: nw-twinkle 2.6s ease-in-out infinite 1.1s;
}
@keyframes nw-breathe {
0%,
100% {
transform: scale(1);
filter: brightness(1);
}
50% {
transform: scale(1.012);
filter: brightness(1.22);
}
}
@keyframes nw-twinkle {
0%,
100% {
opacity: 0.85;
}
50% {
opacity: 1;
}
}
.reduced .dendrites,
.reduced .synapses {
opacity: 1;
transform: none;
transition: none;
animation: none;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,137 @@
// Waitlist backend — direct Supabase calls from the browser using the public
// anon key. The `waitlist` table is INSERT-ONLY to anon (RLS), and every read
// (your own referral code, your referral count) goes through `security definer`
// RPCs that return a single value, so the public anon key never reads the table.
// See docs/launch/waitlist-setup.md for the SQL + go-live runbook.
//
// Env (set in apps/dashboard/.env, NOT committed):
// VITE_SUPABASE_URL=https://<project-ref>.supabase.co
// VITE_SUPABASE_ANON_KEY=<anon public key>
//
// If the env is unset, joinWaitlist returns { ok:false, reason:'unconfigured' }
// so the page can fall back gracefully (local capture) during the demo.
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL as string | undefined;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
let client: SupabaseClient | null = null;
export const waitlistConfigured = Boolean(url && anonKey);
function getClient(): SupabaseClient | null {
if (!waitlistConfigured) return null;
if (!client) {
client = createClient(url as string, anonKey as string, {
auth: { persistSession: false }
});
}
return client;
}
export type JoinResult =
| { ok: true; duplicate: boolean; referralCode: string; referrals: number }
| { ok: false; reason: 'unconfigured' | 'invalid' | 'error'; message?: string };
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const CODE_RE = /^[a-z2-9]{4,16}$/; // matches gen_referral_code() alphabet
/** Read the `?ref=` referral code from the current URL, sanitized. */
export function getReferralCodeFromUrl(): string | undefined {
if (typeof window === 'undefined') return undefined;
try {
const raw = new URLSearchParams(window.location.search).get('ref');
const code = raw?.trim().toLowerCase();
return code && CODE_RE.test(code) ? code : undefined;
} catch {
return undefined;
}
}
/** Build the shareable link for a given referral code (absolute, deploy-aware). */
export function buildShareUrl(referralCode: string): string {
// Prefer the public production URL so the link works when shared off-device;
// fall back to the current origin+path for local/preview testing.
const publicBase = (import.meta.env.VITE_PUBLIC_LAUNCH_URL as string | undefined)?.trim();
const base =
publicBase ||
(typeof window !== 'undefined'
? `${window.location.origin}${window.location.pathname}`
: 'https://samvallad33.github.io/vestige/dashboard/launch');
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}ref=${encodeURIComponent(referralCode)}`;
}
/**
* Join the waitlist. Calls the `join_waitlist` RPC which inserts (or finds an
* existing row) and returns the caller's own referral code + how many people
* they've referred. Idempotent on duplicate email (same code returned, no
* double count).
*/
export async function joinWaitlist(
email: string,
options: { referredBy?: string; referrer?: string } = {}
): Promise<JoinResult> {
const clean = email.trim().toLowerCase();
if (!EMAIL_RE.test(clean)) return { ok: false, reason: 'invalid' };
const sb = getClient();
if (!sb) return { ok: false, reason: 'unconfigured' };
const { data, error } = await sb.rpc('join_waitlist', {
p_email: clean,
p_referred_by: options.referredBy ?? null,
p_referrer: options.referrer ?? null
});
if (error) {
// The RPC raises on a malformed email; surface that as 'invalid'.
if (/invalid email/i.test(error.message)) return { ok: false, reason: 'invalid' };
return { ok: false, reason: 'error', message: error.message };
}
// RPC returns a single-row table → supabase-js gives an array of one object.
const row = Array.isArray(data) ? data[0] : data;
const referralCode = (row?.referral_code as string | undefined) ?? '';
const referrals = (row?.referrals as number | undefined) ?? 0;
const duplicate = Boolean(row?.duplicate);
if (!referralCode) {
// Defensive: RPC succeeded but returned nothing usable.
return { ok: false, reason: 'error', message: 'no referral code returned' };
}
return { ok: true, duplicate, referralCode, referrals };
}
/**
* Live count of people who joined from a given referral code. Powers the
* "N friends joined from your link" counter. Returns null on error/unconfigured.
*/
export async function getReferralCount(referralCode: string): Promise<number | null> {
const sb = getClient();
if (!sb) return null;
try {
const { data, error } = await sb.rpc('referral_count', { p_code: referralCode });
if (error || typeof data !== 'number') return null;
return data;
} catch {
return null;
}
}
/**
* Read the live total signup count. Returns null when unconfigured or on error
* (the page then hides the count).
*/
export async function getWaitlistCount(): Promise<number | null> {
const sb = getClient();
if (!sb) return null;
try {
const { data, error } = await sb.rpc('waitlist_count');
if (error || typeof data !== 'number') return null;
return data;
} catch {
return null;
}
}

View file

@ -1,335 +1,5 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, onNavigate } from '$app/navigation';
import { base } from '$app/paths';
import {
websocket,
isConnected,
memoryCount,
avgRetention,
suppressedCount,
uptimeSeconds,
formatUptime,
} from '$stores/websocket';
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
import InsightToast from '$lib/components/InsightToast.svelte';
import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte';
import VerdictBar from '$lib/components/VerdictBar.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import Icon, { type IconName } from '$lib/components/Icon.svelte';
import { initTheme } from '$stores/theme';
let { children } = $props();
let showCommandPalette = $state(false);
let cmdQuery = $state('');
let cmdInput = $state<HTMLInputElement>(undefined as unknown as HTMLInputElement);
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/') ||
dashboardPath === '/launch' ||
dashboardPath.startsWith('/launch/')
);
onMount(() => {
if (!isMarketingRoute) {
websocket.connect();
}
const teardownTheme = initTheme();
function onKeyDown(e: KeyboardEvent) {
if (isMarketingRoute) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
showCommandPalette = !showCommandPalette;
cmdQuery = '';
if (showCommandPalette) {
requestAnimationFrame(() => cmdInput?.focus());
}
return;
}
if (e.key === 'Escape' && showCommandPalette) {
showCommandPalette = false;
return;
}
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '/') {
e.preventDefault();
const searchInput = document.querySelector<HTMLInputElement>('input[type="text"]');
searchInput?.focus();
return;
}
// Single-key navigation shortcuts
const shortcutMap: Record<string, string> = {
g: '/graph', m: '/memories', t: '/timeline', f: '/feed',
e: '/explore', i: '/intentions', s: '/stats',
r: '/reasoning', a: '/activation', d: '/dreams',
c: '/schedule', p: '/importance', u: '/duplicates',
x: '/contradictions', n: '/patterns',
};
const target = shortcutMap[e.key.toLowerCase()];
if (target && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
goto(`${base}${target}`);
}
}
window.addEventListener('keydown', onKeyDown);
return () => {
websocket.disconnect();
window.removeEventListener('keydown', onKeyDown);
teardownTheme();
};
});
// Native View Transitions for client-side route navigation. Crossfades route
// changes when supported; respects prefers-reduced-motion. This replaces the
// old hand-rolled .animate-page-in keyframe on the route content wrapper.
onNavigate((navigation) => {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
// Each nav item carries a UNIQUE semantic icon (see Icon.svelte). The old
// set reused the same Unicode glyph across multiple items; every entry here
// now has a distinct silhouette that reads instantly.
const nav: { href: string; label: string; icon: IconName; shortcut: string }[] = [
{ href: '/blackbox', label: 'Black Box', icon: 'blackbox', shortcut: 'B' },
{ href: '/memory-prs', label: 'Memory PRs', icon: 'memorypr', shortcut: 'Q' },
{ href: '/graph', label: 'Graph', icon: 'graph', shortcut: 'G' },
{ href: '/reasoning', label: 'Reasoning', icon: 'reasoning', shortcut: 'R' },
{ href: '/memories', label: 'Memories', icon: 'memories', shortcut: 'M' },
{ href: '/timeline', label: 'Timeline', icon: 'timeline', shortcut: 'T' },
{ href: '/feed', label: 'Feed', icon: 'feed', shortcut: 'F' },
{ href: '/explore', label: 'Explore', icon: 'explore', shortcut: 'E' },
{ href: '/activation', label: 'Activation', icon: 'activation', shortcut: 'A' },
{ href: '/dreams', label: 'Dreams', icon: 'dreams', shortcut: 'D' },
{ href: '/schedule', label: 'Schedule', icon: 'schedule', shortcut: 'C' },
{ href: '/importance', label: 'Importance', icon: 'importance', shortcut: 'P' },
{ href: '/duplicates', label: 'Duplicates', icon: 'duplicates', shortcut: 'U' },
{ href: '/contradictions', label: 'Contradictions', icon: 'contradictions', shortcut: 'X' },
{ href: '/patterns', label: 'Patterns', icon: 'patterns', shortcut: 'N' },
{ href: '/intentions', label: 'Intentions', icon: 'intentions', shortcut: 'I' },
{ href: '/stats', label: 'Stats', icon: 'stats', shortcut: 'S' },
{ href: '/settings', label: 'Settings', icon: 'settings', shortcut: ',' },
];
// Mobile nav shows top 5 items
const mobileNav = nav.slice(0, 5);
function isActive(href: string, currentPath: string): boolean {
const path = currentPath.startsWith(base) ? currentPath.slice(base.length) || '/' : currentPath;
if (href === '/graph') return path === '/' || path === '/graph';
return path.startsWith(href);
}
let filteredNav = $derived(
cmdQuery
? nav.filter(n => n.label.toLowerCase().includes(cmdQuery.toLowerCase()))
: nav
);
function cmdNavigate(href: string) {
showCommandPalette = false;
cmdQuery = '';
goto(`${base}${href}`);
}
</script>
{#if isMarketingRoute}
{@render children()}
{:else}
<!-- Ambient background orbs -->
<div class="ambient-orb ambient-orb-1" aria-hidden="true"></div>
<div class="ambient-orb ambient-orb-2" aria-hidden="true"></div>
<div class="ambient-orb ambient-orb-3" aria-hidden="true"></div>
<!-- Desktop: sidebar + content -->
<!-- Mobile: content + bottom nav -->
<div class="flex flex-col md:flex-row h-screen overflow-hidden bg-void relative z-[1]">
<!-- Desktop Sidebar (hidden on mobile) -->
<nav class="hidden md:flex w-16 lg:w-56 flex-shrink-0 glass-sidebar flex-col">
<!-- Logo -->
<a href="{base}/graph" class="logo-link flex items-center gap-3 px-4 py-5 border-b border-synapse/10">
<div class="logo-mark w-8 h-8 rounded-lg bg-gradient-to-br from-dream to-synapse flex items-center justify-center text-bright shadow-lg shadow-synapse/20">
<Icon name="logo" size={18} strokeWidth={1.8} />
</div>
<span class="hidden lg:block text-sm font-semibold text-bright tracking-[0.18em]">VESTIGE</span>
</a>
<!-- Nav items -->
<div class="flex-1 min-h-0 overflow-y-auto py-3 flex flex-col gap-1 px-2">
{#each nav as item}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href="{base}{item.href}"
class="nav-link group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm
{active
? 'bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border'
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
>
<span class="nav-icon w-5 flex justify-center transition-transform duration-200 group-hover:scale-110">
<Icon name={item.icon} size={18} />
</span>
<span class="hidden lg:block">{item.label}</span>
<span class="hidden lg:block ml-auto text-[10px] text-muted/50 font-mono">{item.shortcut}</span>
</a>
{/each}
</div>
<!-- Quick action -->
<div class="px-2 pb-2">
<button
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:text-dim hover:bg-white/[0.03] transition border border-subtle/15"
>
<Icon name="command" size={14} />
<span class="hidden lg:block">Command</span>
<span class="hidden lg:block ml-auto text-[10px] font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">⌘K</span>
</button>
</div>
<!-- Status footer -->
<div class="px-3 py-4 border-t border-synapse/10 space-y-2">
<div class="flex items-center gap-2 text-xs">
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
<div class="ml-auto">
<ThemeToggle />
</div>
</div>
<div class="hidden lg:block text-xs text-muted space-y-0.5">
<div>{$memoryCount} memories</div>
<div>{($avgRetention * 100).toFixed(0)}% retention</div>
<!-- v2.0.7: surface uptime_secs from the Heartbeat event. Fires
every 30s so this self-refreshes. "up 3d 4h" format. -->
{#if $uptimeSeconds > 0}
<div title="MCP server uptime">up {formatUptime($uptimeSeconds)}</div>
{/if}
</div>
{#if $suppressedCount > 0}
<div class="hidden lg:block pt-1">
<ForgettingIndicator />
</div>
{/if}
</div>
</nav>
<!-- Main content -->
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
<AmbientAwarenessStrip />
<VerdictBar />
<div class="flex-1 min-h-0 overflow-y-auto">
{@render children()}
</div>
</main>
<!-- Mobile Bottom Nav (hidden on desktop) -->
<nav class="md:hidden fixed bottom-0 inset-x-0 glass border-t border-synapse/10 z-40 safe-bottom">
<div class="flex items-center justify-around px-2 py-1">
{#each mobileNav as item}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href="{base}{item.href}"
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem]
{active ? 'text-synapse-glow' : 'text-muted'}"
>
<Icon name={item.icon} size={20} />
<span class="text-[9px]">{item.label}</span>
</a>
{/each}
<!-- More button opens command palette on mobile -->
<button
onclick={() => { showCommandPalette = true; cmdQuery = ''; requestAnimationFrame(() => cmdInput?.focus()); }}
class="flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg text-muted min-w-[3.5rem]"
>
<span class="text-lg"></span>
<span class="text-[9px]">More</span>
</button>
</div>
</nav>
</div>
<!-- v2.2 Pulse — InsightToast overlay (floating, fixed) -->
<InsightToast />
{/if}
<!-- Command Palette overlay -->
{#if showCommandPalette && !isMarketingRoute}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4 bg-void/60 backdrop-blur-sm"
onkeydown={(e) => { if (e.key === 'Escape') showCommandPalette = false; }}
onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }}
>
<div class="w-full max-w-lg glass-panel rounded-xl shadow-2xl shadow-synapse/10 overflow-hidden">
<div class="flex items-center gap-3 px-4 py-3 border-b border-synapse/10">
<span class="text-synapse"><Icon name="search" size={16} /></span>
<input
bind:this={cmdInput}
bind:value={cmdQuery}
type="text"
placeholder="Navigate to..."
class="flex-1 bg-transparent text-text text-sm placeholder:text-muted focus:outline-none"
onkeydown={(e) => {
if (e.key === 'Enter' && filteredNav.length > 0) {
cmdNavigate(filteredNav[0].href);
}
}}
/>
<span class="text-[10px] text-muted font-mono bg-white/[0.04] px-1.5 py-0.5 rounded">esc</span>
</div>
<div class="max-h-72 overflow-y-auto py-1">
{#each filteredNav as item}
<button
onclick={() => cmdNavigate(item.href)}
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-dim hover:text-text hover:bg-white/[0.04] transition"
>
<span class="w-5 flex justify-center"><Icon name={item.icon} size={17} /></span>
<span>{item.label}</span>
<span class="ml-auto text-[10px] text-muted/50 font-mono hidden md:block">{item.shortcut}</span>
</button>
{/each}
{#if filteredNav.length === 0}
<div class="px-4 py-6 text-center text-sm text-muted">No matches</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Logo breathes a faint synapse glow on hover — the mark feels live. */
.logo-mark {
transition:
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.3s ease;
}
.logo-link:hover .logo-mark {
transform: rotate(-6deg) scale(1.08);
box-shadow:
0 0 0 1px rgba(129, 140, 248, 0.4),
0 0 22px rgba(99, 102, 241, 0.5);
}
/* The active nav item's icon picks up a soft drop-shadow glow so the
current location reads at a glance even in the collapsed (icon-only)
sidebar. */
.nav-link.text-synapse-glow .nav-icon :global(svg),
.nav-active-border .nav-icon :global(svg) {
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.55));
}
</style>
{@render children()}

View file

@ -1,6 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import LaunchPage from './launch/+page.svelte';
onMount(() => goto('/graph', { replaceState: true }));
// This standalone promo deploy serves the waitlist/launch page DIRECTLY at the
// site root ("/"), prerendered, so visitors see the signup instantly with no
// dashboard flash or router delay. Gated by VITE_ROOT_REDIRECT so the normal
// dashboard/embedded build is unaffected: when it is set to '/launch' the root
// IS the launch page; otherwise the root behaves like the dashboard home
// (redirect to /graph) exactly as before.
const promoRoot = (import.meta.env.VITE_ROOT_REDIRECT as string | undefined)?.trim() === '/launch';
onMount(() => {
if (!promoRoot) goto('/graph', { replaceState: true });
});
</script>
{#if promoRoot}
<LaunchPage />
{/if}

View file

@ -0,0 +1,5 @@
// Standalone promo deploy: when VITE_ROOT_REDIRECT=/launch, the root route
// prerenders the launch page too. In the normal dashboard build this route stays
// tiny and redirects to /graph on mount.
export const ssr = true;
export const prerender = true;

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import LaunchEngineHost from '$lib/launch/LaunchEngineHost.svelte';
// A stripped copy of the launch hero with ALL text/overlay removed — just the
// raw WebGPU particle brain, full viewport. For demo GIFs / screenshots where
// the graph is the only thing on screen.
const heroSeed = 20260625;
let mounted = $state(false);
let prefersReducedMotion = $state(false);
let shell = $state<HTMLElement | undefined>(undefined);
onMount(() => {
mounted = true;
prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
});
</script>
<svelte:head>
<title>Vestige · memory that watches itself think</title>
<meta name="robots" content="noindex" />
</svelte:head>
<main class="graph-shell" bind:this={shell} aria-label="Vestige memory graph">
<LaunchEngineHost
seed={heroSeed}
reducedMotion={prefersReducedMotion}
syncTarget={shell}
suppress={false}
/>
</main>
<style>
:global(body) {
margin: 0;
background: #02030a;
}
:global(*) {
box-sizing: border-box;
}
/* The engine writes --burst / --flash onto this each frame; static fallbacks so
the first frame is never NaN. Same shell contract as the launch hero, minus
every overlay child. */
.graph-shell {
--burst: 0;
--flash: 0;
position: relative;
width: 100vw;
min-height: 100vh;
min-height: 100svh;
overflow: hidden;
background: #02030a;
isolation: isolate;
}
@property --burst {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
@property --flash {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
</style>

View file

@ -0,0 +1,5 @@
// Graph-only demo route — the raw WebGPU particle brain, full screen, NO text.
// Same client-only contract as /launch (the hero is a browser-only WebGPU/canvas
// experience), so ssr is off; the engine mounts and runs on the client.
export const ssr = false;
export const prerender = true;

File diff suppressed because it is too large Load diff

View file

@ -1,5 +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;
// The launch page prerenders the visible overlay, wordmark bridge, and inert
// canvas elements as HTML. The raw WebGPU/canvas engine still boots only in
// onMount, so browser GPU APIs never execute during prerender.
export const ssr = true;
export const prerender = true;

View file

@ -15,7 +15,11 @@ const config = {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
// 200.html (not index.html) is the SPA fallback so it never clobbers a
// PRERENDERED root index.html. The standalone promo build prerenders the
// waitlist page at "/", and that index.html must survive as real HTML so
// visitors see the signup instantly with no empty-shell router boot.
fallback: '200.html',
precompress: true,
strict: false
}),

View file

@ -0,0 +1,182 @@
import pw from '/Users/entity002/vestige-launch-main/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js';
import { mkdirSync } from 'node:fs';
const { chromium } = pw;
const URL = process.env.LAUNCH_URL || 'http://127.0.0.1:5180/dashboard/launch';
const OUT_DIR = '/tmp/vestige-launch-mobile';
mkdirSync(OUT_DIR, { recursive: true });
async function snapshot(page, label) {
const facts = await page.evaluate((snapshotLabel) => {
const engine = document.querySelector('.raw-vestige-engine');
const gpu = document.querySelector('.gpu-canvas');
const fallback = document.querySelector('.fallback-canvas');
const instantCanvas = document.querySelector('.instant-canvas');
const wordmarkBridge = document.querySelector('.wordmark-bridge');
const signSvg = document.querySelector('.sign-svg');
const input = document.querySelector('.email');
const rectFor = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
w: Math.round(r.width),
h: Math.round(r.height),
top: Math.round(r.top),
left: Math.round(r.left),
opacity: style.opacity,
display: style.display,
visibility: style.visibility
};
};
return {
label: snapshotLabel,
mode: engine?.getAttribute('data-mode') ?? 'missing',
engine: rectFor(engine),
gpu: rectFor(gpu),
fallback: rectFor(fallback),
instantCanvas: rectFor(instantCanvas),
wordmarkBridge: rectFor(wordmarkBridge),
signSvg: rectFor(signSvg),
signPathCount: document.querySelectorAll('.sign-svg path').length,
input: rectFor(input),
bodyText: document.body.innerText.trim().slice(0, 120),
webgpu: Boolean(navigator.gpu),
viewport: { w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio }
};
}, label);
await page.screenshot({ path: `${OUT_DIR}/${label}.png`, fullPage: false });
return facts;
}
async function sampleFallbackPixels(page) {
return page.evaluate(() => {
const c = document.querySelector('.fallback-canvas');
if (!c || c.width < 2 || c.height < 2) return { ok: false, reason: 'no sized fallback canvas' };
const sample = document.createElement('canvas');
sample.width = 120;
sample.height = 180;
const ctx = sample.getContext('2d');
if (!ctx) return { ok: false, reason: 'no 2d context' };
try {
ctx.drawImage(c, 0, 0, sample.width, sample.height);
const data = ctx.getImageData(0, 0, sample.width, sample.height).data;
let lit = 0;
let max = 0;
for (let i = 0; i < data.length; i += 4) {
const lum = (data[i] + data[i + 1] + data[i + 2]) / 3;
if (lum > 18) lit += 1;
max = Math.max(max, data[i], data[i + 1], data[i + 2]);
}
return {
ok: true,
width: c.width,
height: c.height,
litFraction: +(lit / (data.length / 4)).toFixed(3),
maxChannel: max
};
} catch (error) {
return { ok: false, reason: String(error).slice(0, 120) };
}
});
}
async function main() {
const browser = await chromium.launch({
headless: true,
args: [
'--enable-unsafe-webgpu',
'--enable-features=Vulkan,WebGPU',
'--ignore-gpu-blocklist',
'--use-gl=angle'
]
});
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true
});
const page = await context.newPage();
const consoleIssues = [];
page.on('console', (message) => {
if (['error', 'warning'].includes(message.type())) {
consoleIssues.push(`${message.type()}: ${message.text()}`);
}
});
page.on('pageerror', (error) => consoleIssues.push(`pageerror: ${error.message}`));
const rounds = [];
for (let i = 0; i < 4; i += 1) {
const started = Date.now();
await page.goto(URL, { waitUntil: 'domcontentloaded' });
const domcontentloadedMs = Date.now() - started;
await page.waitForTimeout(120);
const t120 = await snapshot(page, `reload-${i}-120ms`);
await page.waitForTimeout(480);
const t600 = await snapshot(page, `reload-${i}-600ms`);
const pixels600 = await sampleFallbackPixels(page);
await page.waitForTimeout(900);
const t1500 = await snapshot(page, `reload-${i}-1500ms`);
await page.waitForTimeout(1300);
const t2800 = await snapshot(page, `reload-${i}-2800ms`);
rounds.push({ i, domcontentloadedMs, t120, t600, pixels600, t1500, t2800 });
await page.reload({ waitUntil: 'domcontentloaded' });
}
await context.close();
await browser.close();
const failures = [];
const opacityNumber = (value) => Number.parseFloat(value ?? '0') || 0;
const bridgeVisible = (snap) =>
Boolean(
snap.instantCanvas &&
snap.instantCanvas.w >= 350 &&
snap.instantCanvas.h >= 760 &&
opacityNumber(snap.instantCanvas.opacity) >= 0.9
);
for (const round of rounds) {
for (const snap of [round.t120, round.t600, round.t1500, round.t2800]) {
if (snap.mode === 'missing') {
if (!bridgeVisible(snap)) {
failures.push(`reload ${round.i} ${snap.label}: engine missing and instant 3D bridge not visible`);
}
continue;
}
if (snap.webgpu) {
if (snap.mode === 'fallback') {
failures.push(`reload ${round.i} ${snap.label}: fallback appeared before WebGPU`);
}
if (opacityNumber(snap.fallback?.opacity) > 0.01) {
failures.push(`reload ${round.i} ${snap.label}: fallback canvas is visible on WebGPU path`);
}
if (snap.mode === 'booting') {
if (!bridgeVisible(snap)) {
failures.push(`reload ${round.i} ${snap.label}: instant 3D bridge not visible during boot`);
}
}
} else if (!snap.fallback || snap.fallback.w < 350 || snap.fallback.h < 760) {
failures.push(`reload ${round.i} ${snap.label}: fallback canvas not viewport-sized without WebGPU`);
}
if (!snap.wordmarkBridge && !snap.signSvg) {
failures.push(`reload ${round.i} ${snap.label}: no wordmark bridge/svg`);
}
if (!snap.input || snap.input.w < 250) {
failures.push(`reload ${round.i} ${snap.label}: signup input not visible`);
}
}
if (!round.t600.webgpu && (!round.pixels600.ok || round.pixels600.litFraction < 0.02)) {
failures.push(`reload ${round.i}: fallback pixels not lit at 600ms`);
}
}
console.log(JSON.stringify({ url: URL, rounds, consoleIssues, failures, screenshotDir: OUT_DIR }, null, 2));
if (failures.length) process.exit(1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,217 @@
import pw from '/Users/entity002/vestige-launch-main/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js';
const { chromium } = pw;
const URL = 'http://127.0.0.1:5180/dashboard/launch';
const OUT_DESKTOP = '/Users/entity002/Desktop/vestige-testimonial/vestige-launch-node-only-final-desktop.png';
const OUT_MOBILE = '/Users/entity002/Desktop/vestige-testimonial/vestige-launch-node-only-final-mobile.png';
const consoleErrors = [];
async function run() {
const browser = await chromium.launch({
headless: true,
args: [
'--enable-unsafe-webgpu',
'--enable-features=Vulkan,WebGPU',
'--use-angle=metal',
'--ignore-gpu-blocklist',
'--use-gl=angle'
]
});
// ---- DESKTOP ----
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2
});
const page = await ctx.newPage();
page.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push('[console.error] ' + m.text());
});
page.on('pageerror', (e) => consoleErrors.push('[pageerror] ' + e.message));
await page.goto(URL, { waitUntil: 'networkidle' });
// let WebGPU boot, then wait deep into the loop so we capture the HOLD beat
// (formed, dense formation) rather than the sparse STREAM/DISSOLVE beats.
// loop = 20s; phase ~0.5 (deep hold) lands around t=10s after start.
await page.waitForTimeout(10000);
const checks = await page.evaluate(() => {
const engine = document.querySelector('.raw-vestige-engine');
const canvases = Array.from(document.querySelectorAll('canvas'));
const banned = document.querySelectorAll('svg,line,path,circle,[stroke]');
const cov = canvases.map((c) => {
const r = c.getBoundingClientRect();
return {
cls: c.className,
w: Math.round(r.width),
h: Math.round(r.height),
coversW: r.width >= window.innerWidth - 2,
coversH: r.height >= window.innerHeight - 2,
opacity: getComputedStyle(c).opacity
};
});
return {
innerTextEmpty: document.body.innerText.trim() === '',
innerTextValue: JSON.stringify(document.body.innerText.trim()).slice(0, 80),
bannedCount: banned.length,
dataMode: engine ? engine.getAttribute('data-mode') : 'NO_ENGINE',
canvasCount: canvases.length,
coverage: cov,
webgpuAvailable: !!navigator.gpu,
viewport: { w: window.innerWidth, h: window.innerHeight }
};
});
// sample pixels off the visible canvas to confirm: not white, bright, dense, colorful
const pixelStats = await page.evaluate(async () => {
const engine = document.querySelector('.raw-vestige-engine');
const mode = engine?.getAttribute('data-mode');
const cls = mode === 'fallback' ? '.fallback-canvas' : '.gpu-canvas';
const c = document.querySelector(cls);
if (!c) return { error: 'no canvas for mode ' + mode };
// draw the (possibly webgpu) canvas onto a 2d sampler canvas
const s = document.createElement('canvas');
s.width = 200;
s.height = 120;
const g = s.getContext('2d');
try {
g.drawImage(c, 0, 0, 200, 120);
} catch (e) {
return { error: 'drawImage failed: ' + e.message };
}
const data = g.getImageData(0, 0, 200, 120).data;
let lit = 0;
let white = 0;
let rSum = 0;
let gSum = 0;
let bSum = 0;
let maxChan = 0;
const n = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const gg = data[i + 1];
const b = data[i + 2];
const lum = (r + gg + b) / 3;
if (lum > 18) lit++;
if (r > 240 && gg > 240 && b > 240) white++;
rSum += r;
gSum += gg;
bSum += b;
maxChan = Math.max(maxChan, r, gg, b);
}
// color spread: difference between channel averages signals "colorful" not gray
const ra = rSum / n;
const ga = gSum / n;
const ba = bSum / n;
const spread = Math.max(ra, ga, ba) - Math.min(ra, ga, ba);
return {
litFraction: +(lit / n).toFixed(3),
whiteFraction: +(white / n).toFixed(4),
avg: { r: +ra.toFixed(1), g: +ga.toFixed(1), b: +ba.toFixed(1) },
colorSpread: +spread.toFixed(1),
brightestChannel: maxChan
};
});
await page.screenshot({ path: OUT_DESKTOP });
// brightness/coverage proof from the ACTUAL screenshot PNG (reliable, unlike
// GPU-canvas drawImage readback which returns black for a webgpu context).
const shotBuf = await page.screenshot({ type: 'png' });
// pointer interaction smoke: move mouse across center, ensure no crash
await page.mouse.move(400, 400);
await page.mouse.move(900, 500, { steps: 10 });
await page.waitForTimeout(800);
// extra phase-spaced captures so we can see several formations cycle
for (let k = 0; k < 3; k++) {
await page.waitForTimeout(6500);
await page.screenshot({ path: `/tmp/launch-phase-${k}.png` });
}
await ctx.close();
// ---- MOBILE ----
const mctx = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true
});
const mpage = await mctx.newPage();
mpage.on('pageerror', (e) => consoleErrors.push('[mobile pageerror] ' + e.message));
await mpage.goto(URL, { waitUntil: 'networkidle' });
await mpage.waitForTimeout(4000);
const mMode = await mpage.evaluate(
() => document.querySelector('.raw-vestige-engine')?.getAttribute('data-mode')
);
await mpage.screenshot({ path: OUT_MOBILE });
await mctx.close();
await browser.close();
// decode the desktop screenshot PNG for honest brightness/colorfulness stats.
const shotStats = await analyzePng(shotBuf);
console.log(
JSON.stringify(
{ checks, pixelStats, shotStats, mobileMode: mMode, consoleErrors },
null,
2
)
);
}
// Minimal PNG decoder via the platform's ImageDecoder is unavailable in node, so
// use sharp if present; otherwise fall back to a coarse byte-histogram of the
// raw PNG (good enough to confirm "not all black / has color").
async function analyzePng(buf) {
try {
const sharpMod = await import(
'/Users/entity002/vestige-launch-main/node_modules/.pnpm/node_modules/sharp/lib/index.js'
).catch(() => null);
if (sharpMod && sharpMod.default) {
const sharp = sharpMod.default;
const { data, info } = await sharp(buf)
.resize(240, 150, { fit: 'fill' })
.raw()
.toBuffer({ resolveWithObject: true });
const ch = info.channels;
let lit = 0;
let white = 0;
let rS = 0;
let gS = 0;
let bS = 0;
const n = info.width * info.height;
for (let i = 0; i < data.length; i += ch) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if ((r + g + b) / 3 > 16) lit++;
if (r > 240 && g > 240 && b > 240) white++;
rS += r;
gS += g;
bS += b;
}
const ra = rS / n;
const ga = gS / n;
const ba = bS / n;
return {
decoder: 'sharp',
litFraction: +(lit / n).toFixed(3),
whiteFraction: +(white / n).toFixed(4),
avg: { r: +ra.toFixed(1), g: +ga.toFixed(1), b: +ba.toFixed(1) },
colorSpread: +(Math.max(ra, ga, ba) - Math.min(ra, ga, ba)).toFixed(1)
};
}
} catch (e) {
return { decoder: 'failed', error: String(e).slice(0, 120) };
}
return { decoder: 'none', note: 'sharp unavailable; rely on screenshot file' };
}
run().catch((e) => {
console.error('VERIFY FAILED:', e);
process.exit(1);
});

View file

@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5173,
port: process.env.PORT ? Number(process.env.PORT) : 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3927',

94
pnpm-lock.yaml generated
View file

@ -14,6 +14,9 @@ importers:
apps/dashboard:
dependencies:
'@supabase/supabase-js':
specifier: ^2.108.2
version: 2.108.2
three:
specifier: ^0.172.0
version: 0.172.0
@ -299,66 +302,79 @@ packages:
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.56.0':
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.56.0':
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.56.0':
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.56.0':
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.56.0':
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.56.0':
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.56.0':
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.56.0':
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.56.0':
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.56.0':
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
@ -393,6 +409,33 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@supabase/auth-js@2.108.2':
resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==}
engines: {node: '>=20.0.0'}
'@supabase/functions-js@2.108.2':
resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==}
engines: {node: '>=20.0.0'}
'@supabase/phoenix@0.4.4':
resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==}
'@supabase/postgrest-js@2.108.2':
resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==}
engines: {node: '>=20.0.0'}
'@supabase/realtime-js@2.108.2':
resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==}
engines: {node: '>=20.0.0'}
'@supabase/storage-js@2.108.2':
resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==}
engines: {node: '>=20.0.0'}
'@supabase/supabase-js@2.108.2':
resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==}
engines: {node: '>=20.0.0'}
'@sveltejs/acorn-typescript@1.0.9':
resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==}
peerDependencies:
@ -472,24 +515,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
@ -708,6 +755,10 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
@ -769,24 +820,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@ -946,6 +1001,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -1241,6 +1299,38 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@supabase/auth-js@2.108.2':
dependencies:
tslib: 2.8.1
'@supabase/functions-js@2.108.2':
dependencies:
tslib: 2.8.1
'@supabase/phoenix@0.4.4': {}
'@supabase/postgrest-js@2.108.2':
dependencies:
tslib: 2.8.1
'@supabase/realtime-js@2.108.2':
dependencies:
'@supabase/phoenix': 0.4.4
tslib: 2.8.1
'@supabase/storage-js@2.108.2':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
'@supabase/supabase-js@2.108.2':
dependencies:
'@supabase/auth-js': 2.108.2
'@supabase/functions-js': 2.108.2
'@supabase/postgrest-js': 2.108.2
'@supabase/realtime-js': 2.108.2
'@supabase/storage-js': 2.108.2
'@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)':
dependencies:
acorn: 8.15.0
@ -1547,6 +1637,8 @@ snapshots:
html-escaper@2.0.2: {}
iceberg-js@0.8.1: {}
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.8
@ -1776,6 +1868,8 @@ snapshots:
totalist@3.0.1: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
undici-types@6.21.0:

View file

@ -1,10 +1,16 @@
# Supabase project config for the Vestige Pro waitlist capture.
# Supabase project config for the Vestige LAUNCH waitlist (/dashboard/launch).
#
# Link to a real project with: supabase link --project-ref <your-ref>
# Then deploy: supabase db push && supabase functions deploy waitlist-join
# Apply the schema: supabase db push
# Deploy the welcome email fn: supabase functions deploy waitlist-welcome --no-verify-jwt
#
# The launch page inserts directly with the anon key (insert-only RLS + RPCs);
# see supabase/migrations/0002_launch_waitlist.sql and docs/launch/waitlist-setup.md.
# The legacy service-role waitlist-join function is archived in supabase/legacy/.
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).
[functions.waitlist-welcome]
# Invoked by a Database Webhook on INSERT into public.waitlist (no end-user JWT);
# guard with the optional WAITLIST_WEBHOOK_SECRET header instead.
verify_jwt = false

View file

@ -0,0 +1,165 @@
// Vestige launch waitlist — welcome + SHARE email (Supabase Edge Function, Deno).
//
// Fired by a Supabase Database Webhook on INSERT into public.waitlist. It emails
// the new signup a confirmation that contains THEIR personal referral link and a
// one-tap "Share on X" button. That email is the launch-day multiplier: it
// reaches people after they've closed the tab and nudges them to share, which is
// what actually compounds a dev-launch waitlist.
//
// Secrets (set with `supabase secrets set`, NEVER committed):
// RESEND_API_KEY — required to actually send (no key => no-op, 200 OK)
// WAITLIST_FROM — sender, e.g. "Vestige <onboarding@resend.dev>"
// (use a verified domain in production)
// WAITLIST_PUBLIC_URL — the public launch page, e.g.
// "https://samvallad33.github.io/vestige/dashboard/launch"
// WAITLIST_WEBHOOK_SECRET — optional shared secret; if set, the webhook must
// send it as the `x-webhook-secret` header.
//
// Deploy: supabase functions deploy waitlist-welcome --no-verify-jwt
// Wire up: Supabase → Database → Webhooks → on INSERT of public.waitlist →
// Supabase Edge Functions → waitlist-welcome.
const DEFAULT_FROM = "Vestige <onboarding@resend.dev>";
const DEFAULT_PUBLIC_URL = "https://samvallad33.github.io/vestige/dashboard/launch";
interface WebhookPayload {
type?: string; // 'INSERT'
record?: {
email?: string;
referral_code?: string;
};
}
function shareUrl(base: string, code: string): string {
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}ref=${encodeURIComponent(code)}`;
}
function buildEmail(link: string, xIntent: string) {
const text =
`You're on the Vestige waitlist. 🧠\n\n` +
`Vestige is local-first memory for AI coding agents — rendered as a living ` +
`WebGPU particle brain you can watch remember, forget, and reform. ` +
`Launching July 14, 2026.\n\n` +
`Want in earlier? Every friend who joins from your personal link moves you ` +
`up the list:\n\n${link}\n\n` +
`Share it on X: ${xIntent}\n\n` +
`— Sam (building Vestige solo)`;
const html = `<!doctype html>
<html>
<body style="margin:0;background:#02030a;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#eaf3ff;">
<div style="max-width:520px;margin:0 auto;padding:40px 28px;">
<div style="font-weight:800;letter-spacing:.34em;font-size:13px;color:#8fb6ff;margin-bottom:24px;">VESTIGE</div>
<h1 style="font-size:22px;line-height:1.3;margin:0 0 14px;">You're on the waitlist. 🧠</h1>
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 18px;">
Vestige is local-first memory for AI coding agents rendered as a living
WebGPU particle brain you can watch remember, forget, and reform.
<strong style="color:#8fe9ff;">Launching July 14, 2026.</strong>
</p>
<p style="font-size:15px;line-height:1.6;color:#b9cdec;margin:0 0 10px;">
Want in earlier? Every friend who joins from your personal link moves you up the list:
</p>
<div style="background:#0a1020;border:1px solid rgba(140,190,255,.28);border-radius:12px;padding:14px 16px;margin:0 0 20px;font-size:13px;color:#bfe0ff;word-break:break-all;">
${link}
</div>
<a href="${xIntent}" style="display:inline-block;background:#f5f8ff;color:#0a0f1a;font-weight:700;font-size:15px;text-decoration:none;padding:12px 22px;border-radius:12px;">
Share on X
</a>
<p style="font-size:13px;line-height:1.6;color:#7d93b8;margin:28px 0 0;">
Sam, building Vestige solo. Reply any time.
</p>
</div>
</body>
</html>`;
return { text, html };
}
Deno.serve(async (req) => {
// optional shared-secret check
const expected = Deno.env.get("WAITLIST_WEBHOOK_SECRET");
if (expected && req.headers.get("x-webhook-secret") !== expected) {
return new Response(JSON.stringify({ ok: false, error: "unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
let payload: WebhookPayload;
try {
payload = await req.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "invalid_json" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const email = payload.record?.email?.trim();
const code = payload.record?.referral_code?.trim();
if (!email || !code) {
// Nothing to send; ack so the webhook doesn't retry forever.
return new Response(JSON.stringify({ ok: true, skipped: "no email/code" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const key = Deno.env.get("RESEND_API_KEY");
if (!key) {
// Email is optional infra — capture already succeeded. Ack with a note.
return new Response(JSON.stringify({ ok: true, skipped: "no RESEND_API_KEY" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const from = Deno.env.get("WAITLIST_FROM") ?? DEFAULT_FROM;
const base = Deno.env.get("WAITLIST_PUBLIC_URL") ?? DEFAULT_PUBLIC_URL;
const link = shareUrl(base, code);
const xText =
"I just joined the Vestige waitlist — local-first memory for AI agents, " +
"rendered as a live WebGPU particle brain. Launching July 14:";
const xIntent =
`https://twitter.com/intent/tweet?text=${encodeURIComponent(xText)}` +
`&url=${encodeURIComponent(link)}`;
const { text, html } = buildEmail(link, xIntent);
try {
const res = 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 waitlist — here's your invite link",
text,
html,
}),
});
if (!res.ok) {
const detail = await res.text();
console.error("resend send failed:", res.status, detail);
// Ack anyway: the signup is committed; we don't want infinite retries.
return new Response(JSON.stringify({ ok: false, error: "send_failed" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
} catch (err) {
console.error("resend send threw:", err);
return new Response(JSON.stringify({ ok: false, error: "send_threw" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true, sent: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});

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.';

21
supabase/legacy/README.md Normal file
View file

@ -0,0 +1,21 @@
# Legacy waitlist backend (archived)
These files backed the **old** `/dashboard/waitlist` page (Canvas2D grid +
support bot), which POSTed signups to the `waitlist-join` Edge Function using a
service-role insert.
The **live launch page** is `/dashboard/launch`. It uses a different, simpler
architecture: direct browser inserts with the public anon key + insert-only RLS,
plus `security definer` RPCs for the referral loop. See:
- Schema + RPCs: `supabase/migrations/0002_launch_waitlist.sql`
- Go-live runbook: `docs/launch/waitlist-setup.md`
- Welcome/share email: `supabase/functions/waitlist-welcome/`
Both schemas declared a table literally named `waitlist` with **incompatible**
shapes (this one is `uuid` PK with `name/plan/notes`; the launch one is `bigint`
PK with `referral_code/referred_by`). They cannot coexist in the same database
under that name. The launch schema is the source of truth as of 2026-06-26.
Kept for reference / in case the old page is ever revived (it would need its own
table name). Nothing in the app references `waitlist-join` anymore.

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,122 @@
-- Vestige LAUNCH waitlist (/dashboard/launch) — table + referral loop.
--
-- The browser inserts directly with the public anon key. The table is
-- INSERT-ONLY to anon via RLS, and every read (own referral code, referral
-- count, total count) goes through a `security definer` RPC that returns a
-- single value, so the anon key never gets SELECT on the table.
--
-- This is the source of truth for the launch page. The older service-role
-- Edge Function schema lives in supabase/legacy/ and is retired.
--
-- Apply with: supabase db push (or paste into the SQL editor — see
-- docs/launch/waitlist-setup.md, which mirrors this file exactly).
create table if not exists public.waitlist (
id bigint generated always as identity primary key,
email text not null,
source text,
referrer text, -- document.referrer (analytics only)
referral_code text not null, -- THIS user's own share code
referred_by text, -- the code that brought them in
created_at timestamptz not null default now()
);
create unique index if not exists waitlist_email_key
on public.waitlist (lower(email));
create unique index if not exists waitlist_referral_code_key
on public.waitlist (referral_code);
create index if not exists waitlist_referred_by_idx
on public.waitlist (referred_by);
alter table public.waitlist enable row level security;
drop policy if exists "anon can insert" on public.waitlist;
create policy "anon can insert"
on public.waitlist
for insert
to anon
with check (true);
create or replace function public.gen_referral_code()
returns text
language plpgsql
as $$
declare
alphabet constant text := 'abcdefghjkmnpqrstuvwxyz23456789';
code text;
i int;
begin
loop
code := '';
for i in 1..7 loop
code := code || substr(alphabet, 1 + floor(random() * length(alphabet))::int, 1);
end loop;
exit when not exists (select 1 from public.waitlist where referral_code = code);
end loop;
return code;
end;
$$;
create or replace function public.join_waitlist(
p_email text,
p_referred_by text default null,
p_referrer text default null
)
returns table (referral_code text, referrals int, duplicate boolean)
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := lower(trim(p_email));
v_code text;
v_ref text := nullif(trim(coalesce(p_referred_by, '')), '');
v_dup boolean := false;
begin
if v_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$' then
raise exception 'invalid email';
end if;
select w.referral_code into v_code
from public.waitlist w
where lower(w.email) = v_email
limit 1;
if v_code is null then
v_code := public.gen_referral_code();
insert into public.waitlist (email, source, referrer, referral_code, referred_by)
values (v_email, 'vestige-launch', p_referrer, v_code,
case when v_ref = v_code then null else v_ref end);
else
v_dup := true;
end if;
return query
select v_code,
(select count(*)::int from public.waitlist where referred_by = v_code),
v_dup;
end;
$$;
create or replace function public.referral_count(p_code text)
returns integer
language sql
security definer
set search_path = public
as $$
select count(*)::int from public.waitlist where referred_by = p_code;
$$;
create or replace function public.waitlist_count()
returns integer
language sql
security definer
set search_path = public
as $$
select count(*)::int from public.waitlist;
$$;
grant execute on function public.join_waitlist(text, text, text) to anon;
grant execute on function public.referral_count(text) to anon;
grant execute on function public.waitlist_count() to anon;

47
vercel.json Normal file
View file

@ -0,0 +1,47 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": null,
"installCommand": "pnpm install --frozen-lockfile",
"buildCommand": "VESTIGE_BASE_PATH= VITE_ROOT_REDIRECT=/launch pnpm --filter @vestige/dashboard build",
"outputDirectory": "apps/dashboard/build",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/_app/immutable/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/((?!_app/immutable/).*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
}
]
}
],
"redirects": [
{
"source": "/dashboard/launch",
"destination": "/launch",
"permanent": false
},
{
"source": "/dashboard/launch/:path*",
"destination": "/launch",
"permanent": false
}
],
"rewrites": [
{
"source": "/((?!_app/|favicon\\.svg|manifest\\.json|robots\\.txt|.*\\..*).*)",
"destination": "/200.html"
}
]
}