diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 419ec3d..794ceec 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -1,11 +1,24 @@ -# Optional public waitlist capture endpoint used by /dashboard/waitlist. -# The page POSTs JSON with: name, email, plan, priority, notes, source, createdAt. -# Vestige ships a Supabase Edge Function for this — see docs/launch/waitlist-setup.md. -# Format: https://.functions.supabase.co/waitlist-join -# (Formspree / Tally / Buttondown endpoints also work — anything that accepts the POST.) -VITE_WAITLIST_ENDPOINT= +# ── Vestige launch waitlist (Supabase) ─────────────────────────────────────── +# Copy this file to `.env` (NOT committed) and fill in your Supabase values. +# The /dashboard/launch page writes signups straight to a `waitlist` table using +# the PUBLIC anon key + a Row-Level-Security "insert only" policy, so exposing the +# anon key in the browser is safe (anon can INSERT, cannot read the table). +# +# Get these from Supabase → Project Settings → API. +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= -# Optional support bot endpoint used by /dashboard/waitlist. -# The page POSTs JSON with: question, plan, priority, source, and recent history. -# If unset, the page uses the built-in deterministic onboarding FAQ. +# Optional: the public URL of the launch page, used to build shareable referral +# links (?ref=CODE) that work when sent off-device. Falls back to the current +# origin+path if unset (fine for local/preview). Set this for the deployed site. +VITE_PUBLIC_LAUNCH_URL= + +# See docs/launch/waitlist-setup.md for the one SQL block to paste into Supabase +# (creates the table, the insert-only RLS policy, the referral RPCs, and the +# waitlist_count RPC), plus the optional Resend welcome-email function. + +# ── Legacy (the older /dashboard/waitlist page) ────────────────────────────── +# Optional POST endpoint (Supabase Edge Function / Formspree / Tally). Unused by +# the new launch page above; kept for the legacy waitlist route. +VITE_WAITLIST_ENDPOINT= VITE_SUPPORT_BOT_ENDPOINT= diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 8cec6d9..3a09bf5 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest" }, "dependencies": { + "@supabase/supabase-js": "^2.108.2", "three": "^0.172.0" }, "devDependencies": { diff --git a/apps/dashboard/src/lib/landing/NeuralSign.svelte b/apps/dashboard/src/lib/landing/NeuralSign.svelte index b9922e8..78fdb09 100644 --- a/apps/dashboard/src/lib/landing/NeuralSign.svelte +++ b/apps/dashboard/src/lib/landing/NeuralSign.svelte @@ -1,7 +1,7 @@ -
+
{#if sign} - - - - - - - - - {#each streaks as i} - {@const ang = (i / streaks.length) * Math.PI * 2} - - {/each} - - @@ -84,11 +57,6 @@ {/each} - - {#each sign.synapses as s, i (i)} - - {/each} - {/if} @@ -130,45 +98,7 @@ } } - /* synapse nodes fire */ - .syn { - transform-origin: center; - animation: ns-fire 2.6s ease-in-out infinite; - animation-delay: calc(var(--i) * -0.073s); - } - @keyframes ns-fire { - 0%, - 100% { - opacity: 0.5; - transform: scale(0.8); - } - 50% { - opacity: 1; - transform: scale(1.6); - } - } - - /* energy streaks shimmer */ - .streak { - opacity: 0.3; - animation: ns-streak 3.4s ease-in-out infinite; - animation-delay: calc(var(--i) * -0.07s); - } - @keyframes ns-streak { - 0%, - 100% { - opacity: 0.15; - } - 50% { - opacity: 0.6; - } - } - .reduced .dendrites { animation: none; } - .reduced .syn, - .reduced .streak { - animation: none; - } diff --git a/apps/dashboard/src/lib/landing/dendriteGen.ts b/apps/dashboard/src/lib/landing/dendriteGen.ts index bd82cf0..0c11a0c 100644 --- a/apps/dashboard/src/lib/landing/dendriteGen.ts +++ b/apps/dashboard/src/lib/landing/dendriteGen.ts @@ -1,6 +1,6 @@ // Grows real neural dendrites from text glyphs via space colonization, in the -// browser (canvas + fonts available), and returns SVG-ready path/synapse data. -// Runs once on mount (~0.7s for 3 lines), deterministic via a seeded RNG so the +// browser (canvas + fonts available), and returns SVG-ready path data. +// Runs once on mount, deterministic via a seeded RNG so the // same text always grows the same neural sign. export interface DendritePath { @@ -10,18 +10,18 @@ export interface DendritePath { len: number; depth: number; } -export interface Synapse { - x: number; - y: number; - r: number; -} export interface DendriteSign { paths: DendritePath[]; - synapses: Synapse[]; width: number; height: number; } +export interface GrowSignOptions { + fontPx?: number; + targetW?: number; + iters?: number; +} + // deterministic PRNG (mulberry32) so the sign is stable across loads function rng(seed: number) { let a = seed >>> 0; @@ -139,14 +139,17 @@ function growLine(text: string, fontPx: number, iters: number, rand: () => numbe return { nodes, W, H }; } -/** Grow the full 3-line launch sign. Returns SVG-ready data centered to width 1000. */ -export function growSign(): DendriteSign { +/** Grow the launch sign. Returns SVG-ready data centered to width 1000. */ +export function growSign(options: GrowSignOptions = {}): DendriteSign { const rand = rng(0x5e57); // fixed seed -> stable sign const palette = ['#39ff9d', '#22d3ee', '#b388ff']; const specs = [ - { text: 'VESTIGE', px: 150, targetW: 940, iters: 600 }, - { text: 'JULY 14TH', px: 90, targetW: 620, iters: 400 }, - { text: 'SIGN UP NOW', px: 78, targetW: 700, iters: 400 } + { + text: 'VESTIGE', + px: options.fontPx ?? 150, + targetW: options.targetW ?? 940, + iters: options.iters ?? 600 + } ]; const VBW = 1000; const lines = specs.map((s) => { @@ -157,15 +160,12 @@ export function growSign(): DendriteSign { const VBH = lines.reduce((a, l) => a + l.scaledH, 0); const paths: DendritePath[] = []; - const synapses: Synapse[] = []; let yCursor = 0; let colBase = 0; for (const line of lines) { const { res, scale, scaledH } = line; const offX = (VBW - res.W * scale) / 2; const offY = yCursor; - const childCount = new Array(res.nodes.length).fill(0); - for (const n of res.nodes) if (n.parent >= 0) childCount[n.parent]++; for (let i = 0; i < res.nodes.length; i++) { const n = res.nodes[i]; if (n.parent < 0) continue; @@ -183,14 +183,9 @@ export function growSign(): DendriteSign { len: Math.round(len + 1), depth: Math.round((i / res.nodes.length) * 30) }); - // synapse at junctions/tips — sparse, so the pulse animation stays cheap - // (animating thousands of SVG nodes kills FPS; ~40 per line is plenty). - if ((childCount[i] === 0 || childCount[i] >= 2) && rand() < 0.08) { - synapses.push({ x: Math.round(x1), y: Math.round(y1), r: Math.round((w * 0.9 + 1.4) * 10) / 10 }); - } } yCursor += scaledH; colBase += 1; } - return { paths, synapses, width: VBW, height: Math.round(VBH) }; + return { paths, width: VBW, height: Math.round(VBH) }; } diff --git a/apps/dashboard/src/lib/launch/LaunchEngineHost.svelte b/apps/dashboard/src/lib/launch/LaunchEngineHost.svelte new file mode 100644 index 0000000..e8737b0 --- /dev/null +++ b/apps/dashboard/src/lib/launch/LaunchEngineHost.svelte @@ -0,0 +1,214 @@ + + + + +{#if Engine} + +{/if} + + diff --git a/apps/dashboard/src/lib/launch/NeuralWordmark.svelte b/apps/dashboard/src/lib/launch/NeuralWordmark.svelte new file mode 100644 index 0000000..6925b46 --- /dev/null +++ b/apps/dashboard/src/lib/launch/NeuralWordmark.svelte @@ -0,0 +1,274 @@ + + +
+ {#if sign} + + {:else} + + {/if} +
+ + diff --git a/apps/dashboard/src/lib/launch/RawVestigeEngine.svelte b/apps/dashboard/src/lib/launch/RawVestigeEngine.svelte new file mode 100644 index 0000000..b0348b2 --- /dev/null +++ b/apps/dashboard/src/lib/launch/RawVestigeEngine.svelte @@ -0,0 +1,2349 @@ + + +
+ + +
+ + diff --git a/apps/dashboard/src/lib/launch/waitlist.ts b/apps/dashboard/src/lib/launch/waitlist.ts new file mode 100644 index 0000000..256f6d3 --- /dev/null +++ b/apps/dashboard/src/lib/launch/waitlist.ts @@ -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://.supabase.co +// VITE_SUPABASE_ANON_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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 9cc6ce8..a54cfdc 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -1,335 +1,5 @@ -{#if isMarketingRoute} - {@render children()} -{:else} - - - - - - - -
- - - - -
- - -
- {@render children()} -
-
- - - -
- - - -{/if} - - -{#if showCommandPalette && !isMarketingRoute} - -
{ if (e.key === 'Escape') showCommandPalette = false; }} - onclick={(e) => { if (e.target === e.currentTarget) showCommandPalette = false; }} - > -
-
- - { - if (e.key === 'Enter' && filteredNav.length > 0) { - cmdNavigate(filteredNav[0].href); - } - }} - /> - esc -
-
- {#each filteredNav as item} - - {/each} - {#if filteredNav.length === 0} -
No matches
- {/if} -
-
-
-{/if} - - +{@render children()} diff --git a/apps/dashboard/src/routes/+page.svelte b/apps/dashboard/src/routes/+page.svelte index 7bec987..c83657d 100644 --- a/apps/dashboard/src/routes/+page.svelte +++ b/apps/dashboard/src/routes/+page.svelte @@ -1,6 +1,21 @@ + +{#if promoRoot} + +{/if} diff --git a/apps/dashboard/src/routes/+page.ts b/apps/dashboard/src/routes/+page.ts new file mode 100644 index 0000000..ffaf566 --- /dev/null +++ b/apps/dashboard/src/routes/+page.ts @@ -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; diff --git a/apps/dashboard/src/routes/graph-only/+page.svelte b/apps/dashboard/src/routes/graph-only/+page.svelte new file mode 100644 index 0000000..448bf5b --- /dev/null +++ b/apps/dashboard/src/routes/graph-only/+page.svelte @@ -0,0 +1,67 @@ + + + + Vestige · memory that watches itself think + + + +
+ +
+ + diff --git a/apps/dashboard/src/routes/graph-only/+page.ts b/apps/dashboard/src/routes/graph-only/+page.ts new file mode 100644 index 0000000..d68ad65 --- /dev/null +++ b/apps/dashboard/src/routes/graph-only/+page.ts @@ -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; diff --git a/apps/dashboard/src/routes/launch/+page.svelte b/apps/dashboard/src/routes/launch/+page.svelte index 2de2e8a..b1a4d7b 100644 --- a/apps/dashboard/src/routes/launch/+page.svelte +++ b/apps/dashboard/src/routes/launch/+page.svelte @@ -1,388 +1,1174 @@ - Vestige · give your agent a brain you can watch think + Vestige · memory that watches itself think · waitlist + {@html preHydrationWaitlistScript ? `` : ''} + + -
- -
- - {#if mounted} - - {/if} +
+ - - {#if mounted} - {#key heroSeed} - - {/key} - {/if} + + - - + +
+ + + + Star on GitHub + +
- - {#if mounted} - - {/if} - - - {#if mounted} - - {/if} - -
-
- - {brain.stats.memories} memories - · - {brain.stats.connections} connections - · - 150,000 GPU particles -
- -

- Your agent forgets everything.
- Your memory should be yours. Local, and beautiful. + +
+ +
+

+

+
-

- Vestige is a local-first memory for AI coding agents. Watch it remember, - forget, and leave a receipt, rendered as a brain you can fly through. + + + + +

+

memory that watches itself think

+

+ Local-first memory for AI coding agents. A living particle brain you can + watch remember, forget, and reform.

-
- reseed((e.target as HTMLInputElement).value)} - autocomplete="off" - spellcheck="false" - /> - - {hasSeeded ? `this brain is seeded from "${brain.seed}". one of one.` : 'type anything to seed your own brain'} - -
- - {#if submitState === 'success'} -
- {submitMessage} -

We'll email you before launch. Top concept in your brain: {brain.stats.topConcept}.

-
- {:else} -
+ {#if submitState !== 'success'} + +
+ + +
+ - + {#if submitMessage} +

{submitMessage}

+ {/if}
- {#if submitState === 'error'} -

{submitMessage}

+ {:else} +
+

{submitMessage}

+ + {#if myReferralCode} + + + + + + + + {#if myReferrals > 0} +

+ 🔥 {myReferrals.toLocaleString()} + {myReferrals === 1 ? 'friend has' : 'friends have'} joined from your link +

+ {:else} +

No referrals yet — share your link to climb.

+ {/if} + {:else} + + + {/if} +
{/if} - {/if} + +
+ ▲ Launching {LAUNCH_DATE} + {#if waitlistCount !== null && waitlistCount >= COUNT_REVEAL_THRESHOLD} + + {waitlistCount.toLocaleString()} on the waitlist + {/if} + + open source +
-
-

+ + +
+
+ .ref-count.muted { + color: rgba(190, 212, 245, 0.62); + font-weight: 500; + } + .share-btn { + border: 1px solid rgba(140, 190, 255, 0.3); + border-radius: 12px; + padding: 0.6rem 1.1rem; + background: rgba(20, 30, 60, 0.5); + color: #dceaff; + font: inherit; + font-weight: 650; + cursor: pointer; + transition: + transform 0.2s ease, + border-color 0.2s ease; + } + .share-btn:hover { + transform: translateY(-1px); + border-color: rgba(120, 200, 255, 0.65); + } + + .meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; + font-size: 0.84rem; + color: rgba(190, 212, 245, 0.72); + } + .meta strong { + color: #eaf3ff; + } + .date { + color: #8fe9ff; + font-weight: 650; + } + .dot { + opacity: 0.4; + } + .src { + color: #bcd4f5; + text-decoration: underline; + text-underline-offset: 2px; + } + + .scroll-hint { + margin: 0; + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(150, 180, 220, 0.4); + } + + @media (max-width: 760px) { + /* stronger, taller scrim — particles fill the whole portrait screen */ + .scrim { + background: + radial-gradient(140% 60% at 50% 38%, transparent 22%, rgba(2, 3, 10, 0.6) 100%), + linear-gradient(180deg, rgba(2, 3, 10, 0.5) 0%, rgba(2, 3, 10, 0.12) 30%, rgba(2, 3, 10, 0.55) 62%, rgba(2, 3, 10, 0.92) 100%); + } + /* dim only the ACTIVE engine canvas. The fallback canvas is rendered after + the GPU canvas, so forcing both visible can cover the real WebGPU show. */ + :global(.raw-vestige-engine[data-mode='webgpu'] .gpu-canvas), + :global(.raw-vestige-engine[data-mode='fallback'] .fallback-canvas) { + opacity: 0.82; + } + :global(.raw-vestige-engine[data-mode='webgpu'] .fallback-canvas), + :global(.raw-vestige-engine[data-mode='fallback'] .gpu-canvas) { + opacity: 0; + } + .overlay { + justify-content: flex-end; + gap: 1.4rem; + padding-bottom: clamp(2rem, 7vh, 3.4rem); + } + .wordmark-wrap { + width: 100%; + } + .tagline, + .subline { + text-shadow: 0 2px 14px rgba(2, 3, 10, 0.9); + } + .subline { + max-width: 34ch; + } + .field { + flex-direction: column; + } + .join-btn { + width: 100%; + padding: 0.95rem 1.25rem; + } + .email { + padding: 0.95rem 0.9rem; + } + .meta { + text-shadow: 0 1px 10px rgba(2, 3, 10, 0.9); + } + /* gentler implode on phones (smaller deltas, no per-frame DOM blur which is + expensive on mobile); the WebGPU background carries the spectacle. */ + .hero-block { + transform: scale(calc(1 - var(--burst) * 0.08)) translateZ(calc(var(--burst) * -160px)); + } + .cta-block { + transform: scale(calc(1 - var(--burst) * 0.05)); + translate: 0 calc(var(--burst) * 8px); + } + .hero-block .wordmark-wrap, + .cta-block .tagline { + filter: none; + } + } + + @media (prefers-reduced-motion: reduce) { + /* hard OFF — a periodic full-overlay implode is a WCAG 2.3.3 vestibular + trigger; never distort for users who opt out. */ + .topbar, + .hero-block, + .cta-block { + transition: none; + transform: none !important; + translate: none !important; + filter: none !important; + opacity: 1 !important; + } + .hero-block .wordmark-wrap, + .cta-block .tagline { + filter: none !important; + } + .wild { + animation: none; + } + } + diff --git a/apps/dashboard/src/routes/launch/+page.ts b/apps/dashboard/src/routes/launch/+page.ts index 86a09ca..b00b15b 100644 --- a/apps/dashboard/src/routes/launch/+page.ts +++ b/apps/dashboard/src/routes/launch/+page.ts @@ -1,5 +1,5 @@ -// The launch landing page is a client-only WebGL experience (live 3D memory -// graph + Memory Cinema). Disable SSR so Three.js never runs during prerender; -// the page is still prerendered as a static shell that hydrates on the client. -export const ssr = false; +// The launch page prerenders the visible overlay, wordmark bridge, and inert +// canvas elements as HTML. The raw WebGPU/canvas engine still boots only in +// onMount, so browser GPU APIs never execute during prerender. +export const ssr = true; export const prerender = true; diff --git a/apps/dashboard/svelte.config.js b/apps/dashboard/svelte.config.js index 7b4101d..0fe81d6 100644 --- a/apps/dashboard/svelte.config.js +++ b/apps/dashboard/svelte.config.js @@ -15,7 +15,11 @@ const config = { adapter: adapter({ pages: 'build', assets: 'build', - fallback: 'index.html', + // 200.html (not index.html) is the SPA fallback so it never clobbers a + // PRERENDERED root index.html. The standalone promo build prerenders the + // waitlist page at "/", and that index.html must survive as real HTML so + // visitors see the signup instantly with no empty-shell router boot. + fallback: '200.html', precompress: true, strict: false }), diff --git a/apps/dashboard/verify-launch-mobile.mjs b/apps/dashboard/verify-launch-mobile.mjs new file mode 100644 index 0000000..2286db5 --- /dev/null +++ b/apps/dashboard/verify-launch-mobile.mjs @@ -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); +}); diff --git a/apps/dashboard/verify-launch.mjs b/apps/dashboard/verify-launch.mjs new file mode 100644 index 0000000..422dd30 --- /dev/null +++ b/apps/dashboard/verify-launch.mjs @@ -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); +}); diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 363f4be..032c858 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -6,7 +6,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { - port: 5173, + port: process.env.PORT ? Number(process.env.PORT) : 5173, proxy: { '/api': { target: 'http://127.0.0.1:3927', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92a82be..e0d7559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/dashboard: dependencies: + '@supabase/supabase-js': + specifier: ^2.108.2 + version: 2.108.2 three: specifier: ^0.172.0 version: 0.172.0 @@ -299,66 +302,79 @@ packages: resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.56.0': resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.56.0': resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.56.0': resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.56.0': resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.56.0': resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.56.0': resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.56.0': resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.56.0': resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.56.0': resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.56.0': resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.56.0': resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.56.0': resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.56.0': resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} @@ -393,6 +409,33 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@supabase/auth-js@2.108.2': + resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.108.2': + resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.4': + resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + + '@supabase/postgrest-js@2.108.2': + resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.108.2': + resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.108.2': + resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.108.2': + resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} + engines: {node: '>=20.0.0'} + '@sveltejs/acorn-typescript@1.0.9': resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} peerDependencies: @@ -472,24 +515,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.0': resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.0': resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.0': resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.0': resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} @@ -708,6 +755,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -769,24 +820,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -946,6 +1001,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1241,6 +1299,38 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@supabase/auth-js@2.108.2': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.108.2': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.4': {} + + '@supabase/postgrest-js@2.108.2': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.108.2': + dependencies: + '@supabase/phoenix': 0.4.4 + tslib: 2.8.1 + + '@supabase/storage-js@2.108.2': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.108.2': + dependencies: + '@supabase/auth-js': 2.108.2 + '@supabase/functions-js': 2.108.2 + '@supabase/postgrest-js': 2.108.2 + '@supabase/realtime-js': 2.108.2 + '@supabase/storage-js': 2.108.2 + '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': dependencies: acorn: 8.15.0 @@ -1547,6 +1637,8 @@ snapshots: html-escaper@2.0.2: {} + iceberg-js@0.8.1: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1776,6 +1868,8 @@ snapshots: totalist@3.0.1: {} + tslib@2.8.1: {} + typescript@5.9.3: {} undici-types@6.21.0: diff --git a/supabase/config.toml b/supabase/config.toml index 0a10a41..832b23f 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -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 -# Then deploy: supabase db push && supabase functions deploy waitlist-join +# Apply the schema: supabase db push +# Deploy the welcome email fn: supabase functions deploy waitlist-welcome --no-verify-jwt +# +# The launch page inserts directly with the anon key (insert-only RLS + RPCs); +# see supabase/migrations/0002_launch_waitlist.sql and docs/launch/waitlist-setup.md. +# The legacy service-role waitlist-join function is archived in supabase/legacy/. project_id = "vestige-waitlist" -[functions.waitlist-join] -# Public form posts here; no Supabase JWT required (we gate with the honeypot, -# email validation, and the service-role insert inside the function). +[functions.waitlist-welcome] +# Invoked by a Database Webhook on INSERT into public.waitlist (no end-user JWT); +# guard with the optional WAITLIST_WEBHOOK_SECRET header instead. verify_jwt = false diff --git a/supabase/functions/waitlist-welcome/index.ts b/supabase/functions/waitlist-welcome/index.ts new file mode 100644 index 0000000..4cdc6a5 --- /dev/null +++ b/supabase/functions/waitlist-welcome/index.ts @@ -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 " +// (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 "; +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 = ` + + +
+
VESTIGE
+

You're on the waitlist. 🧠

+

+ 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. +

+

+ Want in earlier? Every friend who joins from your personal link moves you up the list: +

+
+ ${link} +
+ + Share on X → + +

+ — Sam, building Vestige solo. Reply any time. +

+
+ +`; + + 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" }, + }); +}); diff --git a/supabase/legacy/0001_waitlist.sql b/supabase/legacy/0001_waitlist.sql new file mode 100644 index 0000000..3dc5192 --- /dev/null +++ b/supabase/legacy/0001_waitlist.sql @@ -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.'; diff --git a/supabase/legacy/README.md b/supabase/legacy/README.md new file mode 100644 index 0000000..5bcdd56 --- /dev/null +++ b/supabase/legacy/README.md @@ -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. diff --git a/supabase/legacy/waitlist-join/index.ts b/supabase/legacy/waitlist-join/index.ts new file mode 100644 index 0000000..817fc90 --- /dev/null +++ b/supabase/legacy/waitlist-join/index.ts @@ -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 "; + +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 }); +}); diff --git a/supabase/migrations/0002_launch_waitlist.sql b/supabase/migrations/0002_launch_waitlist.sql new file mode 100644 index 0000000..6534727 --- /dev/null +++ b/supabase/migrations/0002_launch_waitlist.sql @@ -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; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..d99d935 --- /dev/null +++ b/vercel.json @@ -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" + } + ] +}