mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
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:
parent
24ef2d504f
commit
6837198328
27 changed files with 5249 additions and 747 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.108.2",
|
||||
"three": "^0.172.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
214
apps/dashboard/src/lib/launch/LaunchEngineHost.svelte
Normal file
214
apps/dashboard/src/lib/launch/LaunchEngineHost.svelte
Normal 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>
|
||||
274
apps/dashboard/src/lib/launch/NeuralWordmark.svelte
Normal file
274
apps/dashboard/src/lib/launch/NeuralWordmark.svelte
Normal 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>
|
||||
2349
apps/dashboard/src/lib/launch/RawVestigeEngine.svelte
Normal file
2349
apps/dashboard/src/lib/launch/RawVestigeEngine.svelte
Normal file
File diff suppressed because it is too large
Load diff
137
apps/dashboard/src/lib/launch/waitlist.ts
Normal file
137
apps/dashboard/src/lib/launch/waitlist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
5
apps/dashboard/src/routes/+page.ts
Normal file
5
apps/dashboard/src/routes/+page.ts
Normal 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;
|
||||
67
apps/dashboard/src/routes/graph-only/+page.svelte
Normal file
67
apps/dashboard/src/routes/graph-only/+page.svelte
Normal 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>
|
||||
5
apps/dashboard/src/routes/graph-only/+page.ts
Normal file
5
apps/dashboard/src/routes/graph-only/+page.ts
Normal 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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
|
|
|
|||
182
apps/dashboard/verify-launch-mobile.mjs
Normal file
182
apps/dashboard/verify-launch-mobile.mjs
Normal 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);
|
||||
});
|
||||
217
apps/dashboard/verify-launch.mjs
Normal file
217
apps/dashboard/verify-launch.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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
94
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
165
supabase/functions/waitlist-welcome/index.ts
Normal file
165
supabase/functions/waitlist-welcome/index.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
37
supabase/legacy/0001_waitlist.sql
Normal file
37
supabase/legacy/0001_waitlist.sql
Normal 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
21
supabase/legacy/README.md
Normal 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.
|
||||
146
supabase/legacy/waitlist-join/index.ts
Normal file
146
supabase/legacy/waitlist-join/index.ts
Normal 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 });
|
||||
});
|
||||
122
supabase/migrations/0002_launch_waitlist.sql
Normal file
122
supabase/migrations/0002_launch_waitlist.sql
Normal 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
47
vercel.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue