diff --git a/README.md b/README.md index b02db8a..a2f457c 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,48 @@ -# vinext-starter +# RED website -A clean full-stack starter running on -[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and -Drizzle support. +Marketing site for [RED](https://github.com/codersauce/red), the modal terminal +editor for the agent era. Built on [vinext](https://github.com/cloudflare/vinext) +(Next-compatible app router on Vite) and deployed as a Cloudflare Worker. ## Prerequisites - Node.js `>=22.13.0` -## Quick Start +## Development ```bash npm install -npm run dev -npm run build +npm run dev # local dev server +npm run build # production build (dist/) +npm test # installer drift check + build + SSR assertions +npm run lint ``` -This starter does not use `wrangler.jsonc`. +## Layout -## Included Shape +- `app/` — the site (single landing page, layout, global styles, 404) +- `public/` — static assets: editor captures, OG image, favicon, and the + installers (`install.sh`, `install.ps1`, `installers.json`) +- `worker/index.ts` — Cloudflare Worker entry (image optimization + app router) +- `scripts/sync-installers.mjs` — syncs installers from `codersauce/red` +- `tests/rendered-html.test.mjs` — asserts on real SSR output of the built worker -- edit site code under `app/` -- `.openai/hosting.json` declares optional Sites D1 and R2 bindings -- `vite.config.ts` simulates declared bindings for local development -- `db/schema.ts` starts intentionally empty -- `examples/d1/` contains an optional D1 example surface -- `drizzle.config.ts` supports local migration generation when needed +## Installer sync -## Workspace Auth Headers +The install scripts served at `/install.sh` and `/install.ps1` are vendored +from the [`codersauce/red`](https://github.com/codersauce/red) repository and +pinned to a commit in `public/installers.json`: -OpenAI workspace sites can read the current user's email from -`oai-authenticated-user-email`. - -SIWC-authenticated workspace sites may also receive -`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty -`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by -`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. - -Treat the full name as optional and fall back to email when it is absent: - -```tsx -import { headers } from "next/headers"; - -export default async function Home() { - const requestHeaders = await headers(); - const email = requestHeaders.get("oai-authenticated-user-email"); - const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); - const fullName = - encodedFullName && - requestHeaders.get("oai-authenticated-user-full-name-encoding") === - "percent-encoded-utf-8" - ? decodeURIComponent(encodedFullName) - : null; - - const displayName = fullName ?? email; - // ... -} +```bash +npm run sync:installers -- --ref # update to a new commit +npm run check:installers # CI drift guard (part of npm test) ``` -## Optional Dispatch-Owned ChatGPT Sign-In - -Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs -optional or required ChatGPT sign-in: - -- Use `getChatGPTUser()` for optional signed-in UI. -- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send - anonymous visitors through Sign in with ChatGPT. -- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for - browser links or actions. -- Pass a same-origin relative `returnTo` path for the destination after sign-in - or sign-out. The helper validates and safely encodes it. -- Mark protected pages with `export const dynamic = "force-dynamic"` because - they depend on per-request identity headers. - -Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the -OAuth cookies, and identity header injection. Do not implement app routes for -those reserved paths. Routes that do not import and call the helper remain -anonymous-compatible. - -SIWC establishes identity only; it does not prove workspace membership. Use the -Sites hosting platform's access policy controls for workspace-wide restrictions, -or enforce explicit server-side membership or allowlist checks. - -Use SIWC for account pages, user-specific dashboards, saved records, and write -actions tied to the current ChatGPT user. Leave public content anonymous. - -## Useful Commands - -- `npm run dev`: start local development -- `npm run build`: verify the vinext build output -- `npm test`: build the starter and verify its rendered loading skeleton -- `npm run db:generate`: generate Drizzle migrations after schema changes +The sync also regenerates `app/installers.generated.ts`, which feeds the +release version shown in the hero. -## Learn More +## Deployment -- [vinext Documentation](https://github.com/cloudflare/vinext) -- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) +Hosting is driven by `.openai/hosting.json`; `npm run build` packages the site +metadata into `dist/.openai/` via `build/sites-vite-plugin.ts`. There is no +`wrangler.jsonc` — the deploy config is generated into `dist/server/`. diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts deleted file mode 100644 index 8d1fb35..0000000 --- a/app/chatgpt-auth.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -export type ChatGPTUser = { - displayName: string; - email: string; - fullName: string | null; -}; - -const USER_EMAIL_HEADER = "oai-authenticated-user-email"; -const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; -const USER_FULL_NAME_ENCODING_HEADER = - "oai-authenticated-user-full-name-encoding"; -const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; -const SIGN_IN_PATH = "/signin-with-chatgpt"; -const SIGN_OUT_PATH = "/signout-with-chatgpt"; -const CALLBACK_PATH = "/callback"; - -export async function getChatGPTUser(): Promise { - const requestHeaders = await headers(); - const email = requestHeaders.get(USER_EMAIL_HEADER); - if (!email) return null; - - const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); - const fullName = - encodedFullName && - requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 - ? safeDecodeURIComponent(encodedFullName) - : null; - - return { - displayName: fullName ?? email, - email, - fullName, - }; -} - -export async function requireChatGPTUser( - returnTo: string, -): Promise { - const user = await getChatGPTUser(); - if (user) return user; - - redirect(chatGPTSignInPath(returnTo)); -} - -export function chatGPTSignInPath(returnTo: string): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -export function chatGPTSignOutPath(returnTo = "/"): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -function safeRelativeReturnPath(value: string): string { - if (!value.startsWith("/") || value.startsWith("//")) return "/"; - - let url: URL; - try { - url = new URL(value, "https://app.local"); - } catch { - return "/"; - } - if (url.origin !== "https://app.local") return "/"; - if (isReservedAuthPath(url.pathname)) return "/"; - - return `${url.pathname}${url.search}${url.hash}`; -} - -function isReservedAuthPath(pathname: string): boolean { - return ( - pathname === SIGN_IN_PATH || - pathname === SIGN_OUT_PATH || - pathname === CALLBACK_PATH - ); -} - -function safeDecodeURIComponent(value: string): string | null { - try { - return decodeURIComponent(value); - } catch { - return null; - } -} diff --git a/app/components/InstallPicker.tsx b/app/components/InstallPicker.tsx new file mode 100644 index 0000000..6e60371 --- /dev/null +++ b/app/components/InstallPicker.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { installMethods, type InstallMethod } from "../install-methods"; + +export default function InstallPicker({ wide = false }: { wide?: boolean }) { + const [installMethod, setInstallMethod] = useState("homebrew"); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const platform = ( + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + "" + ).toLowerCase(); + const detected = platform.includes("win") ? "windows" : platform.includes("linux") ? "unix" : null; + // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR must render the Homebrew default; the visitor's OS is only knowable after hydration + if (detected) setInstallMethod(detected); + }, []); + + const active = installMethods[installMethod]; + + async function copyInstall() { + try { + await navigator.clipboard.writeText(active.command); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + } + + return <> +
+ {(Object.keys(installMethods) as InstallMethod[]).map((method) => )} +
+ + {copied ? "Install command copied to clipboard" : ""} + ; +} diff --git a/app/components/PreviewTabs.tsx b/app/components/PreviewTabs.tsx new file mode 100644 index 0000000..f20e143 --- /dev/null +++ b/app/components/PreviewTabs.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState, type KeyboardEvent } from "react"; +import Image from "next/image"; + +const previewOrder = ["edit", "find", "commands", "agent", "git", "splash"] as const; +type Preview = (typeof previewOrder)[number]; + +const previews: Record = { + edit: { + label: "Edit", + title: "Rendering pipeline", + image: "/ghostty-code.jpg", + alt: "Red editing its Rust rendering pipeline with the project tree open", + note: "src/editor/rendering.rs · Rust · tree-sitter", + }, + find: { + label: "Files", + title: "Find source files", + image: "/ghostty-picker-demo.jpg", + alt: "Red file picker filtering its own rendering and buffer source files", + note: "Ctrl-p · fuzzy files · live preview", + }, + commands: { + label: "Commands", + title: "Discover Git actions", + image: "/ghostty-commands-demo.jpg", + alt: "Red command palette showing Git hunk navigation, stage, reset, and unstage actions", + note: "Space ? · 74 commands · shortcuts included", + }, + agent: { + label: "Agent", + title: "Ask with context", + image: "/ghostty-agent.jpg", + alt: "Red agent prompt asking about tree-sitter injections over the editor highlighter source", + note: "Space A · source-aware prompt", + }, + git: { + label: "Git review", + title: "Review a real diff", + image: "/ghostty-git-workspace.jpg", + alt: "Red Git workspace reviewing an unstaged change to its rendering pipeline", + note: "Space G · unstaged rendering diff", + }, + splash: { + label: "Welcome", + title: "Start from blank", + image: "/ghostty-splash.jpg", + alt: "Red blank-state splash screen showing its logo, startup shortcuts, and agent safety message", + note: "empty buffer · branded splash", + }, +}; + +export default function PreviewTabs() { + const [preview, setPreview] = useState("commands"); + + function handlePreviewKey(event: KeyboardEvent, index: number) { + let next = index; + if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % previewOrder.length; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + previewOrder.length) % previewOrder.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = previewOrder.length - 1; + else return; + + event.preventDefault(); + setPreview(previewOrder[next]); + document.getElementById(`preview-tab-${previewOrder[next]}`)?.focus(); + } + + const active = previews[preview]; + + return
+
+
{active.title}
+
+ {previewOrder.map((key, index) => )} +
+ real editor · Ghostty +
+
+ {active.alt} +
Red source capture{active.note}
+
+
; +} diff --git a/app/globals.css b/app/globals.css index 10db0e7..28b8bc3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -23,6 +23,7 @@ button, a { font: inherit; } button { color: inherit; } a { color: inherit; text-decoration: none; } .shell { width: min(1200px, calc(100% - 64px)); margin-inline: auto; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .nav { display: flex; height: 88px; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } .brand { display: inline-flex; align-items: baseline; gap: 4px; } @@ -33,17 +34,22 @@ a { color: inherit; text-decoration: none; } .nav-github { display: flex; align-items: center; gap: 12px; color: #9a9aa4; font: 500 12px var(--font-mono), monospace; } .nav-github span, .release span, .start-links span, .footer > a:last-child span { color: var(--red); } -.hero { padding: 68px 0 54px; text-align: center; } +.hero { overflow-x: clip; padding: 64px 0 78px; } +.hero-grid { display: grid; align-items: center; grid-template-columns: .95fr 1.05fr; gap: 64px; } +.hero-copy { min-width: 0; } +.hero-copy .install { width: 100%; } .eyebrow { display: inline-flex; align-items: center; gap: 10px; color: var(--dim); font: 550 11px var(--font-mono), monospace; letter-spacing: .12em; text-transform: uppercase; } .pulse { width: 7px; height: 7px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px #e5484d16; } -.wordmark { width: max-content; margin: 42px auto 33px; color: #b9b9c3; font: 400 clamp(22px, 2.4vw, 36px)/1.18 var(--font-mono), monospace; text-align: left; white-space: pre; } -.wordmark span { color: var(--red-2); } .hero h1, .trust h2, .start h2 { margin: 0; color: var(--bright); font-weight: 590; letter-spacing: -.075em; line-height: .96; } -.hero h1 { font-size: clamp(48px, 5.4vw, 82px); } +.hero h1 { margin-top: 27px; font-size: clamp(40px, 4.2vw, 60px); } .trust h2, .start h2 { font-size: clamp(54px, 7vw, 104px); } .hero h1 em, .trust h2 em, .start h2 em { color: var(--red); font-style: normal; } -.lead { max-width: 680px; margin: 29px auto 28px; color: #9898a2; font-size: 16px; line-height: 1.75; } -.hero-actions { display: flex; flex-direction: column; align-items: center; gap: 18px; } +.lead { max-width: 560px; margin: 26px 0 30px; color: #9898a2; font-size: 16px; line-height: 1.75; } +.hero-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 18px; } +.hero-shot { --overhang: 48px; --fade: 200px; --bleed: calc((100vw - min(1200px, 100vw - 64px)) / 2 + var(--overhang)); display: flex; height: clamp(420px, 56vh, 560px); flex-direction: column; overflow: hidden; margin-right: calc(var(--bleed) * -1); border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 34px 100px #00000075; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - var(--bleed) - var(--fade)), transparent calc(100% - var(--bleed))); mask-image: linear-gradient(to right, #000 calc(100% - var(--bleed) - var(--fade)), transparent calc(100% - var(--bleed))); } +.hero-shot-bar { display: flex; min-height: 42px; flex: none; align-items: center; gap: 10px; padding: 0 18px; border-bottom: 1px solid var(--line); background: var(--panel-2); color: #a1a1ab; font: 550 11px var(--font-mono), monospace; white-space: nowrap; } +.hero-shot-note { overflow: hidden; margin-left: auto; color: #6b6b74; font-weight: 500; text-overflow: ellipsis; } +.hero-shot img { display: block; width: 100%; min-height: 0; flex: 1; object-fit: cover; object-position: left top; } .install-selector { display: inline-flex; gap: 3px; padding: 4px; border: 1px solid #2d2d35; border-radius: 7px; background: #131318; } .install-selector button { min-height: 31px; cursor: pointer; padding: 6px 13px; border: 0; border-radius: 4px; background: transparent; color: #73737d; font: 560 10px var(--font-mono), monospace; white-space: nowrap; } .install-selector button:hover { color: #b6b6be; } @@ -56,7 +62,7 @@ a { color: inherit; text-decoration: none; } .install .copy { min-width: 58px; flex: none; padding: 8px 9px; border-left: 1px solid #4a3236; color: #8f7d81; font: 600 10px var(--font-mono), monospace; letter-spacing: .1em; text-transform: uppercase; } .release { color: #858590; font: 500 12px var(--font-mono), monospace; } .release span { margin-left: 8px; } -.platforms { display: flex; margin: 30px 0 0; align-items: center; justify-content: center; gap: 17px; color: #585863; font: 550 10px var(--font-mono), monospace; letter-spacing: .11em; text-transform: uppercase; } +.platforms { display: flex; margin: 30px 0 0; align-items: center; justify-content: flex-start; gap: 17px; color: #585863; font: 550 10px var(--font-mono), monospace; letter-spacing: .11em; text-transform: uppercase; } .platforms span { width: 2px; height: 2px; border-radius: 50%; background: #585863; } .preview-wrap { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 34px 100px #00000075; } @@ -139,11 +145,24 @@ a { color: inherit; text-decoration: none; } .footer > a:last-child { justify-self: end; color: #707079; font: 500 10px var(--font-mono), monospace; } .footer > a:last-child span { margin-left: 5px; } +.notfound { display: flex; min-height: 82vh; flex-direction: column; align-items: center; justify-content: center; padding: 80px 0; text-align: center; } +.notfound h1 { margin: 26px 0 0; color: var(--bright); font-size: clamp(38px, 4.6vw, 68px); font-weight: 590; letter-spacing: -.075em; line-height: .98; } +.notfound h1 em { color: var(--red); font-style: normal; } +.notfound-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 26px; color: #777780; font: 500 12px var(--font-mono), monospace; } +.notfound-links a:hover { color: var(--bright); } +.notfound-links span { margin-left: 7px; color: var(--red); } + @media (max-width: 900px) { .shell { width: min(100% - 34px, 720px); } - .nav-links { display: none; } - .hero { padding-top: 48px; } - .hero h1 { font-size: 61px; } + .nav-links { gap: 20px; font-size: 12px; } + .hero { padding: 48px 0 60px; } + .hero-grid { grid-template-columns: 1fr; gap: 40px; } + .hero-copy { text-align: center; } + .hero h1 { font-size: 52px; } + .lead { margin-inline: auto; } + .hero-actions { align-items: center; } + .platforms { justify-content: center; } + .hero-shot { --overhang: 28px; --fade: 140px; --bleed: calc((100vw - min(100vw - 34px, 720px)) / 2 + var(--overhang)); height: 300px; } .preview-head { grid-template-columns: 1fr auto; gap: 11px 14px; padding: 13px 14px; } .preview-tabs { width: 100%; grid-column: 1 / -1; justify-content: flex-start; } .preview-tabs button { flex: 1 0 auto; } @@ -163,10 +182,14 @@ a { color: inherit; text-decoration: none; } @media (max-width: 600px) { .shell { width: calc(100% - 22px); } - .nav { height: 68px; padding: 0 8px; } - .hero { padding: 39px 8px 37px; } - .hero h1, .start h2 { font-size: 47px; } - .wordmark { margin: 24px auto 17px; transform: scale(.84); } + .nav { height: 68px; gap: 12px; padding: 0 8px; } + .nav-links { min-width: 0; gap: 13px; overflow-x: auto; font-size: 11px; white-space: nowrap; scrollbar-width: none; } + .hero { padding: 36px 0 44px; } + .hero h1 { font-size: 41px; } + .start h2 { font-size: 47px; } + .hero-grid { gap: 30px; } + .hero-shot { --overhang: 24px; --fade: 90px; --bleed: calc(11px + var(--overhang)); height: 272px; } + .hero-shot-bar { min-height: 36px; gap: 8px; padding: 0 12px; font-size: 9px; } .lead { font-size: 14px; line-height: 1.7; } .install { height: 54px; gap: 9px; padding-left: 14px; } .install code { overflow: hidden; font-size: 10px; text-overflow: ellipsis; } diff --git a/app/install-methods.ts b/app/install-methods.ts new file mode 100644 index 0000000..8e93d28 --- /dev/null +++ b/app/install-methods.ts @@ -0,0 +1,19 @@ +export const installMethods = { + homebrew: { + label: "Homebrew", + command: "brew install codersauce/tap/red", + aria: "Copy Homebrew install command", + }, + unix: { + label: "macOS + Linux", + command: "curl --proto '=https' --tlsv1.2 -fsSL https://getred.dev/install.sh | sh", + aria: "Copy macOS and Linux install command", + }, + windows: { + label: "Windows", + command: "irm https://getred.dev/install.ps1 | iex", + aria: "Copy Windows PowerShell install command", + }, +} as const; + +export type InstallMethod = keyof typeof installMethods; diff --git a/app/layout.tsx b/app/layout.tsx index f13c2f0..7fbb8fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,14 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; +import { releaseVersion } from "./installers.generated"; import "./globals.css"; const sans = Geist({ variable: "--font-sans", subsets: ["latin"] }); const mono = Geist_Mono({ variable: "--font-mono", subsets: ["latin"] }); +const canonicalOrigin = "https://getred.dev"; + export async function generateMetadata(): Promise { const requestHeaders = await headers(); const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:4173"; @@ -13,14 +16,34 @@ export async function generateMetadata(): Promise { const socialImage = `${protocol}://${host}/og.png`; return { + metadataBase: new URL(canonicalOrigin), title: "red — the modal editor for the agent era", description: "Fast, familiar modal editing with modern code intelligence and reviewable agent proposals. One self-contained Rust binary for macOS, Linux, and Windows.", + alternates: { canonical: "/" }, icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" }, - openGraph: { title: "red — the modal editor for the agent era", description: "Every agent edit is a proposal. Nothing touches your files until you accept it.", type: "website", images: [{ url: socialImage, width: 1731, height: 908, alt: "Red editor and agent proposal preview" }] }, + other: { "theme-color": "#101014" }, + openGraph: { title: "red — the modal editor for the agent era", description: "Every agent edit is a proposal. Nothing touches your files until you accept it.", type: "website", images: [{ url: socialImage, width: 1200, height: 630, alt: "Red editor and agent proposal preview" }] }, twitter: { card: "summary_large_image", title: "red — the modal editor for the agent era", description: "Fast modal editing. Modern code intelligence. Reviewable agent proposals.", images: [socialImage] }, }; } +const structuredData = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "Red", + applicationCategory: "DeveloperApplication", + operatingSystem: "macOS, Linux, Windows", + softwareVersion: releaseVersion.replace(/^v/, ""), + url: canonicalOrigin, + downloadUrl: "https://github.com/codersauce/red/releases/latest", + license: "https://github.com/codersauce/red/blob/main/LICENSE", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + description: "Modal terminal editor with tree-sitter highlighting, language servers, and reviewable agent proposals. Built in Rust.", +}; + export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return {children}; + return + {children} +