diff --git a/.gitignore b/.gitignore
index 54da33c20..d3f90c6fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,6 @@ next-env.d.ts
# Sentry Config File
.env.sentry-build-plugin
+
+# built from design/landing.html by scripts/build-landing.mjs
+/public/landing.html
diff --git a/design/landing.html b/design/landing.html
new file mode 100644
index 000000000..df190e01d
--- /dev/null
+++ b/design/landing.html
@@ -0,0 +1,3362 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Join the waitlist
+
Agents on your own hardware. Leave your email and we’ll tell you the moment units ship.
+
+
+
Email address
+
+
Enter a valid email address.
+
+
Join the list
+
You’re on the list. Check your inbox shortly.
+
+
+
+
+
+
+
+
diff --git a/next.config.mjs b/next.config.mjs
index 64a5c2d48..8f3ae2448 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -44,7 +44,9 @@ const nextConfig = {
async headers() {
return [
{
- source: "/(.*)",
+ // everything but /graphics: the negative lookahead keeps exactly one rule
+ // matching, so the two X-Frame-Options values can never both be sent
+ source: "/((?!graphics/).*)",
headers: [
{
key: "X-Frame-Options",
@@ -56,6 +58,22 @@ const nextConfig = {
},
],
},
+ {
+ // Glen ships the landing animations as standalone HTML that drives itself
+ // with rAF, so they run in a same-origin iframe rather than being ported.
+ // Framing them needs SAMEORIGIN; they are decorative and read no data.
+ source: "/graphics/:path*",
+ headers: [
+ {
+ key: "X-Frame-Options",
+ value: "SAMEORIGIN",
+ },
+ {
+ key: "Content-Security-Policy",
+ value: "frame-ancestors 'self'",
+ },
+ ],
+ },
]
},
@@ -113,20 +131,28 @@ const nextConfig = {
{ source: "/research/:path*", destination: "/technology/:path*", permanent: true } ]
},
async rewrites() {
- return [
- {
- source: '/gov-docs/:path*',
- destination: 'https://scroll-governance-documentation.vercel.app/:path*'
- },
- {
- source: '/technology',
- destination: 'https://scroll-research.vercel.app/technology'
- },
- {
- source: '/technology/:path*',
- destination: 'https://scroll-research.vercel.app/technology/:path*'
- }
- ]
+ return {
+ beforeFiles: [
+ // The front page is Glen's design file, served as it is: scripts/build-landing.mjs
+ // turns design/landing.html into public/landing.html (links, waitlist endpoint,
+ // metadata) and this rewrite hands "/" to it before any app route.
+ { source: '/', destination: '/landing.html' },
+ ],
+ afterFiles: [
+ {
+ source: '/gov-docs/:path*',
+ destination: 'https://scroll-governance-documentation.vercel.app/:path*'
+ },
+ {
+ source: '/technology',
+ destination: 'https://scroll-research.vercel.app/technology'
+ },
+ {
+ source: '/technology/:path*',
+ destination: 'https://scroll-research.vercel.app/technology/:path*'
+ }
+ ],
+ }
},
// eslint-disable-next-line
webpack: (config, { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }) => {
diff --git a/package.json b/package.json
index e3902eba0..6a1e98d01 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,16 @@
"private": false,
"license": "MIT",
"scripts": {
+ "predev": "node scripts/build-landing.mjs",
"dev": "next dev",
+ "prebuild": "node scripts/build-landing.mjs",
"build": "next build",
"start": "next start",
"lint": "next lint --fix",
"test": "next lint",
"prepare": "husky install",
- "fetch:bloglist": "node scripts/download-blog-posts.data.json.mjs"
+ "fetch:bloglist": "node scripts/download-blog-posts.data.json.mjs",
+ "build:landing": "node scripts/build-landing.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
diff --git a/scripts/build-landing.mjs b/scripts/build-landing.mjs
new file mode 100644
index 000000000..95dd31877
--- /dev/null
+++ b/scripts/build-landing.mjs
@@ -0,0 +1,139 @@
+// Builds public/landing.html — the site's front page — from Glen's design file.
+//
+// design/landing.html is his file, byte for byte; nothing in it is edited by hand. This
+// script fills in what his prototype leaves as placeholders (the legal links, the white
+// paper, the waitlist endpoint) and adds what a real page needs in (description,
+// Open Graph, icons, analytics). next.config.mjs rewrites "/" to the output before any
+// app route is considered. Runs from `predev` and `prebuild`; the output is not committed.
+//
+// When Glen sends a new version: replace design/landing.html, run `yarn build:landing`,
+// and this script will fail loudly if one of its anchors no longer matches his markup.
+import fs from "node:fs"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
+const source = path.join(root, "design", "landing.html")
+const output = path.join(root, "public", "landing.html")
+
+let html = fs.readFileSync(source, "utf8")
+
+const once = (label, from, to) => {
+ const n = html.split(from).length - 1
+ if (n !== 1) throw new Error(`[landing] ${label}: expected exactly one match, found ${n}:\n ${from}`)
+ html = html.replace(from, to)
+}
+
+const link = fs.readFileSync(path.join(root, "src", "constants", "link.ts"), "utf8")
+const constant = name => {
+ const m = link.match(new RegExp(`export const ${name} = "([^"]+)"`))
+ if (!m) throw new Error(`[landing] ${name} not found in src/constants/link.ts`)
+ return m[1]
+}
+
+// ---- links his prototype leaves as "#" or guesses -------------------------------------
+once("privacy policy link", '
Privacy policy ', '
Privacy policy ')
+once("app privacy policy link", '
App privacy policy ', '
App privacy policy ')
+once("terms link", '
Terms of service ', '
Terms of service ')
+once("white paper link", 'href="/scroll-whitepaper.pdf"', 'href="/files/whitepaper.pdf"')
+
+// ---- the Compass button on the Compass API panel: his file points it at his own Compass
+// prototype (index.html, 2026-09-11); here it opens the Compass site ----
+const compassUrl = constant("COMPASS_API_URL")
+once(
+ "compass api → compass site",
+ '
Compass ',
+ `
Compass `,
+)
+
+// ---- the connect chain on the Compass API panel: the shadow under its cards was clipped
+// (Glen's screenshot, 2026-09-11 21:25). Cause: the iframe's demo has min-height:100vh
+// plus 24px padding with the default content-box sizing, so it is 48px taller and wider than
+// the iframe and the centred SVG sits 24px too far down and right. border-box keeps the body
+// the size of the iframe; the 24px padding then gives the drop-shadow its room. His viewBox
+// and the graphic's scale are untouched. The strings are HTML-escaped (srcdoc attribute). ----
+once(
+ "connect chain body sizing",
+ "body {\n margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;\n background: transparent; /* the component paints no background of its own */\n font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;\n }",
+ "body {\n margin: 0; min-height: 100vh; box-sizing: border-box; display: grid; place-items: center; padding: 24px;\n background: transparent; /* the component paints no background of its own */\n font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;\n }",
+)
+
+// ---- the waitlist form: his submitEmail only pretends; this posts to Loops ------------
+const formId = constant("LOOPS_FORM_ID")
+const listId = constant("LOOPS_MAILING_LIST_ID")
+
+const fnStart = html.indexOf("function submitEmail(){")
+const fnEnd = html.indexOf("\n}\n", fnStart)
+if (fnStart < 0 || fnEnd < 0) throw new Error("[landing] submitEmail() not found")
+const submit = `async function submitEmail(){
+ // filled in by scripts/build-landing.mjs: the same Loops form the /sign-up card posts to
+ const email = emailIn.value.trim();
+ const ok = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/.test(email);
+ if (!ok){ emailErr.textContent = 'Enter a valid email address.'; emailErr.hidden = false; emailIn.focus(); return; }
+ emailErr.hidden = true;
+ if (joinBtn.dataset.busy) return;
+ joinBtn.dataset.busy = '1';
+ try {
+ const res = await fetch('https://app.loops.so/api/newsletter-form/${formId}', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: ['email=' + encodeURIComponent(email), 'userGroup=' + encodeURIComponent('Compass Waitlist'), 'mailingLists=' + encodeURIComponent('${listId}')].join('&'),
+ });
+ const data = res.status === 429 ? { success: false, message: 'Too many attempts \\u2014 please try again in a minute.' } : await res.json();
+ if (!data.success){ emailErr.textContent = data.message || 'Something went wrong \\u2014 please try again.'; emailErr.hidden = false; return; }
+ joinBtn.hidden = true;
+ emailIn.closest('.field').hidden = true;
+ joinDone.hidden = false;
+ } catch (e) {
+ emailErr.textContent = 'Network error \\u2014 please try again.'; emailErr.hidden = false;
+ } finally {
+ delete joinBtn.dataset.busy;
+ }
+}`
+html = html.slice(0, fnStart) + submit + html.slice(fnEnd + 2)
+
+// ---- : what the Next metadata used to add for "/" ------------------------------
+const site = (process.env.NEXT_PUBLIC_FRONTENDS_URL || "https://scroll.io").replace(/\/$/, "")
+// his sub-head, read from the file so it follows his copy (Zhengqi 2026-09-11: the old
+// "Native zkEVM Layer 2 for Ethereum" no longer describes the page)
+const subhead = html.match(/
([^<]+)<\/p>/)?.[1]?.trim()
+if (!subhead) throw new Error("[landing] hero sub-head not found for the meta description")
+const description = subhead.replace(/"/g, """)
+const title = (html.match(/
([^<]*)<\/title>/) || [])[1] || "Scroll"
+const head = [
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+]
+const gaId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID
+if (process.env.NODE_ENV === "production" && gaId) {
+ head.push(
+ ``,
+ ``,
+ )
+}
+if (process.env.VERCEL) head.push(``)
+once("head", "", `${head.map(l => ` ${l}`).join("\n")}\n`)
+
+// ---- provenance, so nobody edits the output by hand -----------------------------------
+html = html.replace(
+ /^\s*/i,
+ `\n\n`,
+)
+
+fs.writeFileSync(output, html)
+console.log(
+ `[landing] wrote public/landing.html (${(html.length / 1024).toFixed(0)} KB) from design/landing.html${gaId && process.env.NODE_ENV === "production" ? " with GA" : ""}`,
+)
diff --git a/scripts/download-blog-posts.data.json.mjs b/scripts/download-blog-posts.data.json.mjs
index 7c7681beb..181f120c8 100644
--- a/scripts/download-blog-posts.data.json.mjs
+++ b/scripts/download-blog-posts.data.json.mjs
@@ -17,15 +17,42 @@ function buildPostURL(hostType) {
return `https://blog.scroll.cat/api/posts/${isMainnet ? "published" : "preview"}/${hostType}/data.json`
}
+const mainFile = path.join(blogAssetsDir, "main.data.json")
+const researchFile = path.join(blogAssetsDir, "research.data.json")
+
+// the blog service behind these URLs is no longer running (Zhengqi, 2026-09-09). This
+// module is imported by next.config.mjs, so a failed fetch used to take `next dev` and
+// `next build` down with it — now it keeps whatever data files are already on disk and
+// only complains. SKIP_BLOG_FETCH=1 skips the network round-trip entirely.
async function fetchPosts() {
- await Promise.all([
- fetch(buildPostURL("scroll.io"))
- .then(res => res.json())
- .then(json => fs.writeFileSync("./src/assets/blog/main.data.json", JSON.stringify(json, null, 2))),
- fetch(buildPostURL("research.scroll.io"))
- .then(res => res.json())
- .then(json => fs.writeFileSync("./src/assets/blog/research.data.json", JSON.stringify(json, null, 2))),
- ])
+ if (process.env.SKIP_BLOG_FETCH) {
+ console.log("[blog] SKIP_BLOG_FETCH set — keeping the existing data files")
+ return
+ }
+ // node-fetch has no timeout of its own. Behind a proxy (Surge / Clash fake-ip) the TCP
+ // connection to the dead host opens but the TLS handshake never completes, and `next dev`
+ // sat on this line for good (Zhengqi, 2026-09-10). Five seconds is plenty for a JSON file.
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(new Error("timed out after 5s")), 5000)
+ try {
+ await Promise.all([
+ fetch(buildPostURL("scroll.io"), { signal: controller.signal })
+ .then(res => res.json())
+ .then(json => fs.writeFileSync(mainFile, JSON.stringify(json, null, 2))),
+ fetch(buildPostURL("research.scroll.io"), { signal: controller.signal })
+ .then(res => res.json())
+ .then(json => fs.writeFileSync(researchFile, JSON.stringify(json, null, 2))),
+ ])
+ } finally {
+ clearTimeout(timer)
+ }
}
-await fetchPosts()
+try {
+ await fetchPosts()
+} catch (error) {
+ console.warn(`[blog] could not refresh blog posts (${error.message}); using the files already in src/assets/blog`)
+ for (const file of [mainFile, researchFile]) {
+ if (!fs.existsSync(file)) fs.writeFileSync(file, "[]")
+ }
+}
diff --git a/src/app/_components/AIHardware/index.tsx b/src/app/_components/AIHardware/index.tsx
deleted file mode 100644
index f73aa5265..000000000
--- a/src/app/_components/AIHardware/index.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client"
-
-import { FormEvent, useState } from "react"
-
-import { LOOPS_FORM_ID, LOOPS_MAILING_LIST_ID } from "@/constants/link"
-
-import { ArrowRightSmallIcon } from "../LandingIcons"
-import SectionDivider from "../SectionDivider"
-import { instrumentSerif } from "../fonts"
-
-type Status = "idle" | "loading" | "success" | "error"
-
-const AIHardwareSection = () => {
- const [email, setEmail] = useState("")
- const [status, setStatus] = useState("idle")
- const [errorMessage, setErrorMessage] = useState("")
-
- const handleSubmit = async (e: FormEvent) => {
- e.preventDefault()
- if (!email || status === "loading") return
- setStatus("loading")
- try {
- const res = await fetch(`https://app.loops.so/api/newsletter-form/${LOOPS_FORM_ID}`, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: [
- `email=${encodeURIComponent(email)}`,
- `userGroup=${encodeURIComponent("Compass Waitlist")}`,
- `mailingLists=${encodeURIComponent(LOOPS_MAILING_LIST_ID)}`,
- ].join("&"),
- })
- if (res.status === 429) {
- setStatus("error")
- setErrorMessage("Too many attempts — please try again in a minute.")
- return
- }
- const data = await res.json()
- if (data.success) {
- setStatus("success")
- } else {
- setStatus("error")
- setErrorMessage(data.message || "Something went wrong — please try again.")
- }
- } catch {
- setStatus("error")
- setErrorMessage("Network error — please try again.")
- }
- }
-
- return (
-
-
-
*coming soon*
-
AI Hardware
-
Your Agents stored locally. Unlimited prompting by staking SCR
-
-
- {status === "success" ? (
- Thanks — you're on the waitlist!
- ) : (
-
-
- {status === "error" &&
{errorMessage}
}
-
- )}
-
- )
-}
-
-export default AIHardwareSection
diff --git a/src/app/_components/AnchorLink.tsx b/src/app/_components/AnchorLink.tsx
new file mode 100644
index 000000000..000e1ff86
--- /dev/null
+++ b/src/app/_components/AnchorLink.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { MouseEvent, ReactNode } from "react"
+
+import { resolveAnchor, smoothScrollToTop } from "./smoothScroll"
+
+interface AnchorLinkProps {
+ /** "/#compass" style — a landing-page section */
+ href: string
+ className?: string
+ onClick?: (e: MouseEvent) => void
+ children: ReactNode
+}
+
+/**
+ * A link to a landing-page section. Off the landing page it is a plain ; on it, the
+ * click scrolls to whichever copy of the section is displayed at this breakpoint (see
+ * resolveAnchor) instead of letting the router look the hash up by id, which on the phone
+ * finds the hidden desktop card and goes nowhere.
+ */
+const AnchorLink = ({ href, className, onClick, children }: AnchorLinkProps) => {
+ const pathname = usePathname()
+
+ const handleClick = (e: MouseEvent) => {
+ onClick?.(e)
+ if (e.defaultPrevented || pathname !== "/") return
+ const id = href.split("#")[1]
+ if (!id) return
+ e.preventDefault()
+ smoothScrollToTop(resolveAnchor(id))
+ history.replaceState(null, "", href)
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default AnchorLink
diff --git a/src/app/_components/Button.tsx b/src/app/_components/Button.tsx
new file mode 100644
index 000000000..7e22c1cc9
--- /dev/null
+++ b/src/app/_components/Button.tsx
@@ -0,0 +1,72 @@
+import Link from "next/link"
+import { CSSProperties, MouseEvent } from "react"
+
+import AnchorLink from "./AnchorLink"
+import styles from "./button.module.css"
+
+interface ButtonProps {
+ /** "/#compass" scrolls on the landing page, "/sign-up" routes, "https://…" opens a new tab
+ * when `external`. Omit it for a real — a form's submit, say. */
+ href?: string
+ external?: boolean
+ /** ink fill with white type; the default is white with a hairline */
+ solid?: boolean
+ /** for the form only */
+ type?: "button" | "submit"
+ disabled?: boolean
+ /** on a link, call preventDefault to keep the href as the no-JS fallback */
+ onClick?: (e: MouseEvent) => void
+ className?: string
+ /** the label — plain text, since every character becomes its own rolling span */
+ children: string
+}
+
+/**
+ * Glen's button (scroll.html, 2026-09-10) — see button.module.css for the look. The
+ * label is split into characters here, on the server, so the roll needs no effect: each
+ * span carries its own copy of the letter in `data-c` for the CSS to draw underneath.
+ */
+const Button = ({ href, external = false, solid = false, type = "button", disabled = false, onClick, className = "", children }: ButtonProps) => {
+ const cls = `${styles.btn} ${solid ? styles.solid : ""} ${className}`
+ const label = (
+ <>
+
+ {[...children].map((c, i) => (
+
+ {c}
+
+ ))}
+
+ {children}
+ >
+ )
+
+ if (!href) {
+ return (
+
+ {label}
+
+ )
+ }
+ if (href.startsWith("/#")) {
+ return (
+
+ {label}
+
+ )
+ }
+ if (href.startsWith("/")) {
+ return (
+
+ {label}
+
+ )
+ }
+ return (
+
+ {label}
+
+ )
+}
+
+export default Button
diff --git a/src/app/_components/Compass/index.tsx b/src/app/_components/Compass/index.tsx
deleted file mode 100644
index cf3ac3fd7..000000000
--- a/src/app/_components/Compass/index.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import Image from "next/image"
-
-import SectionDivider from "../SectionDivider"
-import { instrumentSerif } from "../fonts"
-
-export const COMPASS_APP_STORE_URL = "https://apps.apple.com/gb/app/pocketpal-travel-buddy/id6774113297"
-
-const FLOATING_MODELS = [
- { label: "Gemini 3", className: "left-[78px] top-[108px]" },
- { label: "GPT-4o", className: "left-[78px] top-[281px]" },
- { label: "Qwen 3 max", className: "left-[420px] top-[140px]" },
- { label: "Claude Sonnet 3.5", className: "left-[406px] top-[234px]" },
-]
-
-const CompassSection = () => (
-
-
-
Compass
-
Every AI model in one iOS app
-
-
- Download
-
-
- {FLOATING_MODELS.map(({ label, className }) => (
-
-
- {label}
-
- ))}
-
-
-
-
-)
-
-export default CompassSection
diff --git a/src/app/_components/CompassApi/index.tsx b/src/app/_components/CompassApi/index.tsx
deleted file mode 100644
index a5501acbc..000000000
--- a/src/app/_components/CompassApi/index.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { COMPASS_API_URL } from "@/constants/link"
-
-import SectionDivider from "../SectionDivider"
-import { instrumentSerif } from "../fonts"
-
-const BRANDS_ROW_1 = ["Qwen", "Grok", "Kimi", "Black Forest Labs", "NVIDIA", "Claude", "Google", "DeepSeek", "OpenAI", "Mistral"]
-const BRANDS_ROW_2 = ["Gemma", "Kling", "Arcee", "PixVerse", "Vidu", "ElevenLabs", "Runway", "Bytedance", "MiniMax"]
-
-const CompassApiSection = () => (
-
-
-
- Compass API
-
-
Keys to AI models secured natively by Zero Knowledge proofs.
-
-
- View All Models
-
-
-
- {BRANDS_ROW_1.map(brand => (
-
-
- {brand}
-
- ))}
-
-
- {BRANDS_ROW_2.map(brand => (
-
-
- {brand}
-
- ))}
-
-
-
-
-)
-
-export default CompassApiSection
diff --git a/src/app/_components/DitherBackground.tsx b/src/app/_components/DitherBackground.tsx
new file mode 100644
index 000000000..8cad71b2f
--- /dev/null
+++ b/src/app/_components/DitherBackground.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+import styles from "./landing.module.css"
+
+// 8 x 8 Bayer threshold matrix
+const BAYER = [
+ [0, 32, 8, 40, 2, 34, 10, 42],
+ [48, 16, 56, 24, 50, 18, 58, 26],
+ [12, 44, 4, 36, 14, 46, 6, 38],
+ [60, 28, 52, 20, 62, 30, 54, 22],
+ [3, 35, 11, 43, 1, 33, 9, 41],
+ [51, 19, 59, 27, 49, 17, 57, 25],
+ [15, 47, 7, 39, 13, 45, 5, 37],
+ [63, 31, 55, 23, 61, 29, 53, 21],
+]
+// one canvas pixel per 3 screen pixels — the dither is meant to look coarse
+const SCALE = 3
+// redraw interval; the waves move slowly enough that ~11 fps reads as continuous
+const INTERVAL = 90
+
+const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
+
+/**
+ * Glen's page background (scroll.html, 2026-09-10): a violet ordered dither, fixed behind
+ * the page and masked so it only shows across the lower part of the viewport, with two
+ * slow sine waves rolling through the density. This is the version Zhengqi had already
+ * tuned for performance — a third-resolution canvas redrawn every 90ms, not every frame —
+ * with the pixel buffer reused between draws on top. Under reduced motion it is drawn once.
+ */
+const DitherBackground = () => {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const canvas = ref.current
+ const ctx = canvas?.getContext("2d")
+ if (!canvas || !ctx) return
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+
+ let cols = 0
+ let rows = 0
+ let image: ImageData | null = null
+ let last = 0
+ let frame = 0
+
+ const size = () => {
+ cols = Math.max(1, Math.ceil(window.innerWidth / SCALE))
+ rows = Math.max(1, Math.ceil(window.innerHeight / SCALE))
+ canvas.width = cols
+ canvas.height = rows
+ image = ctx.createImageData(cols, rows)
+ }
+
+ const draw = (t: number) => {
+ if (!image) return
+ const d = image.data
+ const T = t * 0.00006
+ for (let y = 0; y < rows; y++) {
+ const g = clamp(y / rows, 0, 1)
+ const base = Math.pow(g, 2.6) * 0.55 - 0.02
+ for (let x = 0; x < cols; x++) {
+ const wave = 0.16 * Math.sin(x * 0.03 + y * 0.075 + T * 6.5) + 0.11 * Math.sin(x * 0.013 - y * 0.03 - T * 4.0)
+ const v = clamp(base + wave * g * 0.9, 0, 1)
+ const th = (BAYER[y & 7][x & 7] + 0.5) / 64
+ const i = (y * cols + x) * 4
+ d[i] = 133 // Scroll violet
+ d[i + 1] = 118
+ d[i + 2] = 208
+ d[i + 3] = v > th ? 255 : 0
+ }
+ }
+ ctx.putImageData(image, 0, 0)
+ }
+
+ const loop = (t: number) => {
+ frame = requestAnimationFrame(loop)
+ if (t - last < INTERVAL) return
+ last = t
+ draw(t)
+ }
+
+ const onResize = () => {
+ size()
+ draw(performance.now())
+ }
+
+ size()
+ window.addEventListener("resize", onResize)
+ if (reduced) draw(0)
+ else frame = requestAnimationFrame(loop)
+
+ return () => {
+ cancelAnimationFrame(frame)
+ window.removeEventListener("resize", onResize)
+ }
+ }, [])
+
+ return
+}
+
+export default DitherBackground
diff --git a/src/app/_components/FooterRidge.tsx b/src/app/_components/FooterRidge.tsx
new file mode 100644
index 000000000..c003d7a83
--- /dev/null
+++ b/src/app/_components/FooterRidge.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import { CSSProperties, useEffect, useRef } from "react"
+
+import styles from "./footer.module.css"
+
+// the hill's profile across the eleven columns, and how far each is dropped
+const PROFILE = [0, 0.34, 0.6, 0.8, 0.93, 1, 0.93, 0.8, 0.6, 0.34, 0]
+// six violets, darkest first — stacked bottom to top
+const STACK = ["#1C1046", "#2E1B70", "#4527AE", "#6D45E8", "#9A80F0", "#C6B9F8"]
+
+const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
+
+/**
+ * Glen's `footerBands` (scroll.html, 2026-09-10), his comment: "footer ridge, violet, hard
+ * to trigger". The ridge stays hidden below the footer until the page is in its last
+ * stretch — the window from 2.2 screens above the footer to the end of the document —
+ * and rises through the last fifth of that, eased in, to show 78% of itself at the very
+ * bottom. One transform on one element per frame; the blur is on the layer, not
+ * recomputed.
+ */
+const FooterRidge = () => {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
+ const footer = el.closest("footer")
+ if (!footer) return
+
+ let ticking = false
+ const update = () => {
+ ticking = false
+ const vh = window.innerHeight
+ const maxScroll = Math.max(document.documentElement.scrollHeight - vh, 1)
+ const footerTop = footer.getBoundingClientRect().top + window.scrollY
+ const start = Math.max(footerTop - vh * 2.2, 0)
+ const raw = clamp((window.scrollY - start) / Math.max(maxScroll - start, 1), 0, 1)
+ const pulled = clamp((raw - 0.8) / 0.2, 0, 1)
+ const eased = Math.pow(pulled, 2.2)
+ el.style.transform = `translate3d(0, ${(100 - eased * 78).toFixed(2)}%, 0)`
+ }
+ const schedule = () => {
+ if (ticking) return
+ ticking = true
+ requestAnimationFrame(update)
+ }
+ update()
+ window.addEventListener("scroll", schedule, { passive: true })
+ window.addEventListener("resize", schedule)
+ return () => {
+ window.removeEventListener("scroll", schedule)
+ window.removeEventListener("resize", schedule)
+ }
+ }, [])
+
+ return (
+
+ {PROFILE.map((p, i) => (
+
+ {[...STACK].reverse().map(color => (
+
+ ))}
+
+ ))}
+
+ )
+}
+
+export default FooterRidge
diff --git a/src/app/_components/Hero/index.tsx b/src/app/_components/Hero/index.tsx
deleted file mode 100644
index 85d791f80..000000000
--- a/src/app/_components/Hero/index.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { ReactNode } from "react"
-
-import { ArrowCircleIcon, CodeIcon, CpuSmallIcon, SmartphoneIcon } from "../LandingIcons"
-import SectionDivider from "../SectionDivider"
-import { instrumentSerif } from "../fonts"
-
-interface ModelCard {
- status: string
- dotColor: string
- icon: ReactNode
- title: string
- description: string
- descWidthClass: string
- href: string
-}
-
-const MODEL_CARDS: ModelCard[] = [
- {
- status: "iOS App",
- dotColor: "#000000",
- icon: ,
- title: "Compass",
- description: "Every model under one place",
- descWidthClass: "lg:w-[320px]",
- href: "#compass",
- },
- {
- status: "Enterprise API",
- dotColor: "#C8B195",
- icon: ,
- title: "Compass API",
- description: "Keys to leading models secured with ZK proofs.",
- descWidthClass: "lg:w-[320px]",
- href: "#compass-api",
- },
- {
- status: "Currently building",
- dotColor: "#5F5C6E",
- icon: ,
- title: "AI Hardware",
- description: "Run local agents. Unlimited prompts by staking SCR.",
- descWidthClass: "lg:w-[338px]",
- href: "#ai-hardware",
- },
-]
-
-const LandingHero = () => (
-
-
-
Scroll
-
Your gateway to frontier models, cheaper and secured by ZK tech.
-
-
- {MODEL_CARDS.map(({ status, dotColor, icon, title, description, descWidthClass, href }) => (
-
-
-
-
- {status}
-
- {icon}
-
-
-
{title}
-
{description}
-
-
-
-
-
-
- ))}
-
-
-
-)
-
-export default LandingHero
diff --git a/src/app/_components/LandingFooter.tsx b/src/app/_components/LandingFooter.tsx
index 484d6b2e7..a665438d2 100644
--- a/src/app/_components/LandingFooter.tsx
+++ b/src/app/_components/LandingFooter.tsx
@@ -2,63 +2,71 @@ import Link from "next/link"
import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import AnchorLink from "./AnchorLink"
+import FooterRidge from "./FooterRidge"
+import styles from "./landing.module.css"
+
+// Glen's footer copy (scroll.html, 2026-09-10); his links are placeholders, ours are the
+// real pages and sections
const FOOTER_COLUMNS = [
{
title: "Legal",
- width: "w-[158px]",
links: [
- { label: "Privacy Policy", href: "/privacy-policy" },
- { label: "App Privacy Policy", href: "/app-privacy-policy" },
- { label: "Terms of Service", href: "/terms-of-service" },
+ { label: "Privacy policy", href: "/privacy-policy" },
+ { label: "App privacy policy", href: "/app-privacy-policy" },
+ { label: "Terms of service", href: "/terms-of-service" },
],
},
{
title: "Product",
- width: "w-[151px]",
links: [
{ label: "Compass", href: "/#compass" },
- { label: "Compass API", href: "/#compass-api" },
- { label: "AI Hardware", href: "/#ai-hardware" },
+ { label: "Compass API", href: "/#api" },
+ { label: "AI hardware", href: "/#hardware" },
],
},
]
-const linkClass = "text-[15px] text-[#5E5E5E] [font-family:var(--font-inter)] transition-colors hover:text-black"
+interface LandingFooterProps {
+ /**
+ * The landing page's footer (Glen's scroll.html, 2026-09-10): no panel of its own — it
+ * sits on the page background with room left under it for the violet ridge that rises
+ * as you reach the end (FooterRidge). His 2026-09-08 "Change footer to white" + "Upper
+ * drop shadow on footer" stays the look for the legal pages, which leave this off.
+ */
+ ridge?: boolean
+}
-const LandingFooter = () => (
-
-
-
-
-
-
- One Plan.
-
- Every Model.
-
-
-
- {FOOTER_COLUMNS.map(({ title, width, links }) => (
-
-
{title}
- {links.map(({ label, href }) =>
- href.startsWith("http") ? (
-
- {label}
-
- ) : (
-
- {label}
-
- ),
- )}
-
- ))}
-
+const LandingFooter = ({ ridge = false }: LandingFooterProps) => (
+
+ {ridge && }
+
+
+
+
+ One plan.
+
+ Every model.
+
-
-
© 2026 Scroll. All rights reserved
+ {FOOTER_COLUMNS.map(({ title, links }) => (
+
+ {title}
+ {links.map(({ label, href }) =>
+ href.includes("#") ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ ),
+ )}
+
+ ))}
+ © 2026 Scroll. All rights reserved.
)
diff --git a/src/app/_components/LandingIcons.tsx b/src/app/_components/LandingIcons.tsx
deleted file mode 100644
index 159dc0eb9..000000000
--- a/src/app/_components/LandingIcons.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { SVGProps } from "react"
-
-export const SmartphoneIcon = (props: SVGProps
) => (
-
-
-
-)
-
-export const CodeIcon = (props: SVGProps) => (
-
-
-
-)
-
-export const CpuSmallIcon = (props: SVGProps) => (
-
-
-
-)
-
-export const CpuLargeIcon = (props: SVGProps) => (
-
-
-
-)
-
-export const ArrowRightSmallIcon = (props: SVGProps) => (
-
-
-
-)
-
-export const ArrowCircleIcon = (props: SVGProps) => (
-
-
-
-
-)
diff --git a/src/app/_components/LandingNav.tsx b/src/app/_components/LandingNav.tsx
index f9615b7c9..96159daea 100644
--- a/src/app/_components/LandingNav.tsx
+++ b/src/app/_components/LandingNav.tsx
@@ -2,20 +2,58 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
-import { MouseEvent, useState } from "react"
+import { MouseEvent, useEffect, useState } from "react"
import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import styles from "./nav.module.css"
+import { resolveAnchor, smoothScrollTo, smoothScrollToTop } from "./smoothScroll"
+
const NAV_LINKS = [
{ label: "Home", href: "/" },
{ label: "Compass", href: "/#compass" },
- { label: "ZK API Keys", href: "/#compass-api" },
- { label: "AI hardware", href: "/#ai-hardware" },
+ { label: "ZK API keys", href: "/#api" },
+ { label: "AI hardware", href: "/#hardware" },
]
-const LandingNav = () => {
+/** true once the page has scrolled past `threshold`; read on a frame, not on every event */
+const useScrolled = (threshold: number, enabled: boolean) => {
+ const [scrolled, setScrolled] = useState(false)
+
+ useEffect(() => {
+ if (!enabled) return
+ let ticking = false
+ const update = () => {
+ setScrolled(window.scrollY > threshold)
+ ticking = false
+ }
+ const onScroll = () => {
+ if (ticking) return
+ ticking = true
+ requestAnimationFrame(update)
+ }
+ update()
+ window.addEventListener("scroll", onScroll, { passive: true })
+ return () => window.removeEventListener("scroll", onScroll)
+ }, [threshold, enabled])
+
+ return scrolled
+}
+
+interface LandingNavProps {
+ /**
+ * The landing page's behaviour (Glen's scroll.html, 2026-09-10): the bar is open at the
+ * top of the page and folds into the pill once you have scrolled 24px — see
+ * nav.module.css. Sign-up, the 404 and the legal pages leave this off and get the pill
+ * throughout.
+ */
+ collapsible?: boolean
+}
+
+const LandingNav = ({ collapsible = false }: LandingNavProps) => {
const pathname = usePathname()
const [open, setOpen] = useState(false)
+ const scrolled = useScrolled(24, collapsible)
// on the landing page itself, scroll smoothly instead of re-navigating (Home would jump otherwise)
const handleNavClick = (e: MouseEvent, href: string) => {
@@ -24,36 +62,31 @@ const LandingNav = () => {
e.preventDefault()
const id = href.split("#")[1]
if (id) {
- document.getElementById(id)?.scrollIntoView({ behavior: "smooth" })
+ smoothScrollToTop(resolveAnchor(id))
} else {
- window.scrollTo({ top: 0, behavior: "smooth" })
+ smoothScrollTo(0)
}
history.replaceState(null, "", href)
}
return (
-
-
-
- handleNavClick(e, "/")}
- className="flex size-[36px] items-center justify-center rounded-[18px] bg-[#F4F3ED]"
- >
-
-
- handleNavClick(e, "/")}
- className="flex h-[32px] items-center justify-center rounded-[16px] bg-[#F4F3ED] px-[14px] text-[14px] font-medium text-black"
- >
- Scroll
-
-
+
+ {/* Glen's brand (scroll.html, 2026-09-10): the 22px mark and the word on a faint grey
+ pill, 12 apart. Not the purple badge Kevin flagged on 2026-09-09 — that was #E4E4F4;
+ this is a 5.5% wash of the ink. */}
+
+ handleNavClick(e, "/")} className="flex items-center gap-[12px]">
+
+ Scroll
+
{NAV_LINKS.map(({ label, href }) => (
-
handleNavClick(e, href)} className="text-[14px] font-medium text-[#0B192C] hover:opacity-70">
+
handleNavClick(e, href)}
+ className={`${styles.link} text-[14px] text-[#4A4845] hover:text-[#0A0A0A]`}
+ >
{label}
))}
@@ -63,27 +96,27 @@ const LandingNav = () => {
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
onClick={() => setOpen(v => !v)}
- className="flex size-[36px] flex-col items-center justify-center gap-[4px] rounded-[18px] md:hidden"
+ className="flex size-[40px] flex-col items-center justify-center gap-[4px] rounded-[8px] md:hidden"
>
+ {open && (
+
+ {NAV_LINKS.map(({ label, href }) => (
+ handleNavClick(e, href)}
+ className="rounded-[12px] px-[16px] py-[12px] text-[16px] text-[#0A0A0A] hover:bg-[#F4F2F0]"
+ >
+ {label}
+
+ ))}
+
+ )}
- {open && (
-
- {NAV_LINKS.map(({ label, href }) => (
- handleNavClick(e, href)}
- className="rounded-[16px] px-[16px] py-[10px] text-[14px] font-medium text-[#0B192C] hover:bg-[#F4F3ED]"
- >
- {label}
-
- ))}
-
- )}
)
}
diff --git a/src/app/_components/SectionDivider.tsx b/src/app/_components/SectionDivider.tsx
deleted file mode 100644
index 7fc120c12..000000000
--- a/src/app/_components/SectionDivider.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { CpuLargeIcon } from "./LandingIcons"
-
-const SectionDivider = () => (
-
-)
-
-export default SectionDivider
diff --git a/src/app/_components/WaitlistCard.tsx b/src/app/_components/WaitlistCard.tsx
new file mode 100644
index 000000000..9e9bd485c
--- /dev/null
+++ b/src/app/_components/WaitlistCard.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import Link from "next/link"
+import { FormEvent, ReactNode, useEffect, useState } from "react"
+
+import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import { LOOPS_FORM_ID, LOOPS_MAILING_LIST_ID } from "@/constants/link"
+
+import Button from "./Button"
+import styles from "./landing.module.css"
+import { Lift } from "./motion"
+
+type Status = "idle" | "loading" | "done" | "error"
+
+interface WaitlistCardProps {
+ /** drives the shell's blur-and-settle entrance; leave unset to play it on mount */
+ shown?: boolean
+ /** set when the card sits in the overlay: the foot link and the success button close it
+ * instead of navigating home */
+ onClose?: () => void
+ /** id for the title, so a dialog can point aria-labelledby at it */
+ titleId?: string
+}
+
+export const CardShell = ({ children, foot, shown }: { children: ReactNode; foot: ReactNode; shown?: boolean }) => {
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => {
+ const id = requestAnimationFrame(() => setMounted(true))
+ return () => cancelAnimationFrame(id)
+ }, [])
+ const on = shown ?? mounted
+ return (
+
+
+ {children}
+ {foot}
+
+
+ )
+}
+
+/**
+ * Glen's login card (scroll.html, 2026-09-10) — the sheet his "Join the waitlist" button
+ * opens — with the waitlist's own content in it: one email field, Confirm, and the note that
+ * we will write when it is ready. It lives in two places: the overlay on the landing page,
+ * as in his file, and the /sign-up page, kept for when a real sign-up exists.
+ */
+const WaitlistCard = ({ shown, onClose, titleId = "waitlist-title" }: WaitlistCardProps) => {
+ const [email, setEmail] = useState("")
+ const [status, setStatus] = useState("idle")
+ const [errorMessage, setErrorMessage] = useState("")
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!email || status === "loading") return
+ setStatus("loading")
+ try {
+ const res = await fetch(`https://app.loops.so/api/newsletter-form/${LOOPS_FORM_ID}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: [
+ `email=${encodeURIComponent(email)}`,
+ `userGroup=${encodeURIComponent("Compass Waitlist")}`,
+ `mailingLists=${encodeURIComponent(LOOPS_MAILING_LIST_ID)}`,
+ ].join("&"),
+ })
+ if (res.status === 429) {
+ setStatus("error")
+ setErrorMessage("Too many attempts — please try again in a minute.")
+ return
+ }
+ const data = await res.json()
+ if (data.success) {
+ setStatus("done")
+ } else {
+ setStatus("error")
+ setErrorMessage(data.message || "Something went wrong — please try again.")
+ }
+ } catch {
+ setStatus("error")
+ setErrorMessage("Network error — please try again.")
+ }
+ }
+
+ const foot = (
+ <>
+ We'll only email you about this.
+
+ {onClose ? (
+
+ Back to Scroll.
+
+ ) : (
+ Back to Scroll.
+ )}
+
+ >
+ )
+
+ if (status === "done") {
+ return (
+
+
+
+
+ Awesome
+
+
+ We'll email you a link when we're done building.
+ {onClose ? Close : Go home }
+
+ )
+ }
+
+ return (
+
+
+
+
+ Join the waitlist
+
+
+
+
+ )
+}
+
+export default WaitlistCard
diff --git a/src/app/_components/button.module.css b/src/app/_components/button.module.css
new file mode 100644
index 000000000..7ed7000ff
--- /dev/null
+++ b/src/app/_components/button.module.css
@@ -0,0 +1,118 @@
+/* Glen's `.btn` (scroll.html, 2026-09-10): a 44px pill, white with a hairline or solid
+ ink, that rises a pixel on hover while a soft white halo comes up behind it and the
+ label rolls up letter by letter. */
+.btn {
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
+ --btn-bg: #fff;
+ --btn-ink: #0a0a0a;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 32px;
+ border-radius: 999px;
+ background: var(--btn-bg);
+ color: var(--btn-ink);
+ font-size: 14px;
+ font-weight: 500;
+ border: 1px solid #e5e1dc;
+ isolation: isolate;
+ cursor: pointer;
+ transition:
+ transform 0.45s var(--ease),
+ box-shadow 0.5s var(--ease),
+ border-color 0.4s var(--ease);
+ will-change: transform;
+}
+
+.solid {
+ --btn-bg: #0a0a0a;
+ --btn-ink: #fff;
+ border-color: #0a0a0a;
+}
+
+/* the halo: layered white shadows on a pseudo-element behind the button. His file clips
+ it with overflow:hidden on the button, which hides all of it; the roll below carries its
+ own clip, so the halo is left free to show here. */
+.btn::before {
+ content: "";
+ position: absolute;
+ inset: -2px;
+ border-radius: inherit;
+ z-index: -1;
+ opacity: 0;
+ box-shadow:
+ 0 0 0 1px rgba(255, 255, 255, 0.9),
+ 0 0 18px 6px rgba(255, 255, 255, 0.95),
+ 0 0 44px 18px rgba(255, 255, 255, 0.75),
+ 0 0 90px 40px rgba(255, 255, 255, 0.45);
+ transition: opacity 0.5s var(--ease);
+}
+
+.btn:hover,
+.btn:focus-visible {
+ transform: translateY(-1px);
+}
+
+.btn:hover::before,
+.btn:focus-visible::before {
+ opacity: 1;
+}
+
+.btn:active {
+ transform: translateY(0) scale(0.985);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+ transform: none;
+}
+
+.btn:disabled::before {
+ opacity: 0;
+}
+
+/* each letter sits above a copy of itself; on hover the stack rolls up one line, the
+ letters 16ms apart */
+.roll {
+ display: inline-flex;
+ overflow: hidden;
+ line-height: 1.2;
+}
+
+.ch {
+ display: inline-block;
+ position: relative;
+ white-space: pre;
+ transition: transform 0.42s var(--ease);
+ transition-delay: calc(var(--i) * 16ms);
+}
+
+.ch::after {
+ content: attr(data-c);
+ position: absolute;
+ left: 0;
+ top: 100%;
+ white-space: pre;
+}
+
+.btn:hover .ch,
+.btn:focus-visible .ch {
+ transform: translateY(-100%);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .btn,
+ .btn::before,
+ .ch {
+ transition: none;
+ }
+
+ .btn:hover .ch,
+ .btn:focus-visible .ch {
+ transform: none;
+ }
+}
diff --git a/src/app/_components/fonts.ts b/src/app/_components/fonts.ts
index 0fa28ebf9..750e7413e 100644
--- a/src/app/_components/fonts.ts
+++ b/src/app/_components/fonts.ts
@@ -1,5 +1,8 @@
-import { Geist, Instrument_Serif } from "next/font/google"
+import { Geist, Instrument_Serif, Inter, JetBrains_Mono } from "next/font/google"
+// Glen's scroll.html (2026-09-10) sets the landing page in three faces: Instrument Serif for
+// the display lines, Inter for everything else, JetBrains Mono for labels, the product rail
+// and the model band. The legal pages already used the serif for their headings.
export const instrumentSerif = Instrument_Serif({
weight: "400",
style: ["normal", "italic"],
@@ -8,6 +11,21 @@ export const instrumentSerif = Instrument_Serif({
variable: "--font-instrument-serif",
})
+export const inter = Inter({
+ weight: ["400", "500", "600", "700"],
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-inter-landing",
+})
+
+export const jetbrainsMono = JetBrains_Mono({
+ weight: ["400", "500", "700"],
+ subsets: ["latin"],
+ display: "swap",
+ variable: "--font-jetbrains-mono",
+})
+
+// still the face of sign-up, the 404 and the legal shell
export const geist = Geist({
subsets: ["latin"],
display: "swap",
diff --git a/src/app/_components/footer.module.css b/src/app/_components/footer.module.css
new file mode 100644
index 000000000..4d06416e0
--- /dev/null
+++ b/src/app/_components/footer.module.css
@@ -0,0 +1,39 @@
+/* Glen's footer ridge (scroll.html, 2026-09-10): eleven columns of six violets, dark at
+ the bottom, blurred into one soft hill that rises out of the bottom of the page as you
+ pull the last stretch of scroll. FooterRidge drives the rise; the footer's overflow clips
+ whatever is still below its edge. */
+.bands {
+ position: absolute;
+ left: -6%;
+ right: -6%;
+ bottom: 0;
+ height: clamp(260px, 42vh, 460px);
+ display: flex;
+ align-items: flex-end;
+ filter: blur(20px);
+ transform: translate3d(0, 100%, 0);
+ will-change: transform;
+ pointer-events: none;
+ opacity: 0.9;
+}
+
+.col {
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ height: 100%;
+ transform: translate3d(0, var(--drop, 0%), 0);
+}
+
+.col > span {
+ display: block;
+ width: 100%;
+ flex: 1 1 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bands {
+ transform: translate3d(0, 22%, 0);
+ }
+}
diff --git a/src/app/_components/landing.module.css b/src/app/_components/landing.module.css
new file mode 100644
index 000000000..dbc279622
--- /dev/null
+++ b/src/app/_components/landing.module.css
@@ -0,0 +1,804 @@
+/* Glen's scroll.html (2026-09-10), section by section. His tokens, type and layout rules
+ are kept as written — this file is the page's stylesheet, the components only hand out
+ its classes — with the fonts arriving through next/font's CSS variables.
+ The site sets html { font-size: 62.5% }, so his rem values are written out here as px at
+ the 16px root he designed on (0.875rem → 14px and so on). */
+
+/* the tokens also reach the plain footer, which the legal pages mount outside .theme, and
+ the waitlist overlay, which is portalled to */
+.theme,
+.footerPlain,
+.overlay {
+ --bg: #f4f2f0;
+ --surface: #ffffff;
+ --surface-2: #faf9f8;
+ --ink: #0a0a0a;
+ --ink-2: #4a4845;
+ --ink-3: #8b8781;
+ --ink-4: #b6b2ac;
+ --line: #e5e1dc;
+ --line-2: #efebe6;
+ --violet: #6d45e8;
+ --violet-soft: #ede8fc;
+ --violet-ink: #2e1b70;
+ --serif: var(--font-instrument-serif), Georgia, serif;
+ --mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
+ --ease-soft: cubic-bezier(0.16, 0.84, 0.44, 1);
+ --gutter: clamp(16px, 4vw, 48px);
+ --nav-h: 76px;
+}
+
+.theme {
+ background: var(--bg);
+ color: var(--ink);
+ font-size: 16px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+.theme :focus-visible {
+ outline: 2px solid var(--violet);
+ outline-offset: 3px;
+ border-radius: 4px;
+}
+
+@media (max-width: 899px) {
+ .theme {
+ --nav-h: 68px;
+ }
+}
+
+.container {
+ width: min(100% - var(--gutter) * 2, 1200px);
+ margin-inline: auto;
+}
+
+/* ---- page background dither: violet, fixed to the viewport, fading in towards the bottom ---- */
+.dither {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: -1;
+ pointer-events: none;
+ image-rendering: pixelated;
+ opacity: 0.26;
+ --dither-fade: linear-gradient(
+ 180deg,
+ transparent 0%,
+ transparent 56%,
+ rgba(0, 0, 0, 0.14) 68%,
+ rgba(0, 0, 0, 0.42) 79%,
+ rgba(0, 0, 0, 0.74) 89%,
+ #000 100%
+ );
+ -webkit-mask-image: var(--dither-fade);
+ mask-image: var(--dither-fade);
+}
+
+/* ---- type ---- */
+.display,
+.sectionTitle {
+ -webkit-text-stroke: 0.42px currentColor;
+}
+
+.display {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(40px, 5.6vw, 72px);
+ line-height: 1.04;
+ letter-spacing: -0.015em;
+ text-wrap: balance;
+ max-width: min(100%, 760px);
+ margin: 0 auto;
+}
+
+.sectionTitle {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: clamp(38.4px, 5.4vw, 60px);
+ line-height: 1.02;
+ letter-spacing: -0.02em;
+ margin: 0;
+}
+
+.lede {
+ color: var(--ink-3);
+ font-size: clamp(15.2px, 1.4vw, 17px);
+ max-width: min(100%, 620px);
+ margin: 0 auto;
+}
+
+/* the lede under a section title sits 16 below it (his inline margin-top). A class rather
+ than a utility: the module's own `margin: 0 auto` above would win over a utility class,
+ which is how this gap once went missing. */
+.ledeBelowTitle {
+ margin-top: 16px;
+}
+
+.label {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.16em;
+ color: var(--ink-3);
+ text-transform: uppercase;
+}
+
+/* ---- hero ---- */
+.hero {
+ position: relative;
+ padding-top: clamp(48px, 9vh, 104px);
+ padding-bottom: 64px;
+}
+
+/* his phone bar is 68 tall but his --nav-h stays 76, so his hero sits 8 lower under the
+ bar than the bar's own height would put it — matched, since that is how his file looks */
+@media (max-width: 899px) {
+ .hero {
+ padding-top: calc(clamp(48px, 9vh, 104px) + 8px);
+ }
+}
+
+.heroInner {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+ text-align: center;
+ position: relative;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.heroInner > * {
+ pointer-events: auto;
+}
+
+.heroActions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ justify-content: center;
+ margin-top: 8px;
+}
+
+.heroFigure {
+ position: relative;
+ z-index: 1;
+ margin-top: clamp(24px, 5vw, 56px);
+ width: min(100%, 1180px);
+ margin-inline: auto;
+}
+
+.figure {
+ position: relative;
+ width: 100%;
+ border-radius: 20px;
+ overflow: hidden;
+}
+
+.figureHero {
+ aspect-ratio: 1200 / 520;
+}
+
+.heroMobile {
+ display: none;
+}
+
+@media (max-width: 820px) {
+ .figureHero {
+ aspect-ratio: 1080 / 1500;
+ }
+
+ .heroDesktop {
+ display: none;
+ }
+
+ .heroMobile {
+ display: block;
+ }
+}
+
+/* ---- marquee band ---- */
+.band {
+ position: relative;
+ width: 100%;
+ margin-top: clamp(56px, 9vw, 112px);
+ padding-block: 32px 48px;
+ overflow: hidden;
+}
+
+.bandLabel {
+ display: block;
+ margin: 0 0 24px;
+ padding-inline: var(--gutter);
+}
+
+.marquee {
+ display: flex;
+ overflow: hidden;
+ -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
+ mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
+}
+
+.track {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: clamp(40px, 6vw, 84px);
+ padding-right: clamp(40px, 6vw, 84px);
+ margin: 0;
+ animation: slide 46s linear infinite;
+}
+
+.marquee:hover .track {
+ animation-play-state: paused;
+}
+
+@keyframes slide {
+ from {
+ transform: translate3d(0, 0, 0);
+ }
+ to {
+ transform: translate3d(-100%, 0, 0);
+ }
+}
+
+.mark {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 0 0 auto;
+ font-family: var(--mono);
+ font-size: clamp(14.4px, 1.5vw, 17px);
+ color: var(--ink);
+ white-space: nowrap;
+}
+
+.mark svg {
+ width: 21px;
+ height: 21px;
+ flex: 0 0 auto;
+ fill: var(--ink);
+}
+
+/* ---- products: sticky rail + scrolling panels ---- */
+.products {
+ position: relative;
+ padding-block: clamp(88px, 14vh, 160px) clamp(64px, 10vh, 120px);
+}
+
+.productsHead {
+ text-align: center;
+ margin-bottom: clamp(48px, 8vh, 96px);
+}
+
+.productsGrid {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
+ gap: clamp(32px, 6vw, 88px);
+ align-items: start;
+}
+
+.rail {
+ position: sticky;
+ top: calc(var(--nav-h) + clamp(24px, 12vh, 120px));
+}
+
+.railList {
+ display: flex;
+ flex-direction: column;
+ gap: 48px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.railLink {
+ display: grid;
+ gap: 4px;
+ width: 100%;
+ padding-left: 24px;
+ border-left: 1.5px solid transparent;
+ text-align: left;
+ transition: border-color 0.5s var(--ease);
+}
+
+.railNum {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ color: var(--ink-4);
+ transition: color 0.4s var(--ease);
+}
+
+.railName {
+ font-family: var(--mono);
+ font-size: 17px;
+ font-weight: 500;
+ color: var(--ink-4);
+ transition: color 0.4s var(--ease);
+}
+
+.railDesc {
+ font-size: 13px;
+ color: var(--ink-3);
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ transition:
+ max-height 0.55s var(--ease),
+ opacity 0.45s var(--ease),
+ margin-top 0.55s var(--ease);
+}
+
+.railItemActive .railLink {
+ border-left-color: var(--ink);
+}
+
+.railItemActive .railName {
+ color: var(--ink);
+}
+
+.railItemActive .railNum {
+ color: var(--ink-3);
+}
+
+.railItemActive .railDesc {
+ max-height: 4em;
+ opacity: 1;
+ margin-top: 8px;
+}
+
+.railItem:hover .railName {
+ color: var(--ink-2);
+}
+
+.panels {
+ display: flex;
+ flex-direction: column;
+ gap: clamp(48px, 9vh, 112px);
+}
+
+.panel {
+ background: var(--surface);
+ border: 1px solid var(--line-2);
+ border-radius: 28px;
+ padding: clamp(20px, 3vw, 32px);
+ box-shadow:
+ 0 2px 4px rgba(46, 27, 112, 0.02),
+ 0 18px 40px -30px rgba(46, 27, 112, 0.2);
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.panelHead {
+ display: none;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.panelNum {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ color: var(--ink-4);
+}
+
+.panelName {
+ font-family: var(--mono);
+ font-size: 17px;
+ font-weight: 500;
+ margin: 0;
+}
+
+.panelDesc {
+ font-size: 13px;
+ color: var(--ink-3);
+ margin: 0;
+}
+
+.panelFoot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+ padding-top: 8px;
+}
+
+.panelNote {
+ font-size: 13px;
+ color: var(--ink-3);
+ flex: 1 1 200px;
+ min-width: 0;
+ margin: 0;
+}
+
+.figureSphere {
+ aspect-ratio: 16 / 11;
+ min-height: 380px;
+}
+
+/* his hub figure is 346 / 232 for an older, tighter cut of the hub; our artwork's 346 x 290
+ viewBox carries the core's drop shadow to the bottom edge. The box trims the last of that
+ shadow room (275 of the 290, anchored to the top) so the panel does not carry dead air
+ under the hub, without letterboxing or clipping anything drawn. */
+.figureHub {
+ aspect-ratio: 346 / 275;
+ max-width: 430px;
+ margin-inline: auto;
+ width: 100%;
+}
+
+.figureChain {
+ height: 150px;
+}
+
+.figureDevice {
+ aspect-ratio: 1 / 1.05;
+ max-width: 560px;
+ margin-inline: auto;
+ width: 100%;
+}
+
+.apiStack {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+@media (max-width: 1000px) {
+ .productsGrid {
+ grid-template-columns: 1fr;
+ gap: 48px;
+ }
+
+ .rail {
+ display: none;
+ }
+
+ .panelHead {
+ display: flex;
+ }
+
+ .figureSphere {
+ aspect-ratio: 4 / 3;
+ min-height: 0;
+ }
+}
+
+@media (max-width: 560px) {
+ .figureSphere {
+ aspect-ratio: 1 / 1;
+ }
+
+ .figureChain {
+ height: 120px;
+ }
+
+ /* his 1 / 1.05 box cuts the device's legend off on a phone — the chips wrap to three
+ rows there and the third disappears under the edge, in his file too. Taller here so
+ the whole legend stays in the frame. */
+ .figureDevice {
+ aspect-ratio: 1 / 1.3;
+ }
+}
+
+/* ---- footer ---- */
+.footer {
+ position: relative;
+ width: 100%;
+ padding-block: clamp(72px, 11vh, 120px) clamp(320px, 44vh, 520px);
+ overflow: hidden;
+}
+
+/* the legal pages keep the earlier white panel with the shadow cast upwards */
+.footerPlain {
+ position: relative;
+ width: 100%;
+ padding-block: 40px;
+ background: #fff;
+ box-shadow: 0 -8px 32px rgba(17, 17, 17, 0.06);
+}
+
+.footerInner {
+ position: relative;
+ z-index: 2;
+ display: grid;
+ grid-template-columns: 1fr auto auto;
+ gap: clamp(32px, 6vw, 96px);
+ align-items: start;
+}
+
+.footerBrand {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ text-align: center;
+ justify-self: start;
+ padding-left: clamp(0px, 6vw, 120px);
+}
+
+.footerMark {
+ width: 30px;
+ height: 30px;
+ color: var(--ink);
+}
+
+.footerTagline {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.35;
+ margin: 0;
+}
+
+.fcol {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 130px;
+}
+
+.fcol h3 {
+ font-size: 14px;
+ font-weight: 700;
+ margin: 0;
+}
+
+.fcol a {
+ font-size: 14px;
+ color: var(--ink-2);
+ transition: color 0.25s var(--ease);
+}
+
+.fcol a:hover {
+ color: var(--ink);
+}
+
+.footerLegal {
+ position: relative;
+ z-index: 2;
+ margin: clamp(48px, 8vh, 88px) 0 0;
+ text-align: center;
+ font-size: 13px;
+ color: var(--ink-3);
+}
+
+@media (max-width: 900px) {
+ .footerInner {
+ grid-template-columns: 1fr 1fr;
+ gap: 48px;
+ }
+
+ .footerBrand {
+ grid-column: 1 / -1;
+ justify-self: start;
+ align-items: flex-start;
+ text-align: left;
+ padding-left: 0;
+ }
+}
+
+/* ---- the card: Glen's login overlay (scroll.html, 2026-09-10), which the waitlist page
+ wears standing alone. The shell blurs and settles in on arrival; the card is a white sheet
+ with a hairline, a serif title, 44px fields and a tinted foot. ---- */
+.cardShell {
+ width: min(100%, 352px);
+ transform: translateY(14px) scale(0.985);
+ filter: blur(6px);
+ opacity: 0;
+ transition:
+ transform 0.6s var(--ease-soft),
+ filter 0.6s var(--ease-soft),
+ opacity 0.6s var(--ease-soft);
+}
+
+.cardShellIn {
+ transform: none;
+ filter: none;
+ opacity: 1;
+}
+
+.card {
+ width: 100%;
+ background: var(--surface);
+ border: 1px solid var(--line-2);
+ border-radius: 20px;
+ overflow: hidden;
+ box-shadow: 0 30px 70px -40px rgba(30, 24, 18, 0.35);
+}
+
+.cardBody {
+ padding: 32px 32px 48px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.cardHead {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+.cardMark {
+ width: 24px;
+ height: 24px;
+ color: var(--ink);
+}
+
+.cardTitle {
+ font-family: var(--serif);
+ font-weight: 400;
+ font-size: 28px;
+ line-height: 1;
+ margin: 0;
+ text-align: center;
+}
+
+.cardText {
+ margin: 0;
+ text-align: center;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--ink-3);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.field label {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.field input {
+ width: 100%;
+ min-height: 44px;
+ padding: 0 16px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ font: inherit;
+ font-size: 14px;
+ color: var(--ink);
+ transition:
+ border-color 0.25s var(--ease),
+ box-shadow 0.25s var(--ease);
+}
+
+.field input::placeholder {
+ color: var(--ink-4);
+}
+
+.field input:focus {
+ outline: none;
+ border-color: var(--ink-3);
+ box-shadow: 0 0 0 3px rgba(10, 10, 10, 0.06);
+}
+
+.cardError {
+ margin: -8px 0 0;
+ text-align: center;
+ font-size: 13px;
+ color: #b3261e;
+}
+
+.cardFoot {
+ background: var(--surface-2);
+ border-top: 1px solid var(--line-2);
+ padding: 24px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 13px;
+ color: var(--ink-3);
+}
+
+.cardFoot p {
+ margin: 0;
+}
+
+.cardFoot a,
+.cardFoot button {
+ font: inherit;
+ background: none;
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ color: var(--ink-2);
+ transition: color 0.25s var(--ease);
+}
+
+.cardFoot a:hover,
+.cardFoot button:hover {
+ color: var(--ink);
+}
+
+/* ---- the overlay the card opens in on the landing page: a frosted wash over the page ---- */
+.overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ display: grid;
+ place-items: center;
+ padding: var(--gutter);
+ color: var(--ink);
+ font-size: 16px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+ background: rgba(244, 242, 240, 0.82);
+ -webkit-backdrop-filter: blur(14px);
+ backdrop-filter: blur(14px);
+ opacity: 0;
+ visibility: hidden;
+ transition:
+ opacity 0.5s var(--ease),
+ visibility 0.5s var(--ease);
+}
+
+.overlayOpen {
+ opacity: 1;
+ visibility: visible;
+}
+
+.overlayClose {
+ position: absolute;
+ top: 24px;
+ right: 24px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ color: var(--ink-2);
+ background: none;
+ border: 0;
+ cursor: pointer;
+ transition: background-color 0.25s var(--ease);
+}
+
+.overlayClose:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .track {
+ animation: none;
+ }
+
+ .railLink,
+ .railNum,
+ .railName,
+ .railDesc,
+ .fcol a,
+ .field input,
+ .cardFoot a {
+ transition: none;
+ }
+
+ .cardShell {
+ transform: none;
+ filter: none;
+ opacity: 1;
+ transition: none;
+ }
+
+ .overlay,
+ .overlayClose {
+ transition: none;
+ }
+}
diff --git a/src/app/_components/motion.module.css b/src/app/_components/motion.module.css
new file mode 100644
index 000000000..5ae875f24
--- /dev/null
+++ b/src/app/_components/motion.module.css
@@ -0,0 +1,141 @@
+/* The design's reveal (Glen's scroll.html, 2026-09-10): fade in from a 14px blur and a
+ short rise, and replay in both directions — an element that leaves the viewport blurs
+ out again. Timings and the soft ease are his. */
+.reveal {
+ --ease-soft: cubic-bezier(0.16, 0.84, 0.44, 1);
+ opacity: 0;
+ filter: blur(14px);
+ transform: translateY(14px);
+ transition:
+ opacity 0.95s var(--ease-soft) var(--d, 0ms),
+ filter 1.05s var(--ease-soft) var(--d, 0ms),
+ transform 1s var(--ease-soft) var(--d, 0ms);
+}
+
+/* `none` rather than his blur(0): a zero blur still leaves the text under a filter, and
+ Chrome then rasterises the headline a touch soft */
+.reveal.in {
+ opacity: 1;
+ filter: none;
+ transform: none;
+}
+
+/* panels fade and slide in from the right but never blur — they have to stay legible */
+.plain {
+ filter: none;
+ transform: translate3d(34px, 18px, 0);
+}
+
+.plain.in {
+ filter: none;
+ transform: none;
+}
+
+@media (max-width: 999px) {
+ .plain {
+ transform: translate3d(0, 18px, 0);
+ }
+}
+
+/* the headline's words: each clipped to its own line and lifted in. The clip box is padded
+ past the line box (and pulled back with negative margins, so the layout is untouched) to
+ keep ascenders, descenders and the serif's overhangs from being cut. */
+.wordMask {
+ display: inline-block;
+ overflow: hidden;
+ vertical-align: bottom;
+ padding: 0.18em 0.08em 0.22em;
+ margin: -0.18em -0.08em -0.22em;
+}
+
+.word {
+ --ease-soft: cubic-bezier(0.16, 0.84, 0.44, 1);
+ display: inline-block;
+ opacity: 0;
+ filter: blur(6px);
+ transform: translateY(120%);
+ transition:
+ transform 0.9s var(--ease-soft) var(--d, 0ms),
+ filter 0.7s var(--ease-soft) var(--d, 0ms),
+ opacity 0.6s var(--ease-soft) var(--d, 0ms);
+ will-change: transform, filter, opacity;
+}
+
+.wordIn {
+ opacity: 1;
+ filter: none;
+ transform: none;
+ will-change: auto;
+}
+
+/* the typed sub-head's caret, blinking while it types — absolutely positioned off the
+ right edge of the last typed letter, so it is no part of the line's width */
+.caret {
+ position: absolute;
+ left: 100%;
+ top: 0.08em;
+ width: 1px;
+ height: 0.95em;
+ margin-left: 2px;
+ background: currentColor;
+ animation: caret 0.75s steps(1) infinite;
+}
+
+@keyframes caret {
+ 0%,
+ 50% {
+ opacity: 1;
+ }
+ 50.01%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+/* the product sheets lift on hover; on the phone, where there is no hover, they lift as
+ they scroll into view instead (`.lifted`, toggled by Lift) */
+.lift {
+ transition:
+ transform 0.85s cubic-bezier(0.16, 0.84, 0.44, 1),
+ box-shadow 0.85s cubic-bezier(0.16, 0.84, 0.44, 1);
+ will-change: transform;
+}
+
+@media (hover: hover) and (min-width: 900px) {
+ .lift:hover {
+ transform: translateY(-8px) scale(1.006);
+ box-shadow:
+ 0 4px 10px rgba(46, 27, 112, 0.04),
+ 0 40px 80px -40px rgba(46, 27, 112, 0.3);
+ }
+}
+
+@media (max-width: 899px) {
+ .lift.lifted {
+ transform: translateY(-6px) scale(1.004);
+ box-shadow:
+ 0 4px 10px rgba(46, 27, 112, 0.04),
+ 0 34px 70px -40px rgba(46, 27, 112, 0.28);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .reveal,
+ .word {
+ opacity: 1;
+ filter: none;
+ transform: none;
+ transition: none;
+ }
+
+ .caret {
+ animation: none;
+ }
+
+ .lift,
+ .lift:hover,
+ .lift.lifted {
+ transition: none;
+ transform: none;
+ }
+}
diff --git a/src/app/_components/motion.tsx b/src/app/_components/motion.tsx
new file mode 100644
index 000000000..ba0ca60a6
--- /dev/null
+++ b/src/app/_components/motion.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import { CSSProperties, Fragment, ReactNode, useEffect, useRef, useState } from "react"
+
+import styles from "./motion.module.css"
+
+/**
+ * Glen's scroll.html (2026-09-10) replaces the monad-derived entrances this file used to
+ * hold (a 30px / 0.8s power2.out rise, fired once) with one recipe for everything that
+ * moves on scroll — see motion.module.css for the values. What survives from the earlier
+ * rounds is the nav's drop-in, which his file does not animate but Zhengqi asked for.
+ */
+
+const prefersReducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
+
+/**
+ * The nav dropping in from above, on load — monad's `.navbar` step, read off their page:
+ *
+ * .navbar { transform: translateY(-100px); opacity: 0 } // in CSS
+ * tl.to('.navbar', { y: 0, opacity: 1, duration: 1, ease: 'quart.out' }, 0)
+ *
+ * Happens once, on arrival, and settles to transform:none so the sticky bar is untouched
+ * afterwards.
+ */
+const EASE_QUART = "cubic-bezier(0.165, 0.84, 0.44, 1)"
+
+export const DropIn = ({ children, className = "" }: { children: ReactNode; className?: string }) => {
+ const [on, setOn] = useState(false)
+ useEffect(() => {
+ if (prefersReducedMotion()) {
+ setOn(true)
+ return
+ }
+ const id = requestAnimationFrame(() => setOn(true))
+ return () => cancelAnimationFrame(id)
+ }, [])
+ return (
+
+ {children}
+
+ )
+}
+
+interface RevealProps {
+ children: ReactNode
+ className?: string
+ /** ms before the entrance starts — Glen staggers the hero at 120 / 320 / 420 */
+ delay?: number
+ /** the panel variant: slides in from the right and never blurs */
+ plain?: boolean
+ /** play once, the first time the element is seen, and never hide it again — for the hero,
+ * where a replay on scrolling back up reads as the page refreshing */
+ once?: boolean
+}
+
+/**
+ * Glen's `.reveal`: an element fades in from a blur and a short rise as it comes into
+ * view, and blurs out again as it leaves — his observer toggles the class in both
+ * directions rather than firing once. Thresholds are his: 10% visible, with the top 4%
+ * and bottom 8% of the viewport not counting, so nothing flickers at the very edge.
+ *
+ * The element keeps its layout box throughout — only transform, filter and opacity
+ * move — so nothing below it shifts and the sticky product rail is unaffected.
+ *
+ * Rendered server-side hidden (the class carries opacity 0) so there is no flash before
+ * hydration; the observer's first callback shows whatever is already on screen.
+ */
+export const Reveal = ({ children, className = "", delay = 0, plain = false, once = false }: RevealProps) => {
+ const ref = useRef(null)
+ const [on, setOn] = useState(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ if (prefersReducedMotion()) {
+ setOn(true)
+ return
+ }
+ const observer = new IntersectionObserver(
+ entries =>
+ entries.forEach(e => {
+ if (once) {
+ if (!e.isIntersecting) return
+ setOn(true)
+ observer.disconnect()
+ } else {
+ setOn(e.isIntersecting)
+ }
+ }),
+ { threshold: 0.1, rootMargin: "-4% 0px -8% 0px" },
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [once])
+
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * The headline's entrance: each word rises out of its own clipped line and sharpens as it
+ * comes, the words 60ms apart, once, on load. This is the reveal Linear, Vercel and Apple's
+ * marketing pages use for a large serif or display line, and it replaces two motions Glen's
+ * file stacked on the same headline — a blur-in and, a second later, an "ascii pass" that
+ * scrambled "frontier models" into symbols. Zhengqi 2026-09-10: the pair read as the line
+ * refreshing twice, and the scramble, in a proportional serif, made the letters jump
+ * sideways as symbol widths changed. Gone; one calm entrance, and nothing ever replays.
+ *
+ * Each word's clip box is padded and pulled back with negative margins so it covers the
+ * serif's ascenders, descenders and the overhang of an "f" without adding to the layout.
+ * Words are inline blocks separated by real spaces, so the line still wraps and balances.
+ */
+export const WordsIn = ({ text, delay = 120, stagger = 60 }: { text: string; delay?: number; stagger?: number }) => {
+ const [on, setOn] = useState(false)
+ useEffect(() => {
+ if (prefersReducedMotion()) {
+ setOn(true)
+ return
+ }
+ const id = requestAnimationFrame(() => setOn(true))
+ return () => cancelAnimationFrame(id)
+ }, [])
+ const words = text.split(" ")
+ return (
+ <>
+ {words.map((word, i) => (
+
+
+
+ {word}
+
+
+ {/* the space lives between the clip boxes: inside one it would be a trailing
+ space at the end of an inline block, which the browser collapses away */}
+ {i < words.length - 1 ? " " : null}
+
+ ))}
+ >
+ )
+}
+
+/**
+ * Glen's `.lift`: the product sheet rises 8px and grows a hair on hover, with a long soft
+ * shadow. His file notes that phones have no hover, so there the sheet lifts as it scrolls
+ * into view (35% visible) and settles when it leaves.
+ */
+export const Lift = ({ children, className = "" }: { children: ReactNode; className?: string }) => {
+ const ref = useRef(null)
+ const [lifted, setLifted] = useState(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el || prefersReducedMotion() || window.matchMedia("(min-width: 900px)").matches) return
+ const observer = new IntersectionObserver(entries => entries.forEach(e => setLifted(e.isIntersecting)), { threshold: 0.35 })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Glen 2026-09-08, twice: the sub-head "has typed animation" and, on seeing the build,
+ * "and this types in". His 2026-09-10 file keeps it and adds the detail: the typing
+ * starts 820ms after the line comes into view, pauses longer on spaces, commas and full
+ * stops, and shows a blinking caret while it runs.
+ *
+ * His file also wipes the line and retypes it every time it scrolls back into view. That
+ * reads as the page glitching rather than as a flourish (Zhengqi 2026-09-10), so here the
+ * line types once, the first time it is seen, and then stays put — including if you scroll
+ * away mid-sentence; it finishes on its own.
+ *
+ * Every character is laid out from the start and the ones not yet typed are merely hidden,
+ * so the line's width never changes and it never rewraps. It used to be two spans — the
+ * typed prefix and an invisible remainder — but the browser does not kern across a span
+ * boundary, so the total width wobbled a fraction of a pixel as the split moved and the
+ * centred line shimmered. The caret is out of the flow altogether — positioned off the
+ * right edge of the last typed character — because even a zero-width inline bar nudged
+ * the line's width by a third of a pixel depending on where it sat.
+ */
+export const Typed = ({ text, className = "", delay = 820 }: { text: string; className?: string; delay?: number }) => {
+ const ref = useRef(null)
+ const [count, setCount] = useState(0)
+ const [typing, setTyping] = useState(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ if (prefersReducedMotion()) {
+ setCount(text.length)
+ return
+ }
+
+ let start: ReturnType | undefined
+ let tick: ReturnType | undefined
+ const stop = () => {
+ clearTimeout(start)
+ clearTimeout(tick)
+ }
+ const run = () => {
+ let i = 0
+ setTyping(true)
+ const next = () => {
+ i += 1
+ setCount(i)
+ if (i >= text.length) {
+ setTyping(false)
+ return
+ }
+ const c = text[i - 1]
+ let d = 26 + Math.random() * 26
+ if (c === " ") d += 16
+ if (c === ",") d += 130
+ if (c === ".") d += 220
+ tick = setTimeout(next, d)
+ }
+ next()
+ }
+
+ const observer = new IntersectionObserver(
+ entries => {
+ if (!entries[0].isIntersecting) return
+ observer.disconnect()
+ start = setTimeout(run, delay)
+ },
+ { threshold: 0.5 },
+ )
+ observer.observe(el)
+ return () => {
+ stop()
+ observer.disconnect()
+ }
+ }, [text, delay])
+
+ const chars = [...text]
+ return (
+
+
+ {chars.map((c, i) => (
+
+ {c}
+ {typing && i === count - 1 && }
+
+ ))}
+
+ {text}
+
+ )
+}
diff --git a/src/app/_components/nav.module.css b/src/app/_components/nav.module.css
new file mode 100644
index 000000000..f807cb2f1
--- /dev/null
+++ b/src/app/_components/nav.module.css
@@ -0,0 +1,118 @@
+/* Glen's nav (scroll.html, 2026-09-10). On the landing page the bar starts open — the
+ full content width, 76 tall, no surface, the brand at one edge and the links at the
+ other — and once the page has scrolled 24px it folds into a 700px frosted pill, 54 tall,
+ sitting 12 below the top. The wrapper's height never changes, so the page under it
+ does not move; only the pill inside it does. Phones skip the open state: the pill is
+ there from the start, 56 tall with a 20px radius. */
+.wrap {
+ --gutter: 0px;
+ --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
+ display: flex;
+ justify-content: center;
+ pointer-events: none;
+}
+
+.floating {
+ --gutter: clamp(16px, 4vw, 48px);
+ height: 68px;
+ padding-top: 12px;
+ transition: padding-top 0.5s var(--ease);
+}
+
+.nav {
+ pointer-events: auto;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: min(100% - var(--gutter) * 2, 700px);
+ height: 56px;
+ padding: 0 16px;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.86);
+ -webkit-backdrop-filter: saturate(180%) blur(18px);
+ backdrop-filter: saturate(180%) blur(18px);
+ border: 1px solid rgba(0, 0, 0, 0.05);
+ box-shadow: 0 6px 28px -12px rgba(46, 27, 112, 0.22);
+ transition:
+ width 0.55s var(--ease),
+ height 0.45s var(--ease),
+ padding 0.45s var(--ease),
+ border-radius 0.45s var(--ease),
+ background-color 0.45s var(--ease),
+ border-color 0.45s var(--ease),
+ box-shadow 0.55s var(--ease),
+ backdrop-filter 0.45s var(--ease);
+}
+
+@media (min-width: 900px) {
+ .floating {
+ height: 76px;
+ }
+
+ .nav {
+ height: 54px;
+ padding: 0 24px;
+ border-radius: 999px;
+ }
+
+ .floating.open {
+ padding-top: 0;
+ }
+
+ .floating.open .nav {
+ width: min(100% - var(--gutter) * 2, 1560px);
+ height: 76px;
+ padding: 0;
+ background: transparent;
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ border-color: transparent;
+ box-shadow: none;
+ }
+}
+
+/* the word beside the mark, on a faint wash of the ink */
+.brandName {
+ font-weight: 600;
+ font-size: 14px;
+ letter-spacing: -0.01em;
+ line-height: 1.55;
+ background: rgba(10, 10, 10, 0.055);
+ color: #0a0a0a;
+ padding: 2px 12px;
+ border-radius: 999px;
+}
+
+/* a hairline grows under a link from the left on hover */
+.link {
+ position: relative;
+ padding-block: 8px;
+ transition: color 0.25s var(--ease);
+}
+
+.link::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 2px;
+ height: 1px;
+ background: currentColor;
+ transform: scaleX(0);
+ transform-origin: left;
+ transition: transform 0.4s var(--ease);
+}
+
+.link:hover::after {
+ transform: scaleX(1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .floating,
+ .nav,
+ .link,
+ .link::after {
+ transition: none;
+ }
+}
diff --git a/src/app/_components/smoothScroll.ts b/src/app/_components/smoothScroll.ts
new file mode 100644
index 000000000..ec75302a1
--- /dev/null
+++ b/src/app/_components/smoothScroll.ts
@@ -0,0 +1,57 @@
+/**
+ * Scroll helpers for the landing page.
+ *
+ * `scrollIntoView({ behavior: "smooth" })` would mostly do, but globals.css sets
+ * `html { scroll-padding-top: 140px }` for the old site header, which is hidden here — the
+ * landing page only has a 48px sticky pill. Animating ourselves keeps the offset correct
+ * and the easing consistent between the nav, the hero CTA and the product rail.
+ */
+const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
+
+export const smoothScrollTo = (top: number, duration = 650) => {
+ const start = window.scrollY
+ const target = Math.max(0, top)
+ const distance = target - start
+ if (Math.abs(distance) < 2) return
+
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ window.scrollTo(0, target)
+ return
+ }
+
+ const startedAt = performance.now()
+ const step = (now: number) => {
+ const progress = Math.min(1, (now - startedAt) / duration)
+ window.scrollTo(0, start + distance * easeInOutCubic(progress))
+ if (progress < 1) requestAnimationFrame(step)
+ }
+ requestAnimationFrame(step)
+}
+
+/** brings the element's vertical centre to the middle of the viewport */
+export const smoothScrollToCenter = (el: HTMLElement | null) => {
+ if (!el) return
+ const rect = el.getBoundingClientRect()
+ smoothScrollTo(window.scrollY + rect.top + rect.height / 2 - window.innerHeight / 2)
+}
+
+/** brings the element's top just below the sticky nav — Glen's scroll-margin-top, the 76 bar + 24 */
+export const smoothScrollToTop = (el: HTMLElement | null, offset = 100) => {
+ if (!el) return
+ smoothScrollTo(window.scrollY + el.getBoundingClientRect().top - offset)
+}
+
+/**
+ * The product cards render twice — once in the desktop grid (`#compass` …) and once in the
+ * phone stack (`#compass-mobile` …), only one of which is displayed at a time. Links and
+ * hashes name the desktop id; this hands back whichever copy is actually on screen, so a
+ * phone tap on "Compass" in the nav or footer lands on the visible card rather than on a
+ * display:none one (Codex review, 2026-09-09).
+ */
+export const resolveAnchor = (id: string): HTMLElement | null => {
+ const el = document.getElementById(id)
+ if (el && el.getClientRects().length > 0) return el
+ const mobile = document.getElementById(`${id}-mobile`)
+ if (mobile && mobile.getClientRects().length > 0) return mobile
+ return el ?? mobile
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index cf2ac4954..c4542a303 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -209,3 +209,21 @@ div.markdown-body img {
@apply p-[16px];
}
}
+
+/* landing page: the click feedback on a model card in the Compass globe, from Glen's
+ prototype (.card.pop) */
+@keyframes model-card-pop {
+ 0% {
+ transform: scale(1);
+ }
+ 40% {
+ transform: scale(0.92);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.model-card-pop {
+ animation: model-card-pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5a2c6f130..055f62ecf 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,7 +13,6 @@ import GlobalComponents from "@/components/GlobalComponents"
import ScrollToTop from "@/components/ScrollToTop"
import WebVitals from "@/components/WebVitals"
import { ROOT_METADATA } from "@/constants/route"
-import RainbowProvider from "@/contexts/RainbowProvider"
import { VersionChecker } from "@/hooks/useVersionCheck"
import ScrollThemeProvider from "@/theme"
@@ -67,11 +66,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+ {/* No wallet provider here any more: every route that needed one (bridge, canvas,
+ developer-nft, …) was taken off the site in 2025-08 (src/app/_*), and wrapping the
+ whole site in RainbowKit still cost every page ~530 KB of compressed JS and a
+ dozen WalletConnect requests on load. The provider and its hooks stay under
+ src/contexts/RainbowProvider for the day a wallet route comes back — mount it in
+ that route's own layout then. (Zhengqi 2026-09-10) */}
-
- {children}
-
-
+ {children}
+
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 2707eb1a7..de41b4ef8 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,98 +1,58 @@
-"use client"
-
-import { usePathname, useRouter } from "next/navigation"
-import { makeStyles } from "tss-react/mui"
-
-import { Button } from "@mui/material"
-
-import useCheckTheme from "@/components/Header/useCheckTheme"
-
-const useStyles = makeStyles()((theme, { dark }) => {
- return {
- wrapper: {
- width: "100%",
- height: "calc(100vh - 44.6rem)",
- minHeight: "30rem",
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- [theme.breakpoints.down("sm")]: {
- width: "100%",
- height: "31rem",
- padding: "0 3rem",
- },
- },
-
- content: {
- display: "grid",
- width: "70rem",
- gridTemplateColumns: "min-content 1fr",
- gridTemplateRows: "repeat(2, min-content)",
- gridColumnGap: "2.6rem",
- gridRowGap: "4rem",
- alignItems: "center",
- color: dark ? theme.palette.primary.contrastText : theme.palette.text.primary,
- [theme.breakpoints.down("sm")]: {
- gridTemplateColumns: "1fr",
- gridTemplateRows: "repeat(3, min-content)",
- gridRowGap: "1rem",
- },
- },
- status: {
- fontSize: "10rem",
- fontWeight: 600,
- lineHeight: 1,
- letterSpacing: "-4px",
- [theme.breakpoints.down("sm")]: {
- fontSize: "6rem",
- },
- },
- message: {
- fontSize: "3.2rem",
- letterSpacing: "-1.4px",
- fontWeight: 500,
- [theme.breakpoints.down("sm")]: {
- fontSize: "1.4rem",
- letterSpacing: "unset",
- },
- },
- action: {
- justifySelf: "center",
- gridColumn: "span 2",
- [theme.breakpoints.down("sm")]: {
- gridColumn: "unset",
- justifySelf: "flex-start",
- marginTop: "3.2rem",
- },
- },
- }
-})
-
-const NotFound = () => {
- const dark = useCheckTheme()
- const { classes } = useStyles({ dark })
- const pathname = usePathname()
-
- const router = useRouter()
-
- const handleReturnHome = () => {
- if (pathname!.startsWith("/alpha")) {
- router.push("/alpha/")
- return
- }
- router.push("/")
- }
- return (
-
-
- 404
- Sorry, the page you are looking for is not found
-
- Home
-
-
+import Link from "next/link"
+
+import ScrollMarkSvg from "@/assets/svgs/landingpage/scroll-mark.svg"
+import { genMeta } from "@/utils/route"
+
+import Button from "./_components/Button"
+import DitherBackground from "./_components/DitherBackground"
+import LandingNav from "./_components/LandingNav"
+import { CardShell } from "./_components/WaitlistCard"
+import { instrumentSerif, inter, jetbrainsMono } from "./_components/fonts"
+import styles from "./_components/landing.module.css"
+
+export const generateMetadata = genMeta(() => ({
+ titleSuffix: "Page not found",
+}))
+
+/**
+ * The 404, dressed like /sign-up: Glen's tokens, type and dither background (scroll.html,
+ * 2026-09-10), the nav as the folded pill, and his login card in the middle carrying the
+ * message — the same shell the waitlist card uses, so a dead link lands on something that
+ * looks like the rest of the site.
+ */
+const NotFound = () => (
+
+
+
+
- )
-}
+
+
+ If a link brought you here, it is out of date.
+
+ Back to Scroll.
+
+ >
+ }
+ >
+
+
+ ERROR 404
+
Page not found
+
+ That page doesn't exist, or it has moved.
+
+
+ Go home
+
+
+
+
+
+)
export default NotFound
diff --git a/src/app/page.tsx b/src/app/page.tsx
deleted file mode 100644
index 41b9f4e2c..000000000
--- a/src/app/page.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { genMeta } from "@/utils"
-
-import AIHardwareSection from "./_components/AIHardware"
-import CompassSection from "./_components/Compass"
-import CompassApiSection from "./_components/CompassApi"
-import Hero from "./_components/Hero"
-import LandingFooter from "./_components/LandingFooter"
-import LandingNav from "./_components/LandingNav"
-import { geist, instrumentSerif } from "./_components/fonts"
-
-export const generateMetadata = genMeta(() => ({
- titleSuffix: "Your Gateway to Frontier Models",
-}))
-
-const LandingPage = () => {
- return (
-
- )
-}
-
-export default LandingPage
diff --git a/src/app/sessions-restricted/page.tsx b/src/app/sessions-restricted/page.tsx
index 3d95b3d70..970f62ab0 100644
--- a/src/app/sessions-restricted/page.tsx
+++ b/src/app/sessions-restricted/page.tsx
@@ -4,7 +4,7 @@ import { notFound } from "next/navigation"
import { Container, Typography } from "@mui/material"
import ScrollySad from "@/assets/images/common/scrolly-sad.png"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
import { genMeta } from "@/utils/route"
export const generateMetadata = genMeta(() => ({
diff --git a/src/app/sessions-terms-of-use/layout.tsx b/src/app/sessions-terms-of-use/layout.tsx
index af590cc59..665d6742e 100644
--- a/src/app/sessions-terms-of-use/layout.tsx
+++ b/src/app/sessions-terms-of-use/layout.tsx
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
import { genMeta } from "@/utils/route"
export const generateMetadata = genMeta(() => ({
diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx
new file mode 100644
index 000000000..11b6add40
--- /dev/null
+++ b/src/app/sign-up/page.tsx
@@ -0,0 +1,34 @@
+import { genMeta } from "@/utils/route"
+
+import DitherBackground from "../_components/DitherBackground"
+import LandingNav from "../_components/LandingNav"
+import WaitlistCard from "../_components/WaitlistCard"
+import { instrumentSerif, inter, jetbrainsMono } from "../_components/fonts"
+import styles from "../_components/landing.module.css"
+
+export const generateMetadata = genMeta(() => ({
+ titleSuffix: "Join the waitlist",
+}))
+
+/**
+ * The waitlist as a page of its own, dressed as Glen's scroll.html (2026-09-10): his tokens,
+ * type and dither background, the nav as the folded pill, and his login card in the middle
+ * with the waitlist form inside. The landing page opens the same card as an overlay (his
+ * way, WaitlistOverlay); this route stays for direct links and for the day a real sign-up
+ * moves in here (Zhengqi 2026-09-10).
+ */
+const SignUpPage = () => (
+
+)
+
+export default SignUpPage
diff --git a/src/components/AIModal/InitialPanel.tsx b/src/components/AIModal/InitialPanel.tsx
index d16eebc11..7f89c77c7 100644
--- a/src/components/AIModal/InitialPanel.tsx
+++ b/src/components/AIModal/InitialPanel.tsx
@@ -7,7 +7,7 @@ import { Stack, Typography } from "@mui/material"
import ScrollyCool from "@/assets/images/common/scrolly-cool.png"
import EnterSvg from "@/assets/svgs/header/enter.svg"
-import { AI_QUESTION_LIST } from "@/constants"
+import { AI_QUESTION_LIST } from "@/constants/ai-assistant"
import useGlobalStore from "@/stores/globalStore"
const InitialPanel = props => {
diff --git a/src/components/AIModal/actions.ts b/src/components/AIModal/actions.ts
index 05f0ef315..c5616cd82 100644
--- a/src/components/AIModal/actions.ts
+++ b/src/components/AIModal/actions.ts
@@ -2,7 +2,7 @@
import OpenAI from "openai"
-import { AI_PROMPT } from "@/constants"
+import { AI_PROMPT } from "@/constants/ai-assistant"
const openai = new OpenAI({
apiKey: process.env.AI_KEY as string,
diff --git a/src/components/AIModal/index.tsx b/src/components/AIModal/index.tsx
index 4b3a27e5b..8f7c90dfb 100644
--- a/src/components/AIModal/index.tsx
+++ b/src/components/AIModal/index.tsx
@@ -11,7 +11,7 @@ import AIBot from "@/assets/images/common/ai-bot.png"
import CloseSvg from "@/assets/svgs/header/close.svg"
import useCheckViewport from "@/hooks/useCheckViewport"
import useGlobalStore from "@/stores/globalStore"
-import { lockBodyScroll } from "@/utils"
+import { lockBodyScroll } from "@/utils/dom"
import AIInput from "./AIInput"
import FeedbackAlert from "./FeedbackAlert"
diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx
index d71d16751..0b5d9bde3 100644
--- a/src/components/Footer/index.tsx
+++ b/src/components/Footer/index.tsx
@@ -3,7 +3,8 @@
import { usePathname } from "next/navigation"
import useHideFooter from "@/hooks/useHideFooter"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
+import { hasLegacyChrome } from "@/utils/route"
import PureFooter from "./PureFooter"
import Support from "./Support"
@@ -12,8 +13,8 @@ const Footer = () => {
const { hideSupport } = useHideFooter()
const pathname = usePathname()
- // the redesigned landing page and its legal pages render their own footer
- if (isSepolia || ["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy"].includes(pathname)) {
+ // only the handful of remaining legacy routes still get this footer
+ if (isSepolia || !hasLegacyChrome(pathname)) {
return null
}
return (
diff --git a/src/components/Header/announcement.tsx b/src/components/Header/announcement.tsx
index b3629beab..d1bd825f7 100644
--- a/src/components/Header/announcement.tsx
+++ b/src/components/Header/announcement.tsx
@@ -4,7 +4,7 @@ import Marquee from "react-fast-marquee"
import { Box } from "@mui/material"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
const Announcement = () => {
const displayAnnouncement = true
diff --git a/src/components/Header/data.ts b/src/components/Header/data.ts
index c5807784d..9cecfdbce 100644
--- a/src/components/Header/data.ts
+++ b/src/components/Header/data.ts
@@ -1,5 +1,5 @@
import { BRIDGE_URL, LEVEL_UP_URL, SCROLL_OPEN_URL, SESSIONS_URL } from "@/constants/link"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
interface MenuItem {
rootKey: string
diff --git a/src/components/Header/desktop_header.tsx b/src/components/Header/desktop_header.tsx
index 0392b13cb..b705aea8a 100644
--- a/src/components/Header/desktop_header.tsx
+++ b/src/components/Header/desktop_header.tsx
@@ -5,13 +5,10 @@ import { Box, Container, Fade, Paper, Popper, Stack } from "@mui/material"
import ScrollLink from "@/components/Link"
import Logo from "@/components/ScrollLogo"
-import WalletToolkit from "@/components/WalletToolkit"
import useCheckViewport from "@/hooks/useCheckViewport"
-import useShowWalletConnector from "@/hooks/useShowWalletToolkit"
-import { isSepolia } from "@/utils"
+import { isSepolia } from "@/utils/common"
import AskAI from "./AskAI"
-import GasPriceViewer from "./GasPriceViewer"
import MenuItem from "./MenuItem"
import NavbarItem from "./NavbarItem"
import { navigations } from "./data"
@@ -26,8 +23,6 @@ const DesktopHeader = ({ currentMenu }) => {
const [hoveringNavbarItemKey, setHoveringNavbarItemKey] = useState("")
- const showWalletConnector = useShowWalletConnector()
-
const [anchorEl, setAnchorEl] = useState
(null)
const handleMouseEnter = (e, key) => {
@@ -167,8 +162,6 @@ const DesktopHeader = ({ currentMenu }) => {
))}
- {!isSepolia && }
- {showWalletConnector && }
{!isSepolia && }
diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx
index f3c96564d..6591dffa7 100644
--- a/src/components/Header/index.tsx
+++ b/src/components/Header/index.tsx
@@ -7,6 +7,7 @@ import { AppBar, Slide } from "@mui/material"
import useScrollTrigger from "@mui/material/useScrollTrigger"
import useCheckViewport from "@/hooks/useCheckViewport"
+import { hasLegacyChrome } from "@/utils/route"
// import Announcement from "./announcement"
import { navigations } from "./data"
@@ -56,8 +57,8 @@ export default function Header() {
return result
}
- // the redesigned landing page and its legal pages render their own pill nav
- if (["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy"].includes(pathname)) {
+ // only the handful of remaining legacy routes still get this nav
+ if (!hasLegacyChrome(pathname)) {
return null
}
diff --git a/src/components/Header/mobile_header.tsx b/src/components/Header/mobile_header.tsx
index 1afa38936..b53467252 100644
--- a/src/components/Header/mobile_header.tsx
+++ b/src/components/Header/mobile_header.tsx
@@ -5,13 +5,9 @@ import { Box, Collapse, List, Stack } from "@mui/material"
import { styled } from "@mui/system"
import Link from "@/components/Link"
-import WalletToolkit from "@/components/WalletToolkit"
-import useShowWalletConnector from "@/hooks/useShowWalletToolkit"
-import { isSepolia } from "@/utils"
import Logo from "../ScrollLogo"
import MenuItem from "./MenuItem"
-import MobileGasPriceViewer from "./MobileGasPriceViewer"
import MobileNavbarItem from "./MobileNavBarItem"
import { navigations } from "./data"
import useCheckCustomNavBarBg from "./useCheckCustomNavBarBg"
@@ -27,8 +23,6 @@ const Bar = styled("div", { shouldForwardProp: prop => prop !== "dark" })((
const MobileHeader = ({ currentMenu }) => {
useCheckCustomNavBarBg()
- const showWalletConnector = useShowWalletConnector()
-
const dark = useCheckTheme()
const [open, setOpen] = useState(false)
const [activeCollapse, setActiveCollapse] = useState("")
@@ -142,8 +136,6 @@ const MobileHeader = ({ currentMenu }) => {
- {showWalletConnector && }
-
{
overflowY: "auto",
}}
>
-
- {renderList()}
- {!isSepolia && }
-
+ {renderList()}
)}
diff --git a/src/components/ScrollToTop/index.tsx b/src/components/ScrollToTop/index.tsx
index a5e783313..fe16f995b 100644
--- a/src/components/ScrollToTop/index.tsx
+++ b/src/components/ScrollToTop/index.tsx
@@ -17,12 +17,13 @@ const ScrollToTop: React.FC = () => {
const pathname = usePathname()
const [visible, setVisible] = useState(false)
+ // the redesigned landing pages use a minimal circle-arrow button instead of the orange fab
+ const isCompassRoute = ["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy"].includes(pathname)
+
const checkScrollPosition = () => {
- if (window.pageYOffset > 300) {
- setVisible(true)
- } else {
- setVisible(false)
- }
+ // Glen's scroll.html (2026-09-10) shows its back-to-top once 80% of a screen has gone by
+ const threshold = isCompassRoute ? window.innerHeight * 0.8 : 300
+ setVisible(window.scrollY > threshold)
}
useEffect(() => {
@@ -39,28 +40,40 @@ const ScrollToTop: React.FC = () => {
})
}
- // the redesigned landing pages use a minimal circle-arrow button instead of the orange fab
- const isCompassRoute = ["/", "/privacy-policy", "/terms-of-service", "/app-privacy-policy"].includes(pathname)
-
- if (!visible) {
- return null
- }
-
if (isCompassRoute) {
+ // Glen's back-to-top (scroll.html, 2026-09-10): a 44px frosted disc at the corner that
+ // fades and rises in once 80% of a screen has gone by, instead of popping in and out
return (
-
-
+
+
)
}
+ if (!visible) {
+ return null
+ }
+
return (
(factory: () => Promise, deps: DependencyList | undefined): T | undefined {
const [res, setRes] = useState()
diff --git a/src/hooks/useMatch.ts b/src/hooks/useMatch.ts
index fc1261800..f008987c6 100644
--- a/src/hooks/useMatch.ts
+++ b/src/hooks/useMatch.ts
@@ -1,6 +1,6 @@
import { usePathname } from "next/navigation"
-import { checkMatchPath } from "@/utils"
+import { checkMatchPath } from "@/utils/route"
const useMatch = pathReg => {
const pathname = usePathname()
diff --git a/src/stores/utils.ts b/src/stores/utils.ts
index c40d44ff4..5f5d4d67f 100644
--- a/src/stores/utils.ts
+++ b/src/stores/utils.ts
@@ -4,8 +4,8 @@ import { readItem } from "squirrel-gill/lib/storage"
import { fetchClaimableTxListUrl, fetchTxListUrl, fetchWithdrawalListUrl } from "@/apis/bridge"
import { BLOCK_NUMBERS } from "@/constants/storageKey"
import { TX_TYPE } from "@/constants/transaction"
-import { sentryDebug } from "@/utils"
import { scrollRequest } from "@/utils/request"
+import { sentryDebug } from "@/utils/sentry"
export interface FrontendTxDB {
[key: string]: Transaction[]
diff --git a/src/utils/common.ts b/src/utils/common.ts
index 40153ced7..51c7a7ae2 100644
--- a/src/utils/common.ts
+++ b/src/utils/common.ts
@@ -1,4 +1,3 @@
-import { isHexString } from "ethers"
import find from "lodash/find"
import { DependencyList } from "react"
@@ -87,7 +86,7 @@ export const formatAmount = (value: number | string): string => {
export function isValidTransactionHash(txHash: string): boolean {
// A valid transaction hash is a hex string of length 66 characters (including the '0x' prefix)
const isValidLength = txHash.length === 66
- return isValidLength && isHexString(txHash)
+ return isValidLength && /^0x[0-9a-fA-F]*$/.test(txHash)
}
export const testAsyncFunc = value => {
diff --git a/src/utils/route.ts b/src/utils/route.ts
index 58937d5b4..e90208476 100644
--- a/src/utils/route.ts
+++ b/src/utils/route.ts
@@ -113,3 +113,13 @@ export function genMeta(fn: MetaGeneratorFn = defaultGenMetaFn) {
return merged
}
}
+
+/**
+ * Routes that still render the old MUI Header/Footer. Everything else — the redesigned
+ * landing page, its legal pages, and any unmatched path (i.e. every 404) — renders its
+ * own chrome. This is deliberately a show-list: an allowlist of "hide it here" pages can
+ * never match a 404, so the old template used to leak onto them.
+ */
+const LEGACY_CHROME_ROUTES = ["/sessions-restricted", "/sessions-terms-of-use", "/terms-and-conditions"]
+
+export const hasLegacyChrome = (pathname: string) => LEGACY_CHROME_ROUTES.includes(pathname) || pathname.startsWith("/archive/")