mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-26 23:51:02 +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.
|
# ── Vestige launch waitlist (Supabase) ───────────────────────────────────────
|
||||||
# The page POSTs JSON with: name, email, plan, priority, notes, source, createdAt.
|
# Copy this file to `.env` (NOT committed) and fill in your Supabase values.
|
||||||
# Vestige ships a Supabase Edge Function for this — see docs/launch/waitlist-setup.md.
|
# The /dashboard/launch page writes signups straight to a `waitlist` table using
|
||||||
# Format: https://<project-ref>.functions.supabase.co/waitlist-join
|
# the PUBLIC anon key + a Row-Level-Security "insert only" policy, so exposing the
|
||||||
# (Formspree / Tally / Buttondown endpoints also work — anything that accepts the POST.)
|
# anon key in the browser is safe (anon can INSERT, cannot read the table).
|
||||||
VITE_WAITLIST_ENDPOINT=
|
#
|
||||||
|
# Get these from Supabase → Project Settings → API.
|
||||||
|
VITE_SUPABASE_URL=
|
||||||
|
VITE_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
# Optional support bot endpoint used by /dashboard/waitlist.
|
# Optional: the public URL of the launch page, used to build shareable referral
|
||||||
# The page POSTs JSON with: question, plan, priority, source, and recent history.
|
# links (?ref=CODE) that work when sent off-device. Falls back to the current
|
||||||
# If unset, the page uses the built-in deterministic onboarding FAQ.
|
# 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=
|
VITE_SUPPORT_BOT_ENDPOINT=
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.108.2",
|
||||||
"three": "^0.172.0"
|
"three": "^0.172.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Living neural launch sign: VESTIGE / JULY 14TH / SIGN UP NOW built from REAL
|
// Living neural sign: VESTIGE built from REAL
|
||||||
// grown dendrites (space colonization on the letterforms) with synapse bulbs,
|
// grown dendrites (space colonization on the letterforms) with a quiet bloom.
|
||||||
// multi-tier bloom, energy burst, and draw-on growth. Not glow-on-text.
|
// Not glow-on-text.
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { growSign, type DendriteSign } from '$lib/landing/dendriteGen';
|
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>
|
</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}
|
{#if sign}
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${sign.width} ${sign.height}`}
|
viewBox={`0 0 ${sign.width} ${sign.height}`}
|
||||||
|
|
@ -52,31 +48,8 @@
|
||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</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>
|
</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 -->
|
<!-- the grown dendrites -->
|
||||||
<g filter="url(#ns-bloom)">
|
<g filter="url(#ns-bloom)">
|
||||||
<g class="dendrites">
|
<g class="dendrites">
|
||||||
|
|
@ -84,11 +57,6 @@
|
||||||
<path d={p.d} stroke={p.col} stroke-width={p.w} stroke-linecap="round" />
|
<path d={p.d} stroke={p.col} stroke-width={p.w} stroke-linecap="round" />
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/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 {
|
.reduced .dendrites {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.reduced .syn,
|
|
||||||
.reduced .streak {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Grows real neural dendrites from text glyphs via space colonization, in the
|
// Grows real neural dendrites from text glyphs via space colonization, in the
|
||||||
// browser (canvas + fonts available), and returns SVG-ready path/synapse data.
|
// browser (canvas + fonts available), and returns SVG-ready path data.
|
||||||
// Runs once on mount (~0.7s for 3 lines), deterministic via a seeded RNG so the
|
// Runs once on mount, deterministic via a seeded RNG so the
|
||||||
// same text always grows the same neural sign.
|
// same text always grows the same neural sign.
|
||||||
|
|
||||||
export interface DendritePath {
|
export interface DendritePath {
|
||||||
|
|
@ -10,18 +10,18 @@ export interface DendritePath {
|
||||||
len: number;
|
len: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
export interface Synapse {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
r: number;
|
|
||||||
}
|
|
||||||
export interface DendriteSign {
|
export interface DendriteSign {
|
||||||
paths: DendritePath[];
|
paths: DendritePath[];
|
||||||
synapses: Synapse[];
|
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GrowSignOptions {
|
||||||
|
fontPx?: number;
|
||||||
|
targetW?: number;
|
||||||
|
iters?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// deterministic PRNG (mulberry32) so the sign is stable across loads
|
// deterministic PRNG (mulberry32) so the sign is stable across loads
|
||||||
function rng(seed: number) {
|
function rng(seed: number) {
|
||||||
let a = seed >>> 0;
|
let a = seed >>> 0;
|
||||||
|
|
@ -139,14 +139,17 @@ function growLine(text: string, fontPx: number, iters: number, rand: () => numbe
|
||||||
return { nodes, W, H };
|
return { nodes, W, H };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grow the full 3-line launch sign. Returns SVG-ready data centered to width 1000. */
|
/** Grow the launch sign. Returns SVG-ready data centered to width 1000. */
|
||||||
export function growSign(): DendriteSign {
|
export function growSign(options: GrowSignOptions = {}): DendriteSign {
|
||||||
const rand = rng(0x5e57); // fixed seed -> stable sign
|
const rand = rng(0x5e57); // fixed seed -> stable sign
|
||||||
const palette = ['#39ff9d', '#22d3ee', '#b388ff'];
|
const palette = ['#39ff9d', '#22d3ee', '#b388ff'];
|
||||||
const specs = [
|
const specs = [
|
||||||
{ text: 'VESTIGE', px: 150, targetW: 940, iters: 600 },
|
{
|
||||||
{ text: 'JULY 14TH', px: 90, targetW: 620, iters: 400 },
|
text: 'VESTIGE',
|
||||||
{ text: 'SIGN UP NOW', px: 78, targetW: 700, iters: 400 }
|
px: options.fontPx ?? 150,
|
||||||
|
targetW: options.targetW ?? 940,
|
||||||
|
iters: options.iters ?? 600
|
||||||
|
}
|
||||||
];
|
];
|
||||||
const VBW = 1000;
|
const VBW = 1000;
|
||||||
const lines = specs.map((s) => {
|
const lines = specs.map((s) => {
|
||||||
|
|
@ -157,15 +160,12 @@ export function growSign(): DendriteSign {
|
||||||
const VBH = lines.reduce((a, l) => a + l.scaledH, 0);
|
const VBH = lines.reduce((a, l) => a + l.scaledH, 0);
|
||||||
|
|
||||||
const paths: DendritePath[] = [];
|
const paths: DendritePath[] = [];
|
||||||
const synapses: Synapse[] = [];
|
|
||||||
let yCursor = 0;
|
let yCursor = 0;
|
||||||
let colBase = 0;
|
let colBase = 0;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const { res, scale, scaledH } = line;
|
const { res, scale, scaledH } = line;
|
||||||
const offX = (VBW - res.W * scale) / 2;
|
const offX = (VBW - res.W * scale) / 2;
|
||||||
const offY = yCursor;
|
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++) {
|
for (let i = 0; i < res.nodes.length; i++) {
|
||||||
const n = res.nodes[i];
|
const n = res.nodes[i];
|
||||||
if (n.parent < 0) continue;
|
if (n.parent < 0) continue;
|
||||||
|
|
@ -183,14 +183,9 @@ export function growSign(): DendriteSign {
|
||||||
len: Math.round(len + 1),
|
len: Math.round(len + 1),
|
||||||
depth: Math.round((i / res.nodes.length) * 30)
|
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;
|
yCursor += scaledH;
|
||||||
colBase += 1;
|
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">
|
<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 { 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>
|
</script>
|
||||||
|
|
||||||
{#if isMarketingRoute}
|
{@render children()}
|
||||||
{@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>
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { onMount } from 'svelte';
|
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>
|
</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
|
// The launch page prerenders the visible overlay, wordmark bridge, and inert
|
||||||
// graph + Memory Cinema). Disable SSR so Three.js never runs during prerender;
|
// canvas elements as HTML. The raw WebGPU/canvas engine still boots only in
|
||||||
// the page is still prerendered as a static shell that hydrates on the client.
|
// onMount, so browser GPU APIs never execute during prerender.
|
||||||
export const ssr = false;
|
export const ssr = true;
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ const config = {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
pages: 'build',
|
pages: 'build',
|
||||||
assets: '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,
|
precompress: true,
|
||||||
strict: false
|
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({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:3927',
|
target: 'http://127.0.0.1:3927',
|
||||||
|
|
|
||||||
94
pnpm-lock.yaml
generated
94
pnpm-lock.yaml
generated
|
|
@ -14,6 +14,9 @@ importers:
|
||||||
|
|
||||||
apps/dashboard:
|
apps/dashboard:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@supabase/supabase-js':
|
||||||
|
specifier: ^2.108.2
|
||||||
|
version: 2.108.2
|
||||||
three:
|
three:
|
||||||
specifier: ^0.172.0
|
specifier: ^0.172.0
|
||||||
version: 0.172.0
|
version: 0.172.0
|
||||||
|
|
@ -299,66 +302,79 @@ packages:
|
||||||
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
|
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
|
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
|
||||||
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
|
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.56.0':
|
'@rollup/rollup-linux-arm64-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
|
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.56.0':
|
'@rollup/rollup-linux-arm64-musl@4.56.0':
|
||||||
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
|
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.56.0':
|
'@rollup/rollup-linux-loong64-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
|
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.56.0':
|
'@rollup/rollup-linux-loong64-musl@4.56.0':
|
||||||
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
|
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
|
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
|
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.56.0':
|
'@rollup/rollup-linux-ppc64-musl@4.56.0':
|
||||||
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
|
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
|
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
|
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.56.0':
|
'@rollup/rollup-linux-riscv64-musl@4.56.0':
|
||||||
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
|
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.56.0':
|
'@rollup/rollup-linux-s390x-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
|
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.56.0':
|
'@rollup/rollup-linux-x64-gnu@4.56.0':
|
||||||
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
|
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.56.0':
|
'@rollup/rollup-linux-x64-musl@4.56.0':
|
||||||
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
|
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.56.0':
|
'@rollup/rollup-openbsd-x64@4.56.0':
|
||||||
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
|
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
|
||||||
|
|
@ -393,6 +409,33 @@ packages:
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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':
|
'@sveltejs/acorn-typescript@1.0.9':
|
||||||
resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==}
|
resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -472,24 +515,28 @@ packages:
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
|
||||||
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
|
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
|
||||||
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
|
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
|
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
|
||||||
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
|
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
|
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
|
||||||
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
|
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
|
||||||
|
|
@ -708,6 +755,10 @@ packages:
|
||||||
html-escaper@2.0.2:
|
html-escaper@2.0.2:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
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:
|
is-reference@3.0.3:
|
||||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||||
|
|
||||||
|
|
@ -769,24 +820,28 @@ packages:
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.31.1:
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.31.1:
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.31.1:
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.31.1:
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
|
|
@ -946,6 +1001,9 @@ packages:
|
||||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
|
|
@ -1241,6 +1299,38 @@ snapshots:
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@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)':
|
'@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|
@ -1547,6 +1637,8 @@ snapshots:
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
|
iceberg-js@0.8.1: {}
|
||||||
|
|
||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
@ -1776,6 +1868,8 @@ snapshots:
|
||||||
|
|
||||||
totalist@3.0.1: {}
|
totalist@3.0.1: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
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>
|
# 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"
|
project_id = "vestige-waitlist"
|
||||||
|
|
||||||
[functions.waitlist-join]
|
[functions.waitlist-welcome]
|
||||||
# Public form posts here; no Supabase JWT required (we gate with the honeypot,
|
# Invoked by a Database Webhook on INSERT into public.waitlist (no end-user JWT);
|
||||||
# email validation, and the service-role insert inside the function).
|
# guard with the optional WAITLIST_WEBHOOK_SECRET header instead.
|
||||||
verify_jwt = false
|
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