From e0bb1ef1c130ffccafd5a75d6a66b97cd485c131 Mon Sep 17 00:00:00 2001 From: Sejal Batra Date: Thu, 17 Sep 2026 16:26:44 +0530 Subject: [PATCH 1/7] feat(launch): add launch-only Chladni field Fixed field behind the launch frame, independent of Home's hero.ts and its shared chunk. Phase follows scroll, drifts upward on scroll and morphs slowly on its own; paints once and stays still under prefers-reduced-motion. 0.3 opacity, 0.13 at 768px and below. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/launch/LaunchField.astro | 49 +++++++ website/src/scripts/launch/field.ts | 127 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 website/src/components/features/launch/LaunchField.astro create mode 100644 website/src/scripts/launch/field.ts diff --git a/website/src/components/features/launch/LaunchField.astro b/website/src/components/features/launch/LaunchField.astro new file mode 100644 index 0000000..fb7d696 --- /dev/null +++ b/website/src/components/features/launch/LaunchField.astro @@ -0,0 +1,49 @@ +--- +/** + * Fixed Chladni field behind the /launch frame. Driven by + * scripts/launch/field.ts, which is independent of Home's hero field. + */ +--- + + + + + + diff --git a/website/src/scripts/launch/field.ts b/website/src/scripts/launch/field.ts new file mode 100644 index 0000000..a56798e --- /dev/null +++ b/website/src/scripts/launch/field.ts @@ -0,0 +1,127 @@ +/** + * Chladni field behind the /launch frame. + * + * Deliberately independent of scripts/home/*: Home owns hero.ts and its + * shared chunk, and this page's field behaves differently — one pattern for + * the whole page whose phase follows scroll position, drifting upward as the + * page scrolls and morphing slowly on its own. + * + * Under prefers-reduced-motion it paints once and stays put: no morph, no + * drift, no repaint on scroll. + */ + +const CHARS = " .:;+xX|"; + +/** How much of the field reads as nodal line versus empty plate. */ +const DENSITY = 11; +/** Ambient morph speed. 0 would leave the field driven by scroll alone. */ +const MORPH = 2; + +/** Glyph cell for Geist Mono at 11px with the field's letter-spacing. */ +const CHAR_WIDTH = 7.4; +const LINE_HEIGHT = 11.6; + +/** Extra rows above and below the viewport so the drift never shows an edge. */ +const HEADROOM_RATIO = 0.5; +/** Minimum ms between repaints while morphing (~22fps is plenty here). */ +const PAINT_INTERVAL = 45; +/** Scroll progress change below which a scroll-only repaint is skipped. */ +const SCROLL_EPSILON = 0.015; + +export const initLaunchField = (pre: HTMLElement) => { + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + const morph = reduceMotion ? 0 : MORPH; + + let cols = 0; + let rows = 0; + let headroom = 0; + let phase = 0; + let lastFrame = 0; + let lastPaint = 0; + let drawnAt = -1; + let scrollQueued = false; + + const progress = () => { + if (reduceMotion) return 0; + const max = document.documentElement.scrollHeight - window.innerHeight; + return max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0; + }; + + const measure = () => { + const width = window.innerWidth; + const height = window.innerHeight; + headroom = reduceMotion ? 0 : height * HEADROOM_RATIO; + cols = Math.ceil(width / CHAR_WIDTH) + 2; + rows = Math.ceil((height + headroom * 2) / LINE_HEIGHT); + drawnAt = -1; + }; + + const paint = (p: number, force: boolean) => { + if (!force && morph === 0 && Math.abs(p - drawnAt) < SCROLL_EPSILON) { + return; + } + drawnAt = p; + + const a = 2.4 + p * 0.55 + Math.sin(phase * 0.16) * 0.85; + const b = 3.3 - p * 0.4 + Math.cos(phase * 0.11) * 0.65; + const last = CHARS.length - 1; + let out = ""; + + for (let y = 0; y < rows; y++) { + const v = y / rows; + for (let x = 0; x < cols; x++) { + const u = x / cols; + const z = + Math.sin(a * Math.PI * u) * Math.sin(b * Math.PI * v) + + Math.sin(b * Math.PI * u) * Math.sin(a * Math.PI * v); + const strength = Math.min(1, Math.abs(z) * (DENSITY / 6)); + const i = Math.floor((1 - strength) * CHARS.length); + out += CHARS[Math.max(0, Math.min(last, i))]; + } + out += "\n"; + } + + pre.textContent = out; + }; + + const applyDrift = () => { + pre.style.transform = `translateY(${-progress() * headroom}px)`; + }; + + const loop = (now: number) => { + if (lastFrame) phase += ((now - lastFrame) / 1000) * (morph * 0.28); + lastFrame = now; + if (now - lastPaint > PAINT_INTERVAL) { + lastPaint = now; + paint(progress(), true); + } + requestAnimationFrame(loop); + }; + + const onScroll = () => { + if (scrollQueued) return; + scrollQueued = true; + requestAnimationFrame(() => { + scrollQueued = false; + applyDrift(); + if (morph === 0) paint(progress(), false); + }); + }; + + measure(); + paint(0, true); + + window.addEventListener("resize", () => { + measure(); + paint(progress(), true); + if (!reduceMotion) applyDrift(); + }); + + if (reduceMotion) return; + + applyDrift(); + window.addEventListener("scroll", onScroll, { passive: true }); + if (morph > 0) requestAnimationFrame(loop); +}; From 927ab0fa8f65b2e3e1d51907cb8cdd726081ca28 Mon Sep 17 00:00:00 2001 From: Sejal Batra Date: Thu, 17 Sep 2026 16:28:02 +0530 Subject: [PATCH 2/7] feat(launch): make signup a real form with a receipt success state -
with a submit handler, so Enter submits; keeps the existing email check, translated error, loading text and toasts. - Scoped per instance instead of by id, so the page can carry more than one form; a successful signup flips every form on the page. - Success restyled as the mockup's receipt: a confirmed row with the submitted address, then the success line. Announced once, from the form that was submitted. - Drops Button.astro here: its mono uppercase treatment and opacity hover don't match the design. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/launch/SignupForm.astro | 450 +++++++++++------- website/src/pages/[lang]/launch.astro | 2 +- website/src/pages/launch.astro | 2 +- 3 files changed, 274 insertions(+), 180 deletions(-) diff --git a/website/src/components/features/launch/SignupForm.astro b/website/src/components/features/launch/SignupForm.astro index 07e1a0e..147e837 100644 --- a/website/src/components/features/launch/SignupForm.astro +++ b/website/src/components/features/launch/SignupForm.astro @@ -1,232 +1,285 @@ --- -import Button from "@/components/ui/Button.astro"; import { createTranslator, getLocaleFromUrl } from "@/utils/i18n"; import { WAITLIST_SOURCE, type WaitlistSource } from "@/constants/waitlist"; interface Props { + /** Unique per instance; prefixes the input and error ids. */ + id: string; /** Which waitlist these signups belong to. */ source?: WaitlistSource; + /** Optional line under the field, hidden once signed up. */ + note?: string; } -const { source = WAITLIST_SOURCE.LAUNCH } = Astro.props; +const { id, source = WAITLIST_SOURCE.LAUNCH, note } = Astro.props; const locale = getLocaleFromUrl(Astro.url.pathname); const t = await createTranslator(locale); --- -