diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aaf28b1b..896b2b10 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -506,6 +506,12 @@ The `.env.example` files are documentation and drift. `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `CORS_ORIGINS`. +`CORS_ORIGINS` must list the **marketing site's** origin in staging and +production, not just the app's: `site/` is a static build on another host and its +founding-price capture POSTs to `/api/waitlist` here (`WEB_URL` in +`site/src/config.ts`). Omit it and every submit fails in the browser as a network +error, with nothing in the web service's logs to say why. + **`web/.env` — defaulted, safe to omit:** `NODE_ENV` (`development`), `EMAIL_FROM`, `PORT` (8787). `BETTER_AUTH_URL` auto-derives to `http://localhost:${PORT}` in development and test; it is required only in diff --git a/README.md b/README.md index a20df567..fc760b2b 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,50 @@ # Antgrid -**Your agent says it's done. Make it prove it.** +**Your machines. Your agents. One control plane.** -Evidence-gated supervision for the CLI coding agents you already run — on your own -hardware, end-to-end encrypted. +Every CLI coding agent you run, on every machine you own, in one place — end-to-end +encrypted, on hardware you control. +[![CI](https://github.com/antgrid-ai/antgrid/actions/workflows/ci.yml/badge.svg)](https://github.com/antgrid-ai/antgrid/actions/workflows/ci.yml) [![License: Elastic License 2.0](https://img.shields.io/badge/license-Elastic%20License%202.0-4b5563?style=flat)](LICENSE.md) [![Latest release](https://img.shields.io/github/v/release/antgrid-ai/antgrid?style=flat&logo=github&label=release)](https://github.com/antgrid-ai/antgrid/releases/latest) [![Stars](https://img.shields.io/github/stars/antgrid-ai/antgrid?style=flat&logo=github)](https://github.com/antgrid-ai/antgrid/stargazers) Antgrid runs the coding agents you already use — Claude Code, Codex, Cursor and others — -in real terminals on your own hardware. Arm its supervisor on a session and it watches the -agent's attention signals, answers what it can, escalates what it can't, and calls a task -done only on concrete evidence — test output, exit codes, a diff — rather than the agent's -own report. - -Around each agent it puts the context you need to check that work yourself: multi-session -terminals, a file tree, git review with diffs, and a live browser preview. The same -workspace opens on a phone, over a relay that is end-to-end encrypted and cannot read a -byte of what passes through it. +in real terminals on your own hardware, and puts one screen over all of them: every +session on every machine you have signed in, grouped by the machine it is on. Around each +agent it puts the context you need to check the work yourself — multi-session terminals, a +file tree, git review with diffs, and a live browser preview. The same workspace opens on +a phone, over a relay that is end-to-end encrypted and cannot read a byte of what passes +through it. + +Arm its supervisor on a session and it goes further: it watches the agent's attention +signals, answers what it can, escalates what it can't, and calls a task done only on +concrete evidence — test output, exit codes, a diff — rather than the agent's own report. +That part is opt-in and it is the paid tier — `CAPABILITIES` in +[`bridge/src/entitlement.ts`](bridge/src/entitlement.ts) is the whole capability gate. The +only other paid line is how many machines one account may run agents on +(`FREE_WORKER_LIMIT` in [`web/src/billing/plans.ts`](web/src/billing/plans.ts)); everything +else is free. Antgrid does not replace your agent and ships no model of its own. -> Status: pre-release, working towards v1. +> [!NOTE] +> **Pre-release, working towards v1.** +> +> **Licence** — source-available under [Elastic License 2.0](#licence): free to read, +> fork, modify and self-host, including commercially. Not OSI open source. +> +> **Contributing** — bug reports are welcome; pull requests are not open yet +> ([CONTRIBUTING.md](CONTRIBUTING.md)). ## Features -- **Supervisor.** Arm it on a session and it watches the agent's attention signals, - answers what it can, escalates what it can't, and calls a task done only on concrete - evidence — test output, exit codes — rather than the agent's own report. You can also - give it follow-up steps to carry out once the task is done; it works through them in - order and stays armed until each one is satisfied. +- **Supervisor** *(paid tier)*. Arm it on a session and it watches the agent's attention + signals, answers what it can, escalates what it can't, and calls a task done only on + concrete evidence — test output, exit codes — rather than the agent's own report. You + can also give it follow-up steps to carry out once the task is done; it works through + them in order and stays armed until each one is satisfied. - **Bring your own agent.** Claude Code, Codex, opencode, Cursor, GitHub Copilot, Antigravity, Kilo, Kimi and Mistral Vibe are wired for notifications and session naming — the current set is `AGENTS` in [`bridge/src/agents/registry.ts`](bridge/src/agents/registry.ts). @@ -83,6 +97,16 @@ feature flag. Encryption protects the transport. It does not sandbox the agent, and it cannot make an untrusted agent safe to run on your machine. +And two things the list above is not. It is not an audit: there has been no external +penetration test and no certification. And it does not empty the trust boundary — it moves +the relay out of it, not our account service. Your phone learns a machine's Ed25519 +identity from your account's device inventory, which `app.antgrid.ai` serves, so that +service is trusted to hand you the right key even though the relay never is. + +None of this needs taking on trust. The handshake specification, both implementations and +the relay itself are linked above and in this repo; [SECURITY.md](SECURITY.md) is the +reporting policy if you find something wrong with them. + ## Architecture | Component | Path | Stack | Role | diff --git a/app/lib/screens/device_cap_dialog.dart b/app/lib/screens/device_cap_dialog.dart index df429078..64f13f61 100644 --- a/app/lib/screens/device_cap_dialog.dart +++ b/app/lib/screens/device_cap_dialog.dart @@ -7,13 +7,21 @@ import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_button.dart'; import '../design/widgets/ab_confirm_dialog.dart'; +import '../design/widgets/ab_icon.dart'; import '../design/widgets/ab_icon_button.dart'; import '../design/widgets/ab_list_row.dart'; -import '../design/widgets/ab_tooltip.dart'; import '../providers/auth.dart'; import '../providers/device_provisioning.dart'; import '../providers/post_signin_provisioning.dart'; import '../services/devices_api.dart'; +import '../util/detached.dart'; +import '../util/external_url.dart'; + +/// Where the worker-cap variant sends someone who wants more machines. There is +/// no checkout to send them to during the beta, so the ask is captured on the +/// site instead; no price is named here or on the way out, because none is +/// committed to yet. +const _foundingPricingUrl = 'https://antgrid.ai/pricing'; /// Shows the device-cap remediation dialog and resolves when it is dismissed. /// Always clears [deviceCapProvider] on close so a later provisioning attempt @@ -34,8 +42,7 @@ Future showDeviceCapDialog( /// same way: revoke one of the listed devices, then retry provisioning this /// machine. Only the copy differs — `appDeviceLimit` is an abuse ceiling that /// pricing never mentions, so its variant never offers upgrading, while the -/// worker cap is the paid axis and shows an upgrade affordance (disabled until -/// checkout ships). +/// worker cap is the paid axis and points at the founding-price waitlist. class DeviceCapDialog extends ConsumerStatefulWidget { const DeviceCapDialog({super.key, required this.info}); @@ -206,20 +213,30 @@ class _DeviceCapDialogState extends ConsumerState { runSpacing: AbTokens.space8, children: [ if (_isWorker) ...[ - // Checkout is not wired yet, so the paid path is shown and - // legibly shut rather than absent. The tooltip alone would - // leave the button unexplained on mobile (no hover), hence - // the inline label beside it. + // The machine slot cannot be bought during the beta, so the + // paid path leads somewhere that works instead of standing + // there disabled. The line says why the button is a + // waitlist and not a purchase; a tooltip could not, having + // no hover on mobile. Text( - 'Coming soon', + 'More machines aren\'t on sale yet.', style: AbTokens.sansStyle( fontSize: AbTokens.fontXs, color: p.textMuted, ), ), - const AbTooltip( - message: 'Coming soon', - child: AbButton(label: 'Upgrade'), + AbButton( + label: 'Join the waitlist', + leading: AbIcon( + AbIcons.openExternal, + size: AbTokens.iconButtonGlyph, + color: p.textSecondary, + ), + onTap: () => detached( + 'DeviceCapDialog', + 'open founding-pricing waitlist', + () => openExternalUrl(context, _foundingPricingUrl), + ), ), ], AbButton( diff --git a/app/lib/screens/upgrade_screen.dart b/app/lib/screens/upgrade_screen.dart index 8cd74e61..0f7ae647 100644 --- a/app/lib/screens/upgrade_screen.dart +++ b/app/lib/screens/upgrade_screen.dart @@ -30,10 +30,13 @@ const _proYearlyFeatures = [ /// TEMP-PROMO: why the plan can't be bought, said in the CTA itself. "Coming /// soon" reads as half-built to someone who arrived from a site that told them -/// the beta is free. Same wording as web's `UNAVAILABLE_CTA_LABEL` and the -/// marketing site's `PlanCard.astro`. Carries no beta flag of its own: the -/// whole static block this belongs to is deleted when checkout opens — see the -/// TEMP-PROMO marker at the foot of this file. +/// the beta is free. This is now the only DEAD paid CTA left: web's pricing page +/// and the marketing site's plan cards capture an address in place, and +/// `device_cap_dialog.dart` — the app's own answer to the same problem — sends +/// the reader out to the site's capture rather than standing there disabled. +/// Carries no beta flag of its own: the whole static block this belongs to is +/// deleted when checkout opens — see the TEMP-PROMO marker at the foot of this +/// file. const _unavailableCtaLabel = 'Available after beta'; /// The machine count sits mid-sentence, so it has to agree with its noun — diff --git a/relay/relay-requirements.md b/relay/relay-requirements.md index c0d4a6fa..f0ee0689 100644 --- a/relay/relay-requirements.md +++ b/relay/relay-requirements.md @@ -9,7 +9,10 @@ > `pair-connected` pairing ceremony below. Both are gone: the relay now > authenticates a single signed `hello` and routing is account-derived > (`mayRoute`), with no pairing step at all. See `relay/CLAUDE.md` for the -> current protocol. +> current protocol. The offline message queue in section 4 never shipped either: +> a frame for a disconnected peer is answered `PEER_OFFLINE` and dropped, and +> nothing is buffered or written to disk — which is what `/privacy` on the site +> states, so do not implement section 4 without changing that page first. --- diff --git a/site/.env.example b/site/.env.example index 664ec636..1a83f475 100644 --- a/site/.env.example +++ b/site/.env.example @@ -1,2 +1,3 @@ PUBLIC_SITE_URL=https://antgrid.ai PUBLIC_APP_URL=https://app.antgrid.ai +PUBLIC_WEB_URL=https://app.antgrid.ai diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 425dfe07..2c9a4e72 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -81,5 +81,24 @@ export default defineConfig({ // the docs document, so only the nesting should need to change. experimental: { fonts }, vite: { plugins: [tailwindcss()] }, - integrations: [icon(), sitemap({ filter: (page) => !page.includes("/og-card") })], + integrations: [ + // simple-icons is named explicitly because astro-icon otherwise assigns an + // installed collection `["*"]` and inlines the whole pack into the build's + // virtual module — 3,700 icons and ~4.7MB of source, to draw seven brand + // marks in Compat.astro. Collections left unnamed (tabler) keep `*`. + icon({ + include: { + "simple-icons": [ + "claudecode", + "openai", + "opencode", + "cursor", + "githubcopilot", + "kimi", + "mistralai", + ], + }, + }), + sitemap({ filter: (page) => !page.includes("/og-card") }), + ], }); diff --git a/site/bun.lock b/site/bun.lock index bdddff5d..524a8c7d 100644 --- a/site/bun.lock +++ b/site/bun.lock @@ -9,6 +9,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5", @@ -129,6 +130,8 @@ "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.94", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA=="], + "@iconify-json/tabler": ["@iconify-json/tabler@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ=="], "@iconify/tools": ["@iconify/tools@4.2.0", "", { "dependencies": { "@iconify/types": "^2.0.0", "@iconify/utils": "^2.3.0", "cheerio": "^1.1.2", "domhandler": "^5.0.3", "extract-zip": "^2.0.1", "local-pkg": "^1.1.2", "pathe": "^2.0.3", "svgo": "^3.3.2", "tar": "^7.5.2" } }, "sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA=="], diff --git a/site/package.json b/site/package.json index 0518f97a..c94fe576 100644 --- a/site/package.json +++ b/site/package.json @@ -15,6 +15,7 @@ "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@iconify-json/simple-icons": "^1.2.94", "@iconify-json/tabler": "^1.2.0", "astro": "^5.0.0", "astro-icon": "^1.1.5" diff --git a/site/public/.well-known/security.txt b/site/public/.well-known/security.txt new file mode 100644 index 00000000..4e60fd12 --- /dev/null +++ b/site/public/.well-known/security.txt @@ -0,0 +1,6 @@ +Contact: mailto:contact@radhaai.com +Contact: https://github.com/antgrid-ai/antgrid/security/advisories/new +Preferred-Languages: en +Canonical: https://antgrid.ai/.well-known/security.txt +Expires: 2027-06-30T23:59:59.000Z +Policy: https://github.com/antgrid-ai/antgrid/blob/HEAD/SECURITY.md diff --git a/site/public/og/control-plane.png b/site/public/og/control-plane.png new file mode 100644 index 00000000..ebe0397a Binary files /dev/null and b/site/public/og/control-plane.png differ diff --git a/site/public/staticwebapp.config.json b/site/public/staticwebapp.config.json index 9bb43526..53dcfdc1 100644 --- a/site/public/staticwebapp.config.json +++ b/site/public/staticwebapp.config.json @@ -12,6 +12,7 @@ { "route": "/og/*", "headers": { "Cache-Control": "public, max-age=86400" } } ], "mimeTypes": { + ".txt": "text/plain", ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml" } diff --git a/site/scripts/shoot-og.mjs b/site/scripts/shoot-og.mjs index 0dccb5ea..cf846617 100644 --- a/site/scripts/shoot-og.mjs +++ b/site/scripts/shoot-og.mjs @@ -17,6 +17,6 @@ await page.evaluate(() => document.fonts.ready); // shipping: the card should show the run resolved, not caught mid-populate. await page .locator("#og") - .screenshot({ path: "public/og/one-screen.png", animations: "disabled" }); + .screenshot({ path: "public/og/control-plane.png", animations: "disabled" }); await browser.close(); -console.log("wrote public/og/one-screen.png"); +console.log("wrote public/og/control-plane.png"); diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index 38da3e49..dfc5ce15 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -11,7 +11,7 @@ import Wordmark from "./ui/Wordmark.astro";

- Run your coding agents on your machine, steer them from your phone — and nothing counts as done without evidence. + Every agent you run, on every machine you own, in one control plane — and a gate on every phase when you arm Handler.

end-to-end encrypted · zero-knowledge relay @@ -25,6 +25,7 @@ import Wordmark from "./ui/Wordmark.astro"; Download Get started Support + Security
diff --git a/site/src/components/Seo.astro b/site/src/components/Seo.astro index 16ad9b0d..b8694c1a 100644 --- a/site/src/components/Seo.astro +++ b/site/src/components/Seo.astro @@ -6,19 +6,28 @@ interface Props { ogImage?: string; ogImageAlt?: string; path?: string; + // Only ever set to keep a page OUT of the index (/404). Left undefined the tag + // is omitted entirely rather than emitted as "index, follow" — that is already + // the default, and a page that states it invites the question of which pages + // set it deliberately. + robots?: string; } // The two defaults describe the same file — a page overriding one must override // the other, or the card ships someone else's alt text. The filename tracks the // card's claim on purpose: scrapers cache og:image by URL and re-shooting in // place leaves the superseded card in previews for as long as they hold it, so a // recut that changes what the card SAYS gets a new name. Keep it in step with -// scripts/shoot-og.mjs, which writes it. +// scripts/shoot-og.mjs, which writes it. The superseded PNG stays in public/og +// even though nothing references it — that is the point of the scheme, not +// leftovers: a scraper still holding the old URL re-fetches it, and deleting the +// file turns every one of those cached previews into a broken image. const { title, description, - ogImage = "/og/one-screen.png", - ogImageAlt = "Every agent. Every machine. One screen. Below, antgrid checking an agent's work against test output while you slept.", + ogImage = "/og/control-plane.png", + ogImageAlt = "Your machines. Your agents. One control plane. Below, antgrid checking an agent's work against test output while you slept.", path = "/", + robots, } = Astro.props; const canonical = new URL(path, SITE_URL).href; const ogUrl = new URL(ogImage, SITE_URL).href; @@ -27,6 +36,7 @@ const ogUrl = new URL(ogImage, SITE_URL).href; {title} +{robots && } {/* Keep in lockstep with --color-page in styles/global.css. */} diff --git a/site/src/components/pricing/PlanCard.astro b/site/src/components/pricing/PlanCard.astro index 1802ab97..acca7422 100644 --- a/site/src/components/pricing/PlanCard.astro +++ b/site/src/components/pricing/PlanCard.astro @@ -1,11 +1,11 @@ --- import { Icon } from "astro-icon/components"; import Button from "../ui/Button.astro"; +import WaitlistCta from "./WaitlistCta.astro"; import { links } from "../../config"; -import { BETA_FREE, type PlanCardData } from "../../data/pricing"; -interface Props { plan: PlanCardData } -const { plan } = Astro.props; -const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) * 100) : null; +import { type PlanCardData, type WaitlistSource } from "../../data/pricing"; +interface Props { plan: PlanCardData; waitlistSource?: WaitlistSource } +const { plan, waitlistSource = "pricing" } = Astro.props; ---
@@ -17,9 +17,15 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd) reach for the type class, which tied a revenue test to a font size. */} ${plan.priceUsd} {plan.unit} - {plan.listUsd && ${plan.listUsd}} - {discountPct && {discountPct}% off launch}
+ {/* A forward price, never a struck one. The higher figure has never been + charged, so rendering it as a crossed-out "was" invents a reference price + the product never had — the thing CCPA's dark-pattern rules and EU Omnibus + Art. 6a both reach. Stated as the list price at launch it is the same + contrast and a true sentence. */} + {plan.listUsd && ( +
Founding price — ${plan.listUsd} at launch
+ )}
{plan.note}
{plan.features.map((f) => ( @@ -28,7 +34,7 @@ const discountPct = plan.listUsd ? Math.round((1 - plan.priceUsd / plan.listUsd)
{plan.comingSoon ? ( - + ) : ( )} diff --git a/site/src/components/pricing/WaitlistCta.astro b/site/src/components/pricing/WaitlistCta.astro new file mode 100644 index 00000000..706dd3d7 --- /dev/null +++ b/site/src/components/pricing/WaitlistCta.astro @@ -0,0 +1,165 @@ +--- +import { links } from "../../config"; + +import type { WaitlistSource } from "../../data/pricing"; + +interface Props { + /** Which surface the address came from — sent as `source` to the API. */ + source: WaitlistSource; + /** Unique per instance: two captures can share a page (label/status ids). */ + id: string; +} +const { source, id } = Astro.props; +const inputId = `${id}-email`; +const statusId = `${id}-status`; +--- +{/* `action` is the real endpoint rather than a data-* attribute so the markup + states its own target, but the submit button ships DISABLED: without the + script a native cross-origin POST would land the reader on a raw JSON body, + and a disabled default button also blocks implicit submission from the + input. The script enables it — so scriptless readers get the noscript note + instead of a control that looks live and is not. */} +
+ +
+ + +
+ {/* Reserves TWO lines, not one: every message here is 60+ characters and wraps + at the card's mobile width, so a one-line reservation still grows the card + under the thumb that just tapped it. */} +

+ +
+ + + + diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro new file mode 100644 index 00000000..e51f2e7f --- /dev/null +++ b/site/src/components/sections/Compat.astro @@ -0,0 +1,74 @@ +--- +import { Icon } from "astro-icon/components"; +import Chip from "../ui/Chip.astro"; +import Eyebrow from "../ui/Eyebrow.astro"; + +// Keep in lockstep with bridge/src/agents/registry.ts. `handler: true` is what +// `handlerObservable` answers true for — a terminal session needs the agent's +// integration to POST /handler-event, a chat session needs a driver, and only +// these three clear either bar. +// +// They are also the only three that get signal. In this palette the accent means +// the system is doing something (see .live-cells in global.css, and the note in +// Eyebrow.astro on why section labels gave the colour up), so a row where all +// ten marks glow would promise the paid feature to six agents that cannot run +// it. The sentence below names the three in words too: colour reinforces here, +// it never carries alone. +const agents = [ + { name: "Claude Code", icon: "simple-icons:claudecode", handler: true }, + { name: "Codex", icon: "simple-icons:openai", handler: true }, + { name: "opencode", icon: "simple-icons:opencode", handler: true }, + { name: "Cursor", icon: "simple-icons:cursor" }, + { name: "GitHub Copilot", icon: "simple-icons:githubcopilot" }, + { name: "Antigravity" }, + { name: "Kilo" }, + { name: "Kimi", icon: "simple-icons:kimi" }, + { name: "Mistral Vibe", icon: "simple-icons:mistralai" }, +]; + +// Two rows rather than one that wraps: the chips are ~100px wider than the shell +// at every desktop width, so a single flex row breaks 9 + 1 and strands the +// dashed chip alone on the second line. Splitting on the distinction the row +// already encodes costs no words and fixes the orphan. +const supervised = agents.filter((a) => a.handler); +const unsupervised = agents.filter((a) => !a.handler); + +// Built from the same array the chips are, so the sentence cannot go on naming +// three while a fourth chip lights up. +const supervisedNames = supervised.map((a) => a.name); +--- +
+
+ The agent you already run + +
+
+ {supervised.map((a) => ( + + {a.icon + ? + : } + {a.name} + + ))} +
+
+ {unsupervised.map((a) => ( + + {a.icon + ? + : } + {a.name} + + ))} + any terminal agent +
+
+ +

+ Handler supervises {supervisedNames.map((name, i) => ( + <>{i === 0 ? "" : i === supervisedNames.length - 1 ? " and " : ", "}{name} + ))} today. Every other agent runs as a named session — terminal, files, git and alerts. +

+
+
diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro index bee900a2..f93ed2ef 100644 --- a/site/src/components/sections/CrossAgent.astro +++ b/site/src/components/sections/CrossAgent.astro @@ -1,13 +1,8 @@ --- import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Chip from "../ui/Chip.astro"; import UseCase from "../ui/UseCase.astro"; -// Keep in lockstep with AGENTS in bridge/src/agents/registry.ts — these are the -// agents wired for notifications and session naming. Anything else still runs (the -// "any terminal agent" chip), it just gets no integration, so it must not be named here. -const agents = ["Claude Code", "Codex", "opencode", "Cursor", "GitHub Copilot", "Kilo", "Kimi", "Mistral Vibe"]; const steps = [ { n: 1, icon: "tabler:download", title: "Install on your machine", body: "The desktop app runs your agents in real terminals and links them to your phone — Windows, macOS, Linux.", accent: false }, { n: 2, icon: "tabler:terminal-2", title: "Run any agent", body: "Start Claude Code, Codex or Cursor exactly how you do now. antgrid wraps them — no workflow change.", accent: false }, @@ -23,13 +18,9 @@ const steps = [ antgrid doesn't replace your coding agent — it's the command centre over the ones you already run. No new model, no new CLI to learn.

-
- {agents.map((a) => ( - {a} - ))} - any terminal agent -
- + {/* The roster moved to Compat.astro, directly under the hero: "does this work + with my agent" gates whether a reader keeps scrolling, so it cannot be + answered in section seven. This section keeps the promise and the setup. */} Pick the agent per task, not per tool — the command centre stays the same.

How it works

diff --git a/site/src/components/sections/Fleet.astro b/site/src/components/sections/Fleet.astro index a112c486..98935c4c 100644 --- a/site/src/components/sections/Fleet.astro +++ b/site/src/components/sections/Fleet.astro @@ -1,68 +1,24 @@ --- -import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Readout from "../ui/Readout.astro"; +import FleetScene from "../shell/FleetScene.astro"; import UseCase from "../ui/UseCase.astro"; - -type Row = { name: string; agent: string; task: string; time: string; state: "working" | "needs" | "done" }; -type Machine = { host: string; tag?: string; rows: Row[] }; -const machines: Machine[] = [ - { host: "macbook-pro", rows: [ - { name: "api", agent: "Claude Code", task: "Refactoring auth middleware…", time: "2m", state: "working" }, - { name: "web", agent: "Codex", task: "Writing checkout tests…", time: "5m", state: "working" }, - ]}, - { host: "studio-workstation", rows: [ - { name: "relay", agent: "Claude Code", task: "Needs you — which migration strategy?", time: "just now", state: "needs" }, - { name: "app", agent: "Cursor", task: "Built release bundle", time: "12m", state: "done" }, - ]}, - { host: "prod-box", tag: "cloud", rows: [ - { name: "evals", agent: "Codex", task: "Running E2E suite…", time: "1m", state: "working" }, - { name: "infra", agent: "Claude Code", task: "Tailing deploy logs…", time: "3m", state: "working" }, - ]}, -]; - -const allRows = machines.flatMap((m) => m.rows); -const totalCount = allRows.length; -const needsCount = allRows.filter((r) => r.state === "needs").length; -const workingCount = allRows.filter((r) => r.state === "working").length; -const doneCount = allRows.filter((r) => r.state === "done").length; --- {/* The band the page is widest at — this is the "one screen" claim, so the - readout gets the full shell and an inset floor to sit on. */} + window gets the full shell and an inset floor to sit on. */}
Fleet view {/* The hero carries "every agent, every machine, one screen" now, so this section takes the narrower claim it is the actual evidence for. Keep the - needs-you line in exactly one place here — heading, lede and the readout's - foot all stated it before, which read as padding around the one screenshot - that proves it. */} + needs-you line in exactly one place here — heading, lede and the frame's + own marker all stated it before, which read as padding around the one + picture that proves it. */}

The one that needs you is never below the fold.

- Your laptop, your workstation, a cloud box — every agent you're running, grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for. + Grouped by the machine it's on, sorted so the one that's blocked is never the one you have to go looking for.

- - {machines.map((m) => ( -
-
- {m.host}{m.tag && · {m.tag}} -
- {m.rows.map((r) => ( -
- {r.state === "done" - ? - : } - {r.name} - {r.agent} - {r.task} - {r.time} -
- ))} -
- ))} - {workingCount} working · {doneCount} done -
+ Glance once, answer the one that's blocked, close the phone. No tabbing through six terminals to find it.
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro index 3575fb22..1961f446 100644 --- a/site/src/components/sections/Hero.astro +++ b/site/src/components/sections/Hero.astro @@ -2,11 +2,9 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Button from "../ui/Button.astro"; -import ProofCard from "./ProofCard.astro"; +import WorkspaceScene from "../shell/WorkspaceScene.astro"; import { links } from "../../config"; import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../../data/pricing"; - -const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); --- {/* The proof loop is the argument, so it gets the stage rather than a column: full shell width under the headline, which also puts it just below the fold @@ -19,7 +17,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); stop around 1568px because past the shell the mask has faded the field out anyway, and the life belongs where the content is. */} - + {/* Geometry lives with the paint in .glow-hero, not in utilities here: the + two change together across the md breakpoint and are meaningless apart. */} +
@@ -57,8 +61,11 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); href={links.pricing} class="inline-flex items-center gap-2 rounded-full border border-signaldeep2 bg-signaldeep/40 px-3 py-1 font-mono text-marker text-signal2 transition-colors hover:border-signalbtn hover:text-signal3" > - {offerPct}% off - Launch offer — Pro ${YEARLY_OFFER_USD} per seat / year + {/* Forwards, never as a discount off a struck price — same rule + PlanCard.astro states and for the same reason: $99 has never been + charged, so "% off" would invent a reference price. */} + Founding + Pro ${YEARLY_OFFER_USD} per seat / year — ${YEARLY_LIST_USD} at launch )} @@ -70,17 +77,21 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); type, where the default wrap strands a two-word tail ("stuck since 2am.", "machine.") on its own line at some widths and not others. A hard
fixes the one width you tested and makes narrow viewports worse. */} - - Agents on your laptop, your workstation, a cloud box.{" "} + + Claude Code on your laptop, Codex on your workstation, another on a cloud box.{" "} One's been stuck since 2am. - {/* The headline claims the overview, so the tension has to sit in the kicker - above it — on its own "One screen." is a watching claim, and the product - acts. The stuck agent is what the lede's proof loop then answers. */} + {/* This deliberately takes the overview claim BACK from Fleet.astro, which + was handed it while the headline carried the evidence gate. The gate is + not entitled to a headline: Handler is opt-in and it is on Pro, so "make + it prove it" was false on every free machine until someone armed it — + the one promise a stranger is asked to believe has to be true on a bare + install. This one is. Fleet.astro keeps the PROOF; the hero takes back + the CLAIM, and the ProofCard below still shows the gate doing its job. */} - Every agent.{" "} - Every machine.{" "} - One screen. + Your machines.{" "} + Your agents.{" "} + One control plane. @@ -88,29 +99,51 @@ const offerPct = Math.round((1 - YEARLY_OFFER_USD / YEARLY_LIST_USD) * 100); rather than pinned to the shell's edges — pushed apart, the buttons read as stranded rather than as a deliberate second column. */}
- {/* Carries the proof loop, because the headline no longer does — it claims - the overview instead. Does not re-open on "your agents run on your own - machines": the kicker and headline directly above have just said that, - and the paragraph runs long on a phone as it is. */} -

- They run on your own machines — your repos, your branches, your existing subscriptions. Hand antgrid the sequence in plain text: it reads every result, refuses a "done" that arrives with no test output, exit code or diff, and moves to the next phase. -

+ {/* Two beats, and their order is the argument. The first is true on a free + install with nothing configured, which is what earns it the space next + to the headline. The second names the paid feature with a VERB — you arm + it — because a reader who takes the gate for a default will find it + missing and conclude we lied. It links rather than explains: Phases.astro + is two sections down and makes the whole case there. */} + {/* One grid child, not two paragraphs: the grid has exactly two columns and + a loose second

becomes a third item, which pushes the CTA column out + of row one and strands the buttons under the copy. */} +

+

+ Every agent you're running, on every machine you own — your repos, your branches, your existing subscriptions. Nothing rented from us, nothing to provision. +

+

+ Arm Handler when you want a phase held until the evidence is there. +

+
-

- No VPN or port forwarding · End-to-end encrypted · Desktop out now, iOS & Android coming to the App Store & Play. + {/* Two lines because there are two jobs here: the guarantees are a + dot-separated list of properties, availability is a sentence. Run + together they made a four-clause line that wrapped to three in this + 21rem column and split "iOS &" from "Android" across rows. */} +

No VPN or port forwarding · End-to-end encrypted

+

+ Desktop out now on Windows, macOS & Linux.{" "} + iOS & Android in private beta — request an invite.

-
- {/* min-w-0 for the same reason as the Worktrees card — see the comment there. */} -
- + {/* The window runs off the bottom of the hero rather than ending inside it: + the reader should feel the app continue past the fold. The fade is a + SIBLING of the window, never a child — as a child it sits under the + window's own border and leaves a bright hairline across the exact point + the page is trying to dissolve. */} +
+
+
+
+ diff --git a/site/src/components/sections/Phases.astro b/site/src/components/sections/Phases.astro index 81fbcee1..691dee27 100644 --- a/site/src/components/sections/Phases.astro +++ b/site/src/components/sections/Phases.astro @@ -1,7 +1,6 @@ --- -import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; -import Readout from "../ui/Readout.astro"; +import HandlerScene from "../shell/HandlerScene.astro"; import UseCase from "../ui/UseCase.astro"; // The phase-gated workflow (research → validate → plan → implement, a human @@ -20,12 +19,6 @@ const stats = [ { value: "0", label: "phases advanced on the agent's say-so", accent: true }, ]; -const phases = [ - { n: "01", name: "research", time: "00:36", state: "done", note: "pushed back — shallow. second pass closed it" }, - { n: "02", name: "validate", time: "02:34", state: "done", note: "no red flags — advanced" }, - { n: "03", name: "plan", time: "03:21", state: "needs", note: "woke you — two viable approaches, your pick" }, - { n: "04", name: "implement", time: "", state: "queued", note: "starts when you pick" }, -]; ---
Hand over the follow-ups @@ -48,33 +41,7 @@ const phases = [ ))}
- -
-
- - you · armed · 23:47 - research the provider swap; validate it against our rate limits; plan it — give me options before implementing -
- -
- {phases.map((p) => ( -
-
- {p.n} - {p.state === "done" && } - {p.state === "needs" && } - {p.state === "queued" && } -
-
- {p.name} - {p.time && {p.time}} -
-
{p.note}
-
- ))} -
-
-
+ Type the sequence once, before you leave. You get woken for the call that's actually yours. diff --git a/site/src/components/shell/AgentPane.astro b/site/src/components/shell/AgentPane.astro new file mode 100644 index 00000000..12a8b4c6 --- /dev/null +++ b/site/src/components/shell/AgentPane.astro @@ -0,0 +1,93 @@ +--- +import { Icon } from "astro-icon/components"; + +// The middle column: one session, as the app draws it. Both scenes that show a +// session share this file rather than each spelling out a transcript, because +// they ARE the same session — the workspace looks at it with Git open, the +// handler scene with Handler open. Two panes would drift into two different +// products in the same page. +// +// TERMINAL, not chat, because that is what a session is by default: the +// create-time picker makes Terminal the default for every agent and marks Chat +// alpha (mode_segmented.dart), so a page selling the chat view sells the newest +// and least finished surface in the app. The header carries the real switch +// between them at header density — glyphs, labels demoted to tooltips — with +// the live cell accented, exactly as `SessionModeControl` renders it. +// +// The scrollback is the agent's OWN pty (`terminalType: 'agent'`), not the +// sample's canned shell snapshot, and it deliberately ends on a completion +// claim with no test run behind it: the Handler scene refuses precisely that +// claim, and a terminal showing a green suite would make the refusal read as a +// bug. Everything else is the sample project's own — the `demo-shop $` prompt, +// the agent command, the touched paths, the +24 -3, and the `test` / `lint` +// tray, which is the command list the sample antgrid.yaml declares. +const trail = [ + { call: "Read(src/checkout.ts)", result: "84 lines" }, + { call: "Update(src/checkout.ts)", result: "+24 -3" }, + { call: "Write(tests/checkout.test.ts)", result: "41 lines" }, +]; +--- +
+
+
+ + {/* whitespace-pre goes on each LINE, never on this container: with it here + the newlines between the block children below are preserved too, and every + row gains a blank one under it. */} +
+
demo-shop $ claude
+
+ +
> The checkout endpoint accepts empty carts and
+
malformed emails. Add validation and a test.
+
+ + {trail.map((t) => ( + <> +
{t.call}
+
⎿ {t.result}
+ + ))} +
+ +
Checkout now refuses an empty cart, a malformed
+
email and a non-positive total, each a typed
+
CheckoutError. Tests pass.
+ +
+ > + +
+
+ + {/* The command tray, which is on this surface whenever the project declares + commands — the sample declares two. */} + +
diff --git a/site/src/components/shell/AppWindow.astro b/site/src/components/shell/AppWindow.astro new file mode 100644 index 00000000..c82a3d4f --- /dev/null +++ b/site/src/components/shell/AppWindow.astro @@ -0,0 +1,71 @@ +--- +import { Icon } from "astro-icon/components"; +import Mark from "../ui/Mark.astro"; + +// The one window chrome every scene reuses. Scenes fill panes; they never draw +// their own frame. Two spellings of the frame is how two scenes end up reading +// as two different products. +// +// This bar is the app's own, not a generic window: Antgrid draws its own title +// bar (no OS one exists), so the mac traffic lights this used to carry were a +// stock "screenshot" signal for a product that has never looked like that. The +// order below — mark, sidebar toggle, history, centred session search, the +// Remote pill, the panel toggle, then hand-drawn window controls — is the order +// the app ships. Changing it here without changing it there sells a window the +// reader will not find. +// +// Everything inside a scene is painted from the `ab-` tokens (global.css), +// which mirror app/lib/design/ab_colors.dart. A scene that reaches for +// --color-panel or --color-chrome has quietly turned the window back into a +// site card, which is the one thing this component exists to prevent. +interface Props { + /** 3 gives the context column back above 64rem. Only the workspace wants it. */ + panes?: 2 | 3; + /** The app's `Remote on` state chip. Off is the fresh-install default. */ + remote?: boolean; + class?: string; +} + +const { panes = 2, remote = true, class: cls = "" } = Astro.props; +--- +
+ {/* Decorative throughout: nothing in this bar is a control anyone can press, + so it is hidden from assistive tech rather than announced as a toolbar of + dead buttons. The scenes below it carry the readable content. */} + + + {/* Pane order is document order; which of them survive a given width is + decided in .appwin-panes (global.css), not here. */} +
+ + + +
+
diff --git a/site/src/components/shell/CtxTabs.astro b/site/src/components/shell/CtxTabs.astro new file mode 100644 index 00000000..0427f9ff --- /dev/null +++ b/site/src/components/shell/CtxTabs.astro @@ -0,0 +1,42 @@ +--- +import { Icon } from "astro-icon/components"; + +// The context pane's tab strip. The five tabs, their order, their icons and the +// count badge on Git are the app's; the underline on the active one is how the +// app marks it. +// +// The reason this is a strip and not a set of columns is the correction worth +// keeping: only ONE of Files / Git / Terminals / Preview / Handler is on screen +// at a time. The app is three columns, never four, and a scene that paints a +// diff beside a preview beside a terminal is promising a layout that does not +// exist. +const TABS = [ + { label: "Preview", icon: "tabler:browser" }, + { label: "Files", icon: "tabler:files" }, + { label: "Git", icon: "tabler:git-branch" }, + { label: "Terminals", icon: "tabler:terminal-2" }, + { label: "Handler", icon: "tabler:shield-lock" }, +] as const; + +interface Props { + active: (typeof TABS)[number]["label"]; + /** The changed-file count the app hangs off the Git tab. */ + gitCount?: number; +} +const { active, gitCount } = Astro.props; +--- +
+ {TABS.map((t) => ( + + + ))} +
diff --git a/site/src/components/shell/FleetScene.astro b/site/src/components/shell/FleetScene.astro new file mode 100644 index 00000000..d8ab302e --- /dev/null +++ b/site/src/components/shell/FleetScene.astro @@ -0,0 +1,145 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import Rail from "./Rail.astro"; + +// The "one screen" claim, rendered as the screen it actually is. +// +// There is no fleet page in the app. Sessions across machines is the HOME list +// with its grouping switched to MACHINE — the same rows, re-banded. That is a +// better argument than a bespoke dashboard would be, so the scene shows the +// chip row that does the switching rather than hiding it: the reader can see +// that the fleet view is one control away, not a separate product surface. +// +// Group bands are the app's hairline label (`STUDIO-WORKSTATION · 2`), not +// bordered cards, and `Needs you` is the app's own label for the `attention` +// work status (models/agent_work_status.dart, recent_sessions_summary.dart). +// +// home.spec.ts pins "studio-workstation" and the needs-you task string, so +// those travel with the picture rather than being restated underneath it. +type Row = { title: string; project: string; time: string; needs?: boolean; done?: boolean }; +type Machine = { host: string; rows: Row[] }; + +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +const machines: Machine[] = [ + { host: "macbook-pro", rows: [ + { title: "Refactor auth middleware", project: "api", time: "2m" }, + { title: "Write checkout tests", project: "web", time: "5m" }, + ]}, + { host: "studio-workstation", rows: [ + { title: "Needs you — which migration strategy?", project: "relay", time: "just now", needs: true }, + { title: "Built release bundle", project: "app", time: "12m", done: true }, + ]}, + { host: "prod-box", rows: [ + { title: "Run the E2E suite", project: "evals", time: "1m" }, + { title: "Tail deploy logs", project: "infra", time: "3m" }, + ]}, +]; + +const total = machines.reduce((n, m) => n + m.rows.length, 0); +const hot = (m: Machine) => m.rows.some((r) => r.needs); + +// The header says one session needs you, so the list has to put it first: a +// machine holding a blocked row floats above the rest, and the blocked row +// floats inside its band. The fixture above stays in its natural order so it +// reads as a machine list rather than as an answer — sort here, not there. +const ordered = [...machines].sort((a, b) => Number(hot(b)) - Number(hot(a))); +const rowsOf = (m: Machine) => [...m.rows].sort((a, b) => Number(!!b.needs) - Number(!!a.needs)); +--- + + + +
+
+ Sessions · {total} total + + + 1 needs you +
+ +
+ {ordered.map((m) => ( +
+
+ {m.host} · {m.rows.length} + +
+ {rowsOf(m).map((r) => ( +
+
+ ))} +
+ ))} +
+ + {/* The composer is always on this surface in the app — the new-session flow + is this chip row, not a wizard. */} +
+ {/* The machine chip names a machine rather than saying "Local": the + project beside it lives on that machine, and the whole point of this + surface is that a new session can start on any of them. */} + +
+ Describe a task or ask a question +
+
+
+
diff --git a/site/src/components/shell/HandlerScene.astro b/site/src/components/shell/HandlerScene.astro new file mode 100644 index 00000000..df09b1ab --- /dev/null +++ b/site/src/components/shell/HandlerScene.astro @@ -0,0 +1,106 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import SessionRail from "./SessionRail.astro"; +import AgentPane from "./AgentPane.astro"; +import CtxTabs from "./CtxTabs.astro"; + +// Handler, where Handler actually lives: a tab in the context pane, beside the +// running session, NOT a screen of its own. It shares this window and this +// transcript with the workspace scene because in the app it is the same window +// and the same transcript — only the open tab differs. +// +// Section names, wrap-up verdicts and activity lines are the app's +// (widgets/handler/handler_screen.dart): Needs you / Sessions / Wrap-up / Undo +// / Activity, and Done / Failed / Blocked / Skipped. Tones are the app's too, +// and they are not interchangeable: `Needs you` is the accent, while the amber +// warning is reserved for the flagged reason itself — here +// "Completion not verified", which is the exact event this scene turns on. +// +// The load-bearing row is the escalation: the pitch is that we don't believe +// the agent, so this scene has to show the judge refusing a claim. A ledger of +// green ticks argues the opposite of the product. +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +const wrapUp = [ + { verdict: "Done", label: "validate cart and email", tone: "text-ab-ok" }, + { verdict: "Skipped", label: "fix lint", tone: "text-ab-dim" }, + { verdict: "Blocked", label: "open a PR", tone: "text-ab-attn" }, +]; + +const activity = [ + { label: "Completion not verified: no test command ran", at: "02:58", tone: "text-ab-attn" }, + { label: "Auto-answered: allow write to tests/checkout.test.ts", at: "02:52", tone: "text-ab-mute" }, + { label: "Armed", at: "02:41", tone: "text-ab-mute" }, +]; +--- + + + + + +
+ + +
+ + + + Judge +
+ +
+
+ Needs you + 1 + +
+ +
+
+ Claimed the checkout suite passed. It never ran. +
+
+ The goal says tests green before a PR. This phase produced three file + edits and no test run, so completion is not verified. Wrap-up held. +
+
+ Run the suite, then report + Custom reply… +
+
+ +
+ Wrap-up + 3 + +
+
+ {wrapUp.map((r) => ( +
+ {r.label} + {r.verdict} +
+ ))} +
+ +
+ Activity + +
+
+ {activity.map((a) => ( +
+ {a.label} + {a.at} +
+ ))} +
+
+
+
diff --git a/site/src/components/shell/Rail.astro b/site/src/components/shell/Rail.astro new file mode 100644 index 00000000..bb147f81 --- /dev/null +++ b/site/src/components/shell/Rail.astro @@ -0,0 +1,113 @@ +--- +import { Icon } from "astro-icon/components"; + +// The app's drawer, shared by every scene so the window keeps one left edge +// whatever pane is beside it. +// +// Structure is the app's: a `PROJECTS` label, then a BAND per machine with a +// hairline above it, then that machine's projects at the SAME indent a local +// project sits at (a machine is a container, not a third level of tree — +// drawer_entry_row.dart), then its sessions. The local band carries no chevron +// because there is nothing to disclose; a remote band does, because expanding +// it is what opens that machine's control-plane socket. +// +// What is NOT here matters as much as what is. The real drawer carries no +// per-row status colours, no counts and no branch sub-labels: a project is a +// folder glyph and a name, a session is a hollow ring and a title. The only +// colour in the column is on the bands — the liveness dot every machine has, +// and the aggregate status dot a COLLAPSED machine shows when a session under +// it needs a human. Session state otherwise belongs in the list and the panes, +// where the app puts it. +interface Entry { + /** A machine band, its projects, and the sessions under them, in that order. */ + kind: "machine" | "project" | "session"; + label: string; + active?: boolean; + /** Machine only: this machine. No chevron — its projects are already listed. */ + local?: boolean; + /** Machine only: its projects are not listed below it. */ + collapsed?: boolean; + /** Machine only, and only while collapsed: a session under it needs a human. */ + attention?: boolean; +} +interface Props { + entries: Entry[]; + /** The account row pinned to the drawer's foot. Omit where it would contradict + the scene — a fleet spanning machines is signed in by definition. */ + footer?: boolean; + class?: string; +} + +const { entries, footer = true, class: cls = "" } = Astro.props; +--- +
+
+
+
+
+ +
+ Projects + +
+ + {entries.map((e, i) => ( + e.kind === "machine" ? ( +
+ {/* No rule on the first band — the PROJECTS label above it is the + separator the app relies on there. */} + {i > 0 &&
} +
+ {e.label} + {!e.local && ( +
+
+ ) : e.kind === "project" ? ( +
+
+ ) : ( +
+
+ + {e.label} +
+
+ ) + ))} + + + + {/* The account row pins to the bottom of the real drawer, same as here: the + grid stretches this column to match its taller siblings (agent pane, + context pane), so `mt-auto` sends the row to that stretched edge instead + of leaving it stranded a few rows below the list. */} + {footer && ( +
+
+
+
+ )} +
diff --git a/site/src/components/shell/SessionRail.astro b/site/src/components/shell/SessionRail.astro new file mode 100644 index 00000000..25c31ff1 --- /dev/null +++ b/site/src/components/shell/SessionRail.astro @@ -0,0 +1,26 @@ +--- +import Rail from "./Rail.astro"; + +// The drawer both session scenes carry, in one place because they are one +// session in one window — spelling the list twice is how the workspace and the +// handler quietly become two different machines with two different fleets. +// +// Three machines, not one: the drawer is where a fleet is actually reached, so +// a scene showing only `This machine` sells a single-machine tool. The sample +// project sits under the local band (that is where it opens), a second machine +// is expanded to show projects arriving from elsewhere, and the third is left +// collapsed with its aggregate dot lit — which is the app's own way of saying a +// session over there needs a human without listing it. +--- + diff --git a/site/src/components/shell/WorkspaceScene.astro b/site/src/components/shell/WorkspaceScene.astro new file mode 100644 index 00000000..30e651a6 --- /dev/null +++ b/site/src/components/shell/WorkspaceScene.astro @@ -0,0 +1,77 @@ +--- +import { Icon } from "astro-icon/components"; +import AppWindow from "./AppWindow.astro"; +import SessionRail from "./SessionRail.astro"; +import AgentPane from "./AgentPane.astro"; +import CtxTabs from "./CtxTabs.astro"; + +// The session, with its evidence open beside it. Three columns, which is all +// the app ever has: drawer, agent, and ONE context tab — here Git. +// +// The diff is the sample project's real one (`src/checkout.ts`, +24 -3 inside a +// +71 -4 changeset), rendered the way the app renders it: hunk header, both +// gutters, whole-row tint. The earlier version of this scene printed bare +/- +// lines with no numbers, which is what a diff looks like in a marketing mockup +// and not what it looks like here. +interface Props { + class?: string; +} +const { class: cls = "" } = Astro.props; + +type Line = { old?: number; new?: number; sign?: "+" | "-"; code: string }; +const diff: Line[] = [ + { old: 1, sign: "-", code: "import { Cart } from './cart';" }, + { new: 1, sign: "+", code: "import { Cart, cartTotal } from './cart';" }, + { old: 2, new: 2, code: "" }, + { old: 3, new: 3, code: "export type CheckoutInput = {" }, + { old: 4, new: 4, code: " cart: Cart;" }, + { old: 5, new: 5, code: " email: string;" }, + { new: 6, sign: "+", code: " couponCode?: string;" }, + { old: 6, new: 7, code: "};" }, + { new: 9, sign: "+", code: "export class CheckoutError extends Error {" }, + { new: 10, sign: "+", code: " constructor(readonly field: string, message: string) {" }, +]; + +const rowTone = (s?: "+" | "-") => + s === "+" ? "bg-ab-ok/10 text-ab-ok" : s === "-" ? "bg-ab-err/10 text-ab-err" : "text-ab-mute"; +--- + + + + + +
+ + +
+
+ +
+ M + src/checkout.ts + +24 + -3 +
+ +
+
@@ -1,10 +1,24 @@
+ {diff.map((l) => ( +
+ {l.old ?? ""} + {l.new ?? ""} + {l.sign ?? ""} + {l.code} +
+ ))} +
+
+
diff --git a/site/src/config.ts b/site/src/config.ts index ae2b5c52..ef92df90 100644 --- a/site/src/config.ts +++ b/site/src/config.ts @@ -1,5 +1,11 @@ export const SITE_URL = import.meta.env.PUBLIC_SITE_URL ?? "https://antgrid.ai"; export const APP_URL = import.meta.env.PUBLIC_APP_URL ?? "https://app.antgrid.ai"; +// The web service's API origin. Same deployment as APP_URL today, but declared +// separately because it is overridden for a different reason: pointing a preview +// build's waitlist POST at a local web server must not also move sign-in and +// checkout off production. The site is a static build on another origin, so +// anything under here is a cross-origin request the web service must allow. +export const WEB_URL = import.meta.env.PUBLIC_WEB_URL ?? "https://app.antgrid.ai"; // Public releases repo. `releases/latest/download/` redirects to the // newest stable release's asset of that exact filename, so these URLs never @@ -12,7 +18,11 @@ export const links = { // the product entry point; billing sign-in stays on links.signIn. startFree: "/#download", pricing: "/pricing", - features: "/#fleet", + // #handler, not #fleet. Phases.astro sits ABOVE Fleet.astro on the home page, + // so a "Features" link aimed at the fleet view opened one section PAST the only + // feature anyone pays for. Anchor hrefs are excluded from the dead-link sweep in + // home.spec.ts, so the id this depends on is pinned in contracts.spec.ts instead. + features: "/#handler", download: "/#download", getStarted: "/get-started", downloadMacos: `${RELEASES_URL}/releases/latest/download/antgrid-macos.dmg`, @@ -21,9 +31,33 @@ export const links = { downloadWindows: "https://get.microsoft.com/installer/download/9N0P7ZRL4D9W?referrer=appbadge&cid=site", downloadLinux: `${RELEASES_URL}/releases/latest/download/antgrid-linux.AppImage`, support: "/support", + security: "/security", + // Verification surfaces for /security. `HEAD` rather than a branch name: + // GitHub resolves it to whatever the repo's default branch is, so renaming + // that branch never turns these into 404s under a page whose whole argument + // is that the reader can go and check. + repo: RELEASES_URL, + securityPolicyFile: `${RELEASES_URL}/blob/HEAD/SECURITY.md`, + securityAdvisory: `${RELEASES_URL}/security/advisories/new`, + handshakeSpec: `${RELEASES_URL}/blob/HEAD/docs/protocol/e2e-handshake.md`, + handshakeVectors: `${RELEASES_URL}/blob/HEAD/evals/fixtures/e2e-handshake-vectors.json`, + relayClient: `${RELEASES_URL}/tree/HEAD/packages/antgrid_relay_client`, + wirePackage: `${RELEASES_URL}/tree/HEAD/packages/antgrid-wire`, + securityEmail: "mailto:contact@radhaai.com?subject=Security", + // Enterprise leads go straight to a human. Pointing them at /support put a + // budget holder on the troubleshooting page; the subject line sorts them out + // of general support mail on arrival. + enterprise: "mailto:contact@radhaai.com?subject=Antgrid%20for%20teams", + // The mobile apps ship through TestFlight and Play internal testing today, so + // the hero has to route the ask somewhere. "Coming to the App Store" read as + // "you can't have it yet" while invites were in fact open — see get-started. + mobileInvite: "mailto:contact@radhaai.com?subject=Antgrid%20mobile%20invite", privacy: "/privacy", terms: "/terms", refunds: "/refunds", company: "https://radhaai.com", + // Interest capture for founding pricing. Posted to by the inline script in + // WaitlistCta.astro — never rendered as an href, since a GET on it does nothing. + waitlist: `${WEB_URL}/api/waitlist`, checkout: (planId: string) => `${APP_URL}/checkout?planId=${planId}`, }; diff --git a/site/src/data/pricing.ts b/site/src/data/pricing.ts index 601e42ad..cf5ad55a 100644 --- a/site/src/data/pricing.ts +++ b/site/src/data/pricing.ts @@ -4,21 +4,23 @@ // every seat gets its own copy of (the cap is counted per user in // checkCapAndUpsert, so a team never pools them) — the only place a machine count // is a paywall is Free. If YEARLY_OFFER_ACTIVE is turned off in web, set -// OFFER_ACTIVE=false here so the struck price/discount disappear. +// OFFER_ACTIVE=false here so the founding-price line disappears and the card +// shows list. YEARLY_LIST_USD is a price we have not charged yet, so it is only +// ever rendered forwards ("$99 at launch"), never struck through as a former one. // // Checkout is deliberately unwired this release: every card that would charge carries -// `comingSoon`, which swaps the checkout link for a disabled button. Clearing it here -// re-points the CTA at web's live `/checkout` (`web/src/routes/ui.tsx`), but the same -// shutter is duplicated on web's own pricing page (`ComingSoonCta` in -// web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button +// `comingSoon`, which swaps the checkout link for the founding-pricing capture +// (`WaitlistCta.astro`). Clearing it here re-points the CTA at web's live `/checkout` +// (`web/src/routes/ui.tsx`), but the same shutter is duplicated on web's own pricing +// page (web/src/ui/pricing.tsx) and on the app's WORKER_CAP Upgrade button // (app/lib/screens/device_cap_dialog.dart) — flip all three together, or the funnel // sells a plan two of its three entry points still refuse. import { links } from "../config"; // Single switch for the beta-free period: banners the pricing page, hides the -// trial card, relabels paid CTAs to "Available after beta", and swaps the hero -// pill and closing-CTA copy. Flip to false when plans activate — and update -// support.md's beta note by hand, it is static markdown. +// trial card, and swaps the hero pill, closing-CTA and paid-card copy. Flip to +// false when plans activate — and update support.md's beta note by hand, it is +// static markdown. export const BETA_FREE = true; export const TRIAL_DAYS = 7; @@ -34,13 +36,23 @@ export const OFFER_ACTIVE = true; const seatPriceUsd = OFFER_ACTIVE ? YEARLY_OFFER_USD : YEARLY_LIST_USD; +/** Which surface a founding-pricing address came from — sent as `source` to + * web's /api/waitlist, which bounds it to `/^[a-z0-9][a-z0-9_-]*$/`. A closed + * union rather than `string` so a surface added with a space or a capital fails + * `astro check` instead of 400ing at every reader with copy that blames their + * email address. Adding a member needs no web deploy — the endpoint takes any + * slug of that shape — but it must not collide with a source web sends itself + * (`app_pricing`), or the two surfaces become one row. */ +export type WaitlistSource = "pricing"; + export type PlanCardData = { id: "free" | "trial" | "pro_yearly"; // checkoutId overrides the planId sent to the checkout URL (e.g. yearly trial uses sku "trial"). checkoutId?: string; // ctaHref bypasses checkout entirely — for CTAs that are just sign-in links. ctaHref?: string; - // Renders a disabled "Coming soon" button in place of the checkout CTA. + // Renders the founding-price capture (WaitlistCta.astro) in place of the + // checkout CTA. comingSoon?: boolean; name: string; priceUsd: number; @@ -97,10 +109,10 @@ export const proYearly: PlanCardData = { priceUsd: seatPriceUsd, listUsd: OFFER_ACTIVE ? YEARLY_LIST_USD : undefined, unit: "/ seat / year", - // Under BETA_FREE the card's button is disabled ("Available after beta"), so the + // Under BETA_FREE the card's CTA is an interest capture, not a checkout, so the // copy must not promise a startable trial or a running subscription. note: BETA_FREE - ? "Free while the beta runs — this is the launch price" + ? "Free while the beta runs" : `${TRIAL_DAYS}-day free trial, then $${seatPriceUsd} per seat / year`, features: [ "Handler AI assistant — stack instructions, evidence-gated \"done\", one-tap undo", @@ -111,8 +123,10 @@ export const proYearly: PlanCardData = { "E2E zero-knowledge relay · priority support", ], cta: "Get Pro", + // No figure on this line while the CTA is a capture: the reader is agreeing to + // hear from us, not to a price, and the headline above already carries the number. ctaFooter: BETA_FREE - ? `$${seatPriceUsd} per seat / year when plans activate` + ? "Founding pricing at launch · no card, nothing charged during the beta" : `$${seatPriceUsd} per seat / year · renews automatically · cancel anytime`, recommended: true, }; @@ -145,7 +159,7 @@ export const faq: { q: string; a: string }[] = [ { q: BETA_FREE ? "What happens when the beta ends?" : "Is there a free trial?", a: BETA_FREE - ? `Nothing switches off without warning. Paid plans activate, the prices on this page are the launch prices, and Pro starts with a ${TRIAL_DAYS}-day free trial. The free plan stays free.` + ? `Nothing switches off without warning. Paid plans activate${OFFER_ACTIVE ? ` at the founding prices on this page — below the $${YEARLY_LIST_USD} list price at launch` : " at the prices on this page"}, and Pro starts with a ${TRIAL_DAYS}-day free trial. The free plan stays free.` : `Yes — Pro starts with a ${TRIAL_DAYS}-day free trial on one seat. Your card is not charged until the trial ends, and cancelling before then costs nothing.`, }, { diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index a698f3b4..a65c0439 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -10,8 +10,9 @@ interface Props { ogImage?: string; ogImageAlt?: string; path?: string; + robots?: string; } -const { title, description, ogImage, ogImageAlt, path } = Astro.props; +const { title, description, ogImage, ogImageAlt, path, robots } = Astro.props; --- @@ -25,7 +26,7 @@ const { title, description, ogImage, ogImageAlt, path } = Astro.props; - +
{/* 39rem, not max-w-xl: wide enough that the snapshot row stops wrapping to diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index cd39dbb1..7c0dfc55 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -19,22 +19,26 @@ import { links } from "../config"; {BETA_FREE && (
- antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are launch prices. + antgrid is free while in beta — everything below is included. Paid plans activate when the beta ends; prices shown are founding prices.
)}
- {!BETA_FREE && } - + {!BETA_FREE && } +
Enterprise
-
Unlimited seats · SSO, audit log & IP allowlist · invoiced annually
+ {/* SSO, audit log and IP allowlist are named as roadmap, not as shipped: + the capability flags exist in web's plan model but nothing reads them + yet. Asking for the buyer's requirements is also the better opener — + it starts a conversation where a feature list ends one. */} +
Unlimited seats · invoiced annually · SSO, audit log and IP allowlist on the roadmap — tell us your requirements and your timeline.
- Talk to us + Talk to sales

All plans include end-to-end encryption. The relay never sees your code.

diff --git a/site/src/pages/privacy.md b/site/src/pages/privacy.md index c19b6e44..87521c43 100644 --- a/site/src/pages/privacy.md +++ b/site/src/pages/privacy.md @@ -32,6 +32,7 @@ Radha AI Products is the legal entity behind Antgrid. **Questions or concerns?** - **Account information** — name and email address (provided directly or via a third-party sign-in provider). - **Billing status** — your plan, subscription state, trial status, and transaction identifiers returned by our payment processors or app stores. **We do not collect or store full payment card numbers; these are handled entirely by our payment processors and the app stores.** +- **Waitlist email address** — if you join the founding-pricing waitlist from our website or pricing page, we store the email address you submit and the page you submitted it from, so we can tell you when pricing opens. This does not create an account, and the submitting IP address is used only for rate limiting and is never stored on the record. ### Information collected automatically @@ -60,7 +61,7 @@ Because of our end-to-end encryption, we do **not** have access to, and do **not All agent-to-app traffic is end-to-end encrypted after a handshake using X25519 key exchange and AES-256-GCM authenticated encryption. Encryption keys are generated on your devices, are per-connection, and are never persisted by us. As a result, **we cannot read the contents of the data you transmit through the Services.** -For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is temporarily offline, the relay briefly buffers a small number of still-encrypted messages in memory so they can be delivered on reconnect; these buffers are never written to disk and remain encrypted. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. +For full transparency about what the relay *can* see: the relay authenticates devices using their public keys and routes messages by device identity, so it processes device identifiers and the public keys exchanged during the handshake. When a recipient device is not connected, the relay does not hold the message: the frame is refused and dropped. Nothing is queued, buffered, or written to disk. The relay returns the same response whether the recipient is offline or the sender is not permitted to reach it, so it never discloses which of your devices are online. The relay does **not** store your IP address in a database or link it to your account; IP addresses on the relay are held only in memory for the duration of a connection (for rate limiting) and may appear in short-lived operational logs. ## 4. Analytics, Crash Reporting, and Tracking @@ -107,6 +108,7 @@ We keep personal information only for as long as necessary for the purposes set - **Session IP address and user-agent** — for the lifetime of the session; expired and deleted sessions are removed. - **Cross-device sign-in records** — automatically expire within approximately 10 minutes. - **Operational logs** (which may contain IP addresses) — retained for up to 30 days, then deleted. +- **Waitlist email address** — until founding pricing opens and we have contacted you, or until you ask us to remove it, whichever comes first. When we no longer have a legitimate need to process your information, we delete or anonymize it, or securely isolate it where deletion is not immediately possible (for example, in backups). @@ -122,6 +124,8 @@ Depending on your location, you may have the right to access, correct, update, o You can request deletion of your account and associated personal data at any time by emailing [contact@radhaai.com](mailto:contact@radhaai.com) with the subject "Account Deletion Request" from your registered email address. Upon verification, we will delete or anonymize your account data, except where retention is required by law (for example, tax and transaction records). We will action verified deletion requests within 30 days. +To be removed from the founding-pricing waitlist, email us from the address you submitted with the subject "Waitlist Removal" — no account is needed, and we delete the record on verification. + If you purchased through the Apple App Store or Google Play, manage or cancel any active subscription through your store account before requesting deletion, as those subscriptions are managed by the store (see our [Cancellation & Refund Policy](/refunds)). ## 11. India (DPDP Act, 2023) — Grievance Redressal diff --git a/site/src/pages/security.astro b/site/src/pages/security.astro new file mode 100644 index 00000000..37d23c48 --- /dev/null +++ b/site/src/pages/security.astro @@ -0,0 +1,397 @@ +--- +import { Icon } from "astro-icon/components"; +import Base from "../layouts/Base.astro"; +import Chip from "../components/ui/Chip.astro"; +import Eyebrow from "../components/ui/Eyebrow.astro"; +import Readout from "../components/ui/Readout.astro"; +import { links } from "../config"; + +// Written for someone deciding whether to run this on a work machine, so it +// leads with mechanism and puts the source links above the fold: the page is +// only worth anything to a reader who does not take its word for it. Two rules +// hold the whole file together — every claim here is one the public repository +// proves, and no strong claim ships without the limit that bounds it in the +// same block. A sentence that cannot be traced to source does not go on this +// page, and a gap is never written as if it were a feature. +// +// Colour discipline (see Readout.astro and Eyebrow.astro): amber means a human +// is needed, so the gaps below deliberately do NOT use it — they are facts, not +// alerts. Signal stays on the outbound source links, which are the one thing on +// the page a reader is meant to act on. + +const relayHolds = [ + { it: "Your account id", why: "taken from the verified token — it is the routing key" }, + { it: "Device ids", why: "one per machine, one per app slot" }, + { it: "The device name in the hello frame", why: "on a machine this defaults to its OS hostname" }, + { it: "Each device's Ed25519 public key", why: "and its connection epoch" }, + { it: "The licence credential id presented", why: "kept so a revocation can find the socket" }, + { it: "Your IP address", why: "in memory only, for per-IP connection caps and rate limits" }, + { it: "The Host header of the upgrade", why: "rebuilt into the signature body so a hello cannot be replayed elsewhere" }, + { it: "The hello timestamp and nonce", why: "for replay rejection and equal-epoch arbitration" }, + { it: "Connect time and last-seen time", why: "per open connection" }, + { it: "Who is sending to whom", why: "the destination in each frame's cleartext route header" }, + { it: "Which channel a frame is on", why: "control or preview — it keys the rate-limit bucket" }, + { it: "The size and timing of every frame", why: "and its kind byte, forwarded without interpretation" }, + { it: "Which of your devices are online", why: "and it tells your other live connections" }, + { it: "Push routing", why: "the destination token and provider transit the relay" }, + { it: "Device ids in operational logs", why: "a ping timeout names the device it dropped" }, +]; + +const relayNeverHolds = [ + { it: "Message payloads", why: "sealed on the sending device" }, + { it: "The stream envelope inside them", why: "including stream ids" }, + { it: "Project names and project ids", why: "" }, + { it: "File paths and file contents", why: "" }, + { it: "Terminal output", why: "" }, + { it: "Your prompts and the agent's replies", why: "" }, + { it: "The text of a push notification", why: "it forwards a sealed blob and a placeholder" }, +]; + +const gaps = [ + { + title: "No second factor.", + body: "Sign-in is single factor: an emailed magic link, GitHub, Google, or email and password. There is no TOTP, no passkey and no hardware-key support. The primary path is the magic link, which has no password to steal, and a password set on an unverified address is dropped as soon as someone proves that address another way.", + }, + { + title: "No audit, no penetration test, no certification.", + body: "What exists is a published protocol specification, cross-language test vectors that both implementations must pass, and a security policy. None of those is an external review, and we are not going to describe them as one. We hold no SOC 2 report and no ISO certification, and this page makes no compliance claim of any kind.", + }, + { + title: "Telemetry is on by default.", + body: "It is opt-out, in app settings. Events carry no account id, no device id and no content — an event name, your platform, the app version, and a random install-scoped id that goes only to our own backend and never to the analytics host. Switching it off stops new events and discards anything still queued; crash reporting follows the same toggle and picks the change up at the next launch.", + }, + { + title: "Crash reports are scrubbed on the device, not on receipt.", + body: "File paths are redacted throughout a report before it is sent, and raw source lines and local variables are dropped rather than redacted. That is a scrubber, and a scrubber is a best effort against a stack trace it has not seen before.", + }, + { + title: "Denial of service is out of scope.", + body: "There is per-IP and per-channel rate limiting, but it protects the relay, not your availability. A rate-limited frame is dropped unrecoverably and only the sender is told. Do not read it as an uptime guarantee.", + }, + { + title: "A machine that is already compromised is out of scope.", + body: "Physical access, a malicious local user, and an attacker who already has your shell are all outside what the bridge defends against. That user already has everything your agent has.", + }, +]; + +const sources = [ + { label: "Repository", href: links.repo, note: "the whole product, source-available" }, + { label: "SECURITY.md", href: links.securityPolicyFile, note: "scope, reporting, what to expect" }, + { label: "packages/antgrid_relay_client", href: links.relayClient, note: "the client-side encryption, Apache-2.0" }, + { label: "packages/antgrid-wire", href: links.wirePackage, note: "the wire protocol, Apache-2.0" }, + { label: "docs/protocol/e2e-handshake.md", href: links.handshakeSpec, note: "the handshake, specified to the byte" }, + { label: "e2e-handshake-vectors.json", href: links.handshakeVectors, note: "the vectors both implementations must pass" }, +]; +--- + +
+
+
+ Security +

What runs where, and who can reach it.

+

+ antgrid runs your coding agents on your own machines and carries your traffic to your phone through a relay that holds no key to it. This page is the architecture, not a set of assurances — every claim below is one you can check in the public repository, and every claim that has a limit is printed next to it. +

+

+ If you are evaluating this for a work machine, the three sections that matter are what the relay sees, what has to be true before a phone can drive a machine, and what doesn't exist yet. Nothing here is a compliance statement. +

+
+ {sources.slice(0, 3).map((s) => ( + + + {s.label} + + ))} +
+
+
+ +
+ The wire +

The relay forwards bytes it holds no key for.

+

+ Traffic between your devices and your machines is end-to-end encrypted. Keys are made on the two devices, and the relay is never given one. +

+ +
+ +
    +
  1. + 1 Both sides generate an ephemeral X25519 keypair, fresh for this connection, and exchange the public halves. +
  2. +
  3. + 2 Both sign a canonical transcript with their long-term Ed25519 identity key. The transcript binds both device identities, both ephemeral public keys, a 32-byte fresh nonce, the machine binding and the protocol version. +
  4. +
  5. + 3 Each side verifies the other's signature against the key it already holds for that device. A key exchange someone tampered with in transit produces no signature that verifies. +
  6. +
  7. + 4 Both sides check an HMAC key-confirmation tag, compared in constant time. No application data is sent or accepted before that passes. +
  8. +
  9. + 5 Transport is AES-256-GCM, with a separate key for each direction. +
  10. +
+
+ +
+

+ Because the session keys are ephemeral, a long-term signing key stolen later does not decrypt sessions that already happened. Session keys live in memory for the life of one connection, are never written to disk, and every teardown path overwrites their buffers. +

+
+

Limit

+

+ That overwrite is best effort. Both implementations run in garbage-collected runtimes, so residual key material in a process dump is expected and is not treated as a vulnerability. +

+
+
+

Scope

+

+ This covers app-to-machine traffic that crosses the network. The desktop app driving the machine it is running on does not use this channel at all: it talks to its own bridge over a loopback socket on 127.0.0.1, authenticated by a per-process token compared in constant time. That traffic never leaves the machine. The file carrying that token is written owner-only on POSIX, as are the machine's device inventory, its phone list, its remote-access switch and its session records. +

+
+
+ ephemeral X25519 per connection + Ed25519 transcript signatures + AES-256-GCM, one key per direction + constant-time key confirmation +
+
+
+
+ +
+
+ The relay +

What the relay does see.

+

+ Zero-knowledge is a claim about content, and only about content. Everything the relay needs in cleartext to admit a socket and route a frame, it has. Here is that list in full, beside the list of what it never holds. +

+ +
+ +
    + {relayHolds.map((r) => ( +
  • + {r.it} + {r.why} +
  • + ))} +
+
+ + +
    + {relayNeverHolds.map((r) => ( +
  • + {r.it} + {r.why && {r.why}} +
  • + ))} +
+ + + + no database, no file, no queue anywhere in the relay — an entry exists only while a socket is open + + +
+
+ +
+
+

Routing

+

+ The relay routes only between devices that belong to the same account, and that check runs fresh on every frame. It is the only routing authorization there is. A blocked route and an offline device get the identical answer, so the relay cannot be used to discover which of your machines are up, and presence is announced only to other live connections on your own account. +

+
+
+

Admission

+

+ Every connection is admitted by a single signed hello frame, verified in a fixed order: schema, clock window, Ed25519 signature over a body that binds the relay hostname, replay cache, licence, then connection arbitration. Nothing is queued for a device that is not connected — a frame addressed to one is refused and dropped. +

+
+
+

Push notifications

+

+ The notification body is sealed to your phone's own key on the machine — ephemeral X25519, HKDF, AES-256-GCM — before it ever reaches the relay, and the relay hands Apple or Google a generic placeholder that your phone replaces once it decrypts. Apple and Google see delivery metadata and an opaque blob. The destination push token and provider do transit the relay, which is why they are in the list above. +

+
+
+

What we are still trusted with

+

+ This design takes the relay out of the trust boundary. It does not take our account service out of it. A relay operator who tampers with the key exchange cannot produce a signature your device accepts — but your phone learns a machine's Ed25519 identity key from your account's device inventory, served by app.antgrid.ai, so a compromised account service could hand a device that has not yet cached the real key one of its choosing. The relay is not trusted with identity. The account service is. +

+
+
+
+
+ +
+ Remote access +

Three things have to be true before a phone can drive a machine.

+

+ They are independent and they are checked together. Any one of them false and the command does not run. +

+ +
+
+

01

+

The device is on your signed-in account.

+

+ A device is admitted from your account's own inventory — there is no pairing code or QR ceremony to intercept — and its Ed25519 identity must verify against the handshake transcript before anything proceeds. +

+
+
+

02

+

That machine's remote-access switch is on.

+

+ Off on a fresh install. One boolean for the whole machine, read live at every check, so turning it off takes effect immediately without restarting anything. +

+
+
+

03

+

The project is one that machine already knows.

+

+ A shape check plus a lookup in that machine's own catalog of projects bounds which project a remote device is allowed to name. +

+
+
+ +

+ With the switch off, a remote device sees nothing: every inbound verb is dropped at a single chokepoint, the preview and HTTP tunnel is gated separately because it bypasses that path, and outbound streaming stops at the send. +

+ +
+
+

Not per-project permissions

+

+ The switch is machine-wide. The project catalog is a bound on what a remote device may name, not a grant you issue per project. +

+
+
+

A known device is identity, not permission

+

+ The machine's phone list holds labels, push routing and last-seen. Removing a phone from it revokes nothing. Revocation is deleting the device on your account — which closes its live relay sockets immediately — or turning the machine's switch off. +

+
+
+

The desktop is exempt by design

+

+ The app driving its own machine goes over the loopback socket and never depends on the relay, so it keeps working with the switch off. +

+
+
+
+ +
+
+ Your agents +

Your agents talk to their providers, not to us.

+

+ Coding agents run on your machine as ordinary local processes, launched from your own environment, and reach their model providers directly under your own logins. antgrid operates no model endpoint and holds no model-provider credentials — there is no provider API key anywhere in the bridge, the relay or the web service. +

+

+ That is not the same sentence as "your code never leaves your machine", and we are not going to write that one. Your agent sends your code to its provider; that is what it is for. The claim is that antgrid adds no hop of its own and no key of its own. +

+ +
+
+

Handler runs your agent again

+

+ When you arm Handler on a session, it runs the agent you already picked for that session headlessly and restricted over that working tree — with a proven read-only tool set where the agent offers one, and with the agent's own restricted mode, and no transcript handed to it, where that restriction is configuration rather than a flag we can verify. So working-tree content and transcript excerpts reach the vendor you already chose, on your own account, without a fresh action from you for each call. +

+

+ It never borrows a different vendor's agent to supervise a session unless you pick one yourself, and those runs are kept out of your own session history. +

+
+
+

antgrid does not sandbox your agent

+

+ An agent runs with your environment and your permissions, and an agent you have configured to skip approvals will skip them. Handler's destructive-path floor is an advisory floor, not a sandbox. +

+

+ In an isolated session, the branch's own setup steps run as shell lines — the same trust class as the commands you run yourself. Escapes out of the checkout path are refused, but the commands themselves are branch content. +

+
+
+
+
+ +
+ Honest inventory +

What doesn't exist yet.

+

+ Every item here is something a reasonable reviewer will ask for and we do not have. None of it is written as a feature. +

+
+ {gaps.map((g) => ( +
+

{g.title}

+

{g.body}

+
+ ))} +
+

+ What we collect, and for how long, is set out on the privacy page. +

+
+ +
+
+ Verify it +

Check the crypto claims yourself.

+

+ The handshake is specified in public down to the byte layout of the signed transcript, with cross-language test vectors that both the TypeScript and the Dart implementation must pass. The wire protocol and the client-side encryption are Apache-2.0 and carry their own licence files, so you can read, reimplement and publish work built on them without asking us. +

+

+ The rest of antgrid is source-available under the Elastic License 2.0. That is not OSI-approved open source and we do not call it that. +

+ +
+
+ +
+ Disclosure +

Reporting a vulnerability.

+

+ If you can read traffic the relay is not supposed to read, or run something on a machine without its owner's account and consent, we want to hear about it before anyone else does. Please don't open a public issue, discussion or pull request for it. +

+
+
+

Email

+ contact@radhaai.com +

Put "Security" in the subject line.

+
+
+

GitHub private advisory

+ + + Report a vulnerability + +

Preferred, and it keeps the thread on the repository.

+
+
+

+ We acknowledge reports within three business days, tell you what we found, keep you updated while we fix it, and credit you in the release notes if you want it. We ask for a reasonable window before public disclosure. Please test against your own account and your own machines. +

+

+ What is in scope and what is not is listed in SECURITY.md. Machine-readable contact details are at /.well-known/security.txt. +

+
+
+ diff --git a/site/src/pages/support.md b/site/src/pages/support.md index e0cd59a3..1dbd0973 100644 --- a/site/src/pages/support.md +++ b/site/src/pages/support.md @@ -13,7 +13,7 @@ Need a hand? Email **[contact@radhaai.com](mailto:contact@radhaai.com)** and we' ## What is Antgrid? -Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Gemini CLI** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. +Antgrid lets you run AI coding agents such as **Claude Code**, **Codex**, and **Cursor** on your own computer, then monitor and control them from your phone, tablet, or desktop over an end-to-end encrypted connection. You see the agent's live terminal output, browse the project's files, and stay in control from anywhere — and Handler, the built-in assistant, marks nothing done without evidence. ## Supported platforms @@ -32,19 +32,20 @@ Your agent's live output, files, and sessions appear once a project is running. ## Signing in -Antgrid supports three sign-in options: +Antgrid supports four sign-in options: - **Email magic link** — enter your email, open the link we send you, then press **"Approve sign-in"** on the page it opens. - **GitHub** - **Google** +- **Email and password** — available once you have set a password on your account; verify the address first. **Didn't get your magic-link email?** Check your spam folder and request a new link from the app. Links are single-use and expire after **10 minutes**. ## Plans and billing -**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are the launch prices. +**Antgrid is currently free while in beta** — every feature is included, Handler and remote control alike, on the house. Paid plans activate when the beta ends; the prices on [Pricing](/pricing) are founding prices, below the list price at launch. -Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams, SSO, audit logs and IP allowlisting are **Enterprise** — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. +Once plans are live: **Antgrid is free on one worker machine**, with end-to-end encrypted **remote control**, fleet view and browser preview included. **Pro is billed per seat — one seat per person** — and gives every person up to **10 worker machines** of their own, plus **Handler** — the AI assistant that watches your sessions, takes instructions mid-run, and judges every item against evidence — and priority support. Pro includes a **7-day free trial** and covers up to **25 seats**. Larger teams are **Enterprise**, where SSO, audit logs and IP allowlisting are on the roadmap — email us at [contact@radhaai.com](mailto:contact@radhaai.com). See [Pricing](/pricing) for current details. **Manage or cancel your subscription:** diff --git a/site/src/pages/terms.md b/site/src/pages/terms.md index ee644009..c754d37e 100644 --- a/site/src/pages/terms.md +++ b/site/src/pages/terms.md @@ -25,7 +25,7 @@ The Services are intended for users who are at least 18 years old. ## 1. Our Services -Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Local use of Antgrid is free.** Paid plans add encrypted remote control and related features (see Section 5). +Antgrid is a command centre for AI coding agents that you run on your own machine and can monitor and control remotely from your other devices. **Antgrid has a free plan, and encrypted remote control is included on it.** Paid plans add more worker machines per person, the Handler assistant, and team seats (see Section 5). The Services are not intended for use in any jurisdiction where such use would be contrary to law or would subject us to any registration requirement. You access the Services on your own initiative and are responsible for compliance with applicable local laws. diff --git a/site/src/styles/global.css b/site/src/styles/global.css index c74883be..b36dc630 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -112,6 +112,42 @@ so they can't drift apart. Prose caps (max-w-xl/2xl) are deliberately NOT tied to this: body copy stays at a readable measure however wide the shell. */ --container-shell: 72rem; + + /* ---- App material ----------------------------------------------------- + The APP's palette (app/lib/design/ab_colors.dart), carried here so the + hand-built app shell reads as the product sitting on the page rather than + as more page with a border around it. + + These are deliberately NOT reconciled with the site ramp above. The app is + Zinc-neutral and the site is warm-neutral, and that difference is the only + thing doing the work — an app window painted in --color-panel is just a + card. Keep every `ab-` token pointing at the Dart value it mirrors; the two + drifting apart is silent and shows up as a window that looks almost right. + + The accent is absent on purpose: the app's `accent`/`accentHighlight`/ + `accentMuted` are ALREADY #db6f4b / #ea997f / #d2542a, byte-identical to + --color-signal / signal2 / signalbtn above, so the shell uses those. */ + --color-ab-deepest: #09090b; + --color-ab-deep: #0c0c0f; + --color-ab-surface: #18181b; + --color-ab-raised: #1f1f23; + --color-ab-elevated: #27272a; + --color-ab-selected: #2f2f35; + --color-ab-line-soft: #1a1a1f; + --color-ab-line: #27272a; + --color-ab-line-strong: #3f3f46; + --color-ab-text: #e4e4e7; + --color-ab-text2: #a1a1aa; + --color-ab-mute: #71717a; + --color-ab-dim: #52525b; + /* Status tones. `attn` is the app's statusAttention and it means what amber + means everywhere else on this site: a human is needed. Nothing else in a + scene may take it. */ + --color-ab-run: #8fcfae; + --color-ab-think: #e2c792; + --color-ab-attn: #e5a055; + --color-ab-ok: #22c55e; + --color-ab-err: #f87171; } html { @@ -136,11 +172,41 @@ body { /* ---- Atmosphere -------------------------------------------------------- */ -/* Signal radial glow — position with inset utilities on an absolute wrapper. */ +/* Signal radial glow — position with inset utilities on an absolute wrapper. + Only safe where a frame contains it (the closing CTA's bordered card); the hero + is full-bleed and needs .glow-hero below. */ .glow-signal { background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%); } +/* The hero's light, which is two different devices because a phone has no room + for the desktop one. Wide: a compact halo about a third of the frame across, + with dark air either side — the falloff is visible on every axis, which is the + only reason it reads as a light source at all. Narrow: that same fixed 980px + halo is wider than the viewport, so no falloff lands inside the frame and the + light flattens into a brown tint over the headline, ending in one hard + horizontal terminus under the kicker. + So below md it stops being an orb and becomes an edge: the gradient's centre + sits ON the top edge, so there is never a circle to resolve, and the falloff + runs down the one axis a phone has room for. Alpha drops with it — 0.16 across + the whole of a small frame is a far larger event than 0.16 across a third of a + large one, and it is sitting behind the lowest-contrast text on the page. */ +.glow-hero { + position: absolute; + inset: 0 0 auto 0; + height: 340px; + background: radial-gradient(ellipse 150% 100% at 50% 0%, rgba(210, 84, 42, 0.11), transparent 72%); +} +@media (min-width: 48rem) { + .glow-hero { + inset: -14rem auto auto 50%; + height: 620px; + width: min(980px, 68vw); + transform: translateX(-50%); + background: radial-gradient(closest-side, rgba(210, 84, 42, 0.16), transparent 70%); + } +} + /* The namesake, made literal. Ruled lines at one pitch (square cells at any zoom) plus a scattered handful of filled cells: a rack where most machines are idle and a few are working, which is the picture the page is selling before a @@ -167,6 +233,17 @@ body { .live-cells { mask-image: radial-gradient(130% 85% at 50% 8%, black 8%, transparent 72%); } +/* Not below md. The cells are placed in raw px against a wide canvas, so on a + phone only the leftmost column is on screen and every one of those sits behind + the copy rather than beside it — a warm block fading in and out under muted + body text reads as a rendering fault, not as a machine waking up. The static + field stays; liveness is carried in the same viewport by the beta pill and by + the ProofCard's own loop, which says it far better than a background can. */ +@media (max-width: 47.999rem) { + .live-cells { + display: none; + } +} .live-cells rect { fill: rgba(210, 84, 42, 0.14); opacity: 0; @@ -283,3 +360,84 @@ html.js .reveal.in { transition: none; } } + + +/* ---- App shell --------------------------------------------------------- */ + +/* The window's pane geometry. In CSS rather than utilities because the three + columns collapse in a specific ORDER as width runs out — the context pane + first, then the rail — and that sequence is one decision that belongs in one + place, not spread across three responsive prefixes on three elements. */ +.appwin-panes { + display: grid; + grid-template-columns: minmax(0, 1fr); +} +.appwin-rail, +.appwin-ctx { + display: none; +} +@media (min-width: 40rem) { + .appwin-panes { + grid-template-columns: 12.5rem minmax(0, 1fr); + } + .appwin-rail { + display: block; + } +} +/* Only the three-pane variant ever gets the context column back, and only once + the agent pane can still hold a terminal line without wrapping it. The + context column is sized by its tab strip, not by its content: Preview / + Files / Git / Terminals / Handler is the app's full set and all five have to + fit on one line, because a wrapped or clipped strip is the one detail that + gives a hand-built window away. */ +@media (min-width: 64rem) { + .appwin-panes--3 { + grid-template-columns: 13rem minmax(0, 1fr) 21rem; + } + .appwin-panes--3 .appwin-ctx { + display: block; + } +} +@media (min-width: 80rem) { + .appwin-panes { + grid-template-columns: 14rem minmax(0, 1fr); + } + .appwin-panes--3 { + grid-template-columns: 14rem minmax(0, 1fr) 24rem; + } +} + +/* The hero window runs off the bottom of its section, so the reader feels the + app continue past the fold rather than watching a card end. The fade is a + sibling rather than a pseudo-element on the window itself — as a child it + would sit under the window's own border and leave a bright hairline across + the point the page is trying to dissolve. */ +.appwin-bleed > .appwin { + border-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.appwin-caret { + display: inline-block; + width: 0.5em; + height: 1.05em; + vertical-align: -0.18em; + background: var(--color-signal); + animation: ab-caret 1.1s steps(2) infinite; +} +@keyframes ab-caret { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .appwin-caret { + animation: none; + } +} diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts index 90eadc6b..2015def5 100644 --- a/site/tests/contracts.spec.ts +++ b/site/tests/contracts.spec.ts @@ -14,6 +14,9 @@ const DOWNLOADS = { linux: "https://github.com/antgrid-ai/antgrid/releases/latest/download/antgrid-linux.AppImage", }; +// The web service, which is a different origin from this static build. +const WAITLIST_ORIGIN = "https://app.antgrid.ai"; + test("desktop downloads point at the published release artifacts", async ({ page }) => { await page.goto("/#download"); const band = page.locator("#download"); @@ -59,14 +62,22 @@ test("the paid path stays closed: no checkout links anywhere", async ({ page }) } }); -test("charging plans render a disabled button, never a live CTA", async ({ page }) => { +test("charging plans capture interest, never a live checkout CTA", async ({ page }) => { await page.goto("/pricing"); - // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for a - // disabled button. Asserted by state, not by label — the label is BETA_FREE-gated. + // Any card carrying `comingSoon` (pricing.ts) must swap its checkout link for the + // founding-price capture. Asserted by state, not by label — copy is BETA_FREE-gated. const yearlyCard = page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator(".."); - await expect(yearlyCard.locator("button[disabled]")).toHaveCount(1); + const capture = yearlyCard.locator("form[data-waitlist]"); + await expect(capture).toHaveCount(1); + // The address goes to the web service, cross-origin from this static site. + await expect(capture).toHaveAttribute("action", `${WAITLIST_ORIGIN}/api/waitlist`); + await expect(capture).toHaveAttribute("data-waitlist", "pricing"); await expect(yearlyCard.locator("a[href]")).toHaveCount(0); + // The capture ships disabled so a scriptless reader is told to email instead; once + // the page's script has run nothing in the card may still be inert, or the dead + // paid CTA is back under a new name. + await expect(yearlyCard.locator("button[disabled]")).toHaveCount(0); // The free card is the one plan whose CTA stays live. const freeCard = page.locator("span.font-mono", { hasText: /^Free$/ }).locator("..").locator(".."); @@ -85,7 +96,7 @@ for (const path of ["/pricing", "/terms", "/refunds", "/support"]) { // Indexed pages. og-card is excluded on purpose: it is the screenshot source for // the social card, already noindex and filtered out of the sitemap. -const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds"]; +const INDEXED = ["/", "/pricing", "/get-started", "/support", "/privacy", "/terms", "/refunds", "/security"]; test("every indexed page ships a description search engines will show whole", async ({ page }) => { // 155 is where Google starts truncating. Social previews cut earlier — mobile @@ -99,6 +110,17 @@ test("every indexed page ships a description search engines will show whole", as } }); +// The filename tracks what the card SAYS (Seo.astro), so a recut renames it — +// and the rename is a string in Seo.astro that nothing else checks. Get it wrong +// and og:image 404s: every shared link loses its preview, on every page at once, +// with the site otherwise green. Assert the file, never the name. +test("the social card the meta tag names is actually in the build", async ({ page }) => { + await page.goto("/"); + const src = await page.locator('meta[property="og:image"]').getAttribute("content"); + const res = await page.request.get(new URL(src!).pathname); + expect(res.status(), `og:image is missing from the build: ${src}`).toBe(200); +}); + test("the social card declares its dimensions so previews reserve the box", async ({ page }) => { // Without these a client fetches the PNG before it can size the card, and the // preview reflows around it — or renders the link bare while it waits. @@ -108,3 +130,43 @@ test("the social card declares its dimensions so previews reserve the box", asyn const alt = await page.locator('meta[property="og:image:alt"]').getAttribute("content"); expect(alt, "the card carries no alt text").toBeTruthy(); }); + +// Every in-page anchor the site links to must exist. home.spec.ts's dead-link +// sweep skips "/#..." hrefs — it resolves them over HTTP, where the fragment is +// never sent — so a renamed section id breaks navigation with nothing red. These +// are the only links on the site that can rot silently. +test("every in-page anchor the nav and footer offer has a section to land on", async ({ page }) => { + await page.goto("/"); + const fragments = await page.locator("a[href^='/#'], a[href^='#']").evaluateAll((els) => + [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!.split("#")[1]))] + ); + expect(fragments.length, "the home page offers no in-page anchors at all").toBeGreaterThan(0); + for (const id of fragments) { + await expect(page.locator(`#${id}`), `nothing on the page has id="${id}"`).toHaveCount(1); + } +}); + +// Features has to open on the paid feature. Handler is the only thing anyone pays +// for and its section sits ABOVE the fleet view, so aiming this at #fleet scrolled +// the reader straight past it — a revenue link that resolved fine and pointed at +// the wrong thing, which is why it is pinned by target here rather than by wording. +test("Features opens the section that sells Handler", async ({ page }) => { + await page.goto("/"); + const features = page.getByRole("link", { name: /^Features$/ }); + expect(await features.count(), "no Features link").toBeGreaterThan(0); + for (let i = 0; i < (await features.count()); i++) { + await expect(features.nth(i)).toHaveAttribute("href", "/#handler"); + } + await expect(page.locator("#handler")).toContainText("Handler"); +}); + +// The 404 template answers EVERY unknown path, so without this a mistyped inbound +// link can be indexed under its own URL as a page that says nothing exists. +test("the not-found page is kept out of the index", async ({ page }) => { + await page.goto("/404"); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", /noindex/); + + // Real pages must NOT inherit it — a stray default here delists the whole site. + await page.goto("/"); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); +}); diff --git a/site/tests/home.spec.ts b/site/tests/home.spec.ts index 2e894c3a..47b13801 100644 --- a/site/tests/home.spec.ts +++ b/site/tests/home.spec.ts @@ -41,12 +41,28 @@ test("privacy shows relay's-eye view and crypto chips", async ({ page }) => { await expect(page.getByText("AES-256-GCM")).toBeVisible(); }); -test("cross-agent shows agents and the 3 steps", async ({ page }) => { +// The roster lives in #agents now, not in the cross-agent band — asserting the +// chip from an unscoped page locator kept this test green off the OTHER section. +test("cross-agent shows the 3 steps", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: /bring the agent you already use\./i })).toBeVisible(); - await expect(page.getByText("any terminal agent")).toBeVisible(); - await expect(page.getByText("Windows, macOS, Linux")).toBeVisible(); - await expect(page.getByText("Take it with you")).toBeVisible(); + const section = page.locator("section").filter({ hasText: "Bring the agent you already use." }); + await expect(section.getByRole("heading", { name: /bring the agent you already use\./i })).toBeVisible(); + await expect(section.getByText("Windows, macOS, Linux")).toBeVisible(); + await expect(section.getByText("Take it with you")).toBeVisible(); +}); + +// The only wording assertion in this file, and it is not marketing copy: the +// supervised three are whatever `handlerObservable` answers true for in +// bridge/src/agents/registry.ts, so a fourth chip going accent — or the prose +// falling out of step with the chips — is a false capability claim, not a +// rewrite. The catch-all chip is the free-tier promise beside it. +test("the agent roster names the supervised three and a catch-all", async ({ page }) => { + await page.goto("/#agents"); + const agents = page.locator("#agents"); + for (const name of ["Claude Code", "Codex", "opencode"]) { + await expect(agents.getByText(name, { exact: true })).toHaveCount(2); + } + await expect(agents.getByText("any terminal agent")).toBeVisible(); }); test("closing CTA band renders with app stores still pending", async ({ page }) => { @@ -64,7 +80,7 @@ test("no horizontal overflow on mobile", async ({ page }) => { }); test("internal links resolve (no dangling hrefs to missing pages)", async ({ page }) => { - const removedPages = ["/docs", "/security"]; + const removedPages = ["/docs"]; for (const startPath of ["/", "/pricing", "/get-started"]) { await page.goto(startPath); diff --git a/site/tests/pricing.spec.ts b/site/tests/pricing.spec.ts index 94013ca0..4a28947c 100644 --- a/site/tests/pricing.spec.ts +++ b/site/tests/pricing.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; // Prices and the tier axis. KEEP IN LOCKSTEP with src/data/pricing.ts, which is itself // pinned to the shipped catalog by web/tests/billing/site-pricing-lockstep.test.ts — @@ -23,15 +23,133 @@ test("plan cards carry the real prices, machine allowances and seat ceiling", as await expect(freeCard.locator("[data-price]", { hasText: "$0" })).toBeVisible(); await expect(freeCard.getByText("1 worker machine")).toBeVisible(); - // Yearly card: $49 offer price (the headline figure) + $99 struck list price, both - // per seat — the unit is the claim, so it is asserted beside the number. + // Yearly card: $49 founding price (the headline figure) and $99 named as the + // price at launch. The unit is part of the claim, so it is asserted beside the + // number. $99 must never render as a struck-through former price — it has never + // been charged, so a crossed-out "was" would be a reference price we invented. await expect(yearlyCard.locator("[data-price]", { hasText: "$49" })).toBeVisible(); - await expect(yearlyCard.locator("span.line-through", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.locator("[data-list]", { hasText: "$99" })).toBeVisible(); + await expect(yearlyCard.getByText(/Founding price/)).toBeVisible(); + await expect(yearlyCard.locator("s, del, .line-through")).toHaveCount(0); await expect(yearlyCard.getByText("/ seat / year")).toBeVisible(); await expect(yearlyCard.getByText("Up to 10 worker machines per person")).toBeVisible(); await expect(yearlyCard.getByText(/Up to 25 seats/)).toBeVisible(); }); +// Founding-price capture. The paid card's CTA is an interest form, not a checkout — +// contracts.spec.ts pins its target and the closed paid path; these cover what the +// reader actually experiences at the control. + +const capture = (page: Page) => + page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..").locator("form[data-waitlist]"); + +test("the capture asks for an address without naming a price", async ({ page }) => { + await page.goto("/pricing"); + const form = capture(page); + + // The waitlist trades on "founding pricing", never on a figure or a struck anchor — + // an address is not consent to a price. + await expect(form).not.toContainText("$"); + await expect(form.locator("s, del, .line-through")).toHaveCount(0); + + // Accessibility floor: a real label (visually hidden is fine), an email field, and a + // status line the reader's screen reader is told about. + const field = form.getByLabel(/email address/i); + await expect(field).toHaveAttribute("type", "email"); + await expect(form.locator("[aria-live]")).toHaveCount(1); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); +}); + +test("joining posts the address with the surface it came from", async ({ page }) => { + const posted: unknown[] = []; + await page.route("**/api/waitlist", async (route) => { + posted.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // One verb throughout: the button says Join, so the confirmation says joined. + await expect(form.locator("[aria-live]")).toContainText(/joined the list/i); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); + expect(posted).toEqual([{ email: "founder@example.com", source: "pricing" }]); +}); + +test("the control says it is working while the address is in flight", async ({ page }) => { + let release = () => {}; + const held = new Promise((resolve) => (release = resolve)); + await page.route("**/api/waitlist", async (route) => { + await held; + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: true }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + // Same verb in every state, so the reader never wonders whether a second thing + // happened: Join -> Joining -> Joined. + await expect(form.getByRole("button", { name: /^Joining/ })).toBeDisabled(); + release(); + await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible(); +}); + +test("a malformed address is refused at the field, before anything is sent", async ({ page }) => { + let requests = 0; + await page.route("**/api/waitlist", async (route) => { + requests += 1; + await route.fulfill({ status: 200, headers: { "access-control-allow-origin": "*" }, body: "{}" }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + await expect(form.locator("[aria-live]")).toContainText(/does not look like an email address/i); + // The error says what to do next and leaves the control usable, rather than + // dead-ending the way the button it replaced did. + await expect(form.locator("[aria-live]")).toContainText(/try again/i); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + expect(requests).toBe(0); +}); + +test("a rejected address explains itself and leaves the reader able to retry", async ({ page }) => { + // The API answers a rejection with a machine code, so the page owes the reader + // its own sentence — echoing "BAD_REQUEST" back at them is not an explanation. + await page.route("**/api/waitlist", async (route) => { + await route.fulfill({ + status: 400, + headers: { "content-type": "application/json", "access-control-allow-origin": "*" }, + body: JSON.stringify({ ok: false, error: "BAD_REQUEST" }), + }); + }); + + await page.goto("/pricing"); + const form = capture(page); + await form.getByLabel(/email address/i).fill("founder@example.com"); + await form.getByRole("button", { name: /^Join the list$/ }).click(); + + const status = form.locator("[aria-live]"); + await expect(status).toContainText(/not accepted/i); + await expect(status).toContainText(/try again/i); + await expect(status).not.toContainText("BAD_REQUEST"); + await expect(form.getByRole("button", { name: /^Join the list$/ })).toBeEnabled(); + await expect(form.getByLabel(/email address/i)).toBeEditable(); +}); + test("the FAQ answers the seat and machine questions in place", async ({ page }) => { await page.goto("/pricing"); await expect(page.getByRole("heading", { name: /what counts as a seat\?/i })).toBeVisible(); diff --git a/site/tests/security.spec.ts b/site/tests/security.spec.ts new file mode 100644 index 00000000..b9aab4d0 --- /dev/null +++ b/site/tests/security.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from "@playwright/test"; + +// The page only works if a reader can go and check it, so what is asserted here +// is the structure that makes that possible: the sections a sceptical reader is +// sent to, the outbound links that let them read the crypto themselves, and +// security.txt actually being served. Prose inside the sections is deliberately +// not asserted — see the note at the top of home.spec.ts. + +const REPO = "https://github.com/antgrid-ai/antgrid"; + +test("security page renders with one h1 and the sections it promises", async ({ page }) => { + await page.goto("/security"); + const h1 = page.getByRole("heading", { level: 1 }); + await expect(h1).toHaveCount(1); + expect((await h1.innerText()).trim().length).toBeGreaterThan(0); + + await expect(page.getByRole("heading", { name: /what the relay does see/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /three things have to be true/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /exist yet/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /reporting a vulnerability/i })).toBeVisible(); + + // The relay section is the page's central claim: both halves of the ledger + // must render, not just the flattering one. + const relay = page.locator("#relay"); + await expect(relay.getByText("in cleartext at the relay")).toBeVisible(); + await expect(relay.getByText("never at the relay")).toBeVisible(); +}); + +test("the verification links point at the public repository", async ({ page }) => { + await page.goto("/security"); + // Asserted as targets rather than fetched: these are third-party URLs, and a + // GitHub outage must not be able to fail the site suite. `.first()` because + // each of these is offered twice — once above the fold, once in the verify + // list — and a second copy appearing is not a regression. + await expect(page.getByRole("link", { name: "Repository" }).first()).toHaveAttribute("href", REPO); + await expect(page.getByRole("link", { name: "SECURITY.md" }).first()).toHaveAttribute( + "href", + `${REPO}/blob/HEAD/SECURITY.md` + ); + await expect(page.getByRole("link", { name: "packages/antgrid_relay_client" }).first()).toHaveAttribute( + "href", + `${REPO}/tree/HEAD/packages/antgrid_relay_client` + ); + await expect(page.getByRole("link", { name: /report a vulnerability/i })).toHaveAttribute( + "href", + `${REPO}/security/advisories/new` + ); + await expect(page.getByRole("link", { name: "contact@radhaai.com" })).toHaveAttribute( + "href", + /^mailto:contact@radhaai\.com/ + ); +}); + +test("every internal link on the page resolves", async ({ page }) => { + await page.goto("/security"); + const hrefs = await page.locator("a[href^='/']").evaluateAll((els) => + [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!))].filter((h) => !h.startsWith("/#")) + ); + expect(hrefs.length).toBeGreaterThan(0); + for (const href of hrefs) { + const res = await page.request.get(href); + expect(res.status(), `dead link on /security: ${href}`).toBeLessThan(400); + } +}); + +test("security.txt is served with the fields a scanner reads", async ({ page }) => { + const res = await page.request.get("/.well-known/security.txt"); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toContain("Contact: mailto:contact@radhaai.com"); + expect(body).toContain("Canonical: https://antgrid.ai/.well-known/security.txt"); + expect(body).toContain("Preferred-Languages:"); + // RFC 9116 treats an expired file as stale, so the date has to stay ahead of + // the reader — bump it, never drop the field. + const expires = body.match(/^Expires: (.+)$/m); + expect(expires, "security.txt has no Expires field").toBeTruthy(); + expect(new Date(expires![1]).getTime()).toBeGreaterThan(Date.now()); +}); + +test("the footer routes readers to the security page", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("footer").getByRole("link", { name: "Security" })).toHaveAttribute("href", "/security"); +}); + +test("no horizontal overflow on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/security"); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1); + expect(overflow).toBe(false); +}); diff --git a/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql new file mode 100644 index 00000000..df5bb1ae --- /dev/null +++ b/web/prisma/migrations/20260901000000_add_waitlist_signup/migration.sql @@ -0,0 +1,21 @@ +-- Add waitlist_signup for the marketing site's launch-interest capture. +-- Rows are written by the anonymous, cross-origin POST /api/waitlist route; no +-- FK to user — a signup happens long before an account exists. +-- +-- The unique index on "email" is load-bearing, not hygiene: the route inserts +-- with ON CONFLICT DO NOTHING so a repeat submit is a silent no-op answered +-- with the same 200 as a first submit. Without it a second submit would create +-- a duplicate row, and any later de-dup would have to distinguish the two — +-- which is exactly the membership fact the endpoint must not expose. + +CREATE TABLE "waitlist_signup" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + + CONSTRAINT "waitlist_signup_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "waitlist_signup_email_key" ON "waitlist_signup" ("email"); +CREATE INDEX "waitlist_signup_created_at_idx" ON "waitlist_signup" ("created_at"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 5a7f26ea..5fea2e22 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -367,6 +367,21 @@ model AnalyticEvent { @@map("analytic_event") } +// ---------- Marketing waitlist ---------- + +model WaitlistSignup { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + // Unique so a re-submit is an ON CONFLICT DO NOTHING rather than a duplicate + // row — POST /api/waitlist answers 200 either way and must never reveal which + // of the two happened. Stored already lowercased/trimmed by the route. + email String @unique(map: "waitlist_signup_email_key") + source String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([createdAt], map: "waitlist_signup_created_at_idx") + @@map("waitlist_signup") +} + // ---------- Better-Auth OAuth Provider plugin ---------- model OauthClient { diff --git a/web/src/app.ts b/web/src/app.ts index 03ba8d5b..68b5c5f4 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -14,6 +14,7 @@ import { devBillingRoutes } from "./routes/dev-billing.js"; import { oauthHandoffRoutes } from "./routes/oauth-handoff.js"; import { oauthStartRoutes } from "./routes/oauth-start.js"; import { eventsRoutes } from "./routes/events.js"; +import { waitlistRoutes } from "./routes/waitlist.js"; import { uiRoutes } from "./routes/ui.js"; import { setPublicOrigin } from "./ui/origin.js"; import type { DB } from "./db/index.js"; @@ -73,6 +74,10 @@ export function buildApp(deps: AppDeps) { credentials: true, allowHeaders: ["content-type", "authorization"], allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + // Without this the fetch spec caches a preflight for 5 seconds, so every + // retry on a cross-origin JSON POST (the marketing site's waitlist form) + // pays a second round trip before the one that carries the body. + maxAge: 86400, }) ); @@ -110,6 +115,7 @@ export function buildApp(deps: AppDeps) { }); app.route("/", health); app.route("/", eventsRoutes({ db: deps.db, clientIp })); + app.route("/", waitlistRoutes({ db: deps.db, clientIp })); app.route("/", deviceRoutes({ db: deps.db, auth: deps.auth, relay: deps.relay })); app.route("/", agentRoutes({ db: deps.db, auth: deps.auth, env: deps.env })); app.route("/", subscriptionRoutes({ db: deps.db, auth: deps.auth })); diff --git a/web/src/routes/ui.tsx b/web/src/routes/ui.tsx index 2f9a8122..848f00da 100644 --- a/web/src/routes/ui.tsx +++ b/web/src/routes/ui.tsx @@ -1535,10 +1535,10 @@ export function uiRoutes(deps: { const userId = c.get("userId"); await provisionProductAccountForUser(deps.db, userId); const plans = await listActivePlans(deps.db); - // TEMP-PROMO: every plan renders as a disabled "Coming soon" card while - // in-app purchases aren't live — grep "TEMP-PROMO" repo-wide for every - // related spot (backend grant logic in web/src/models/subscription.ts - // plus the matching disabled UI in web/src/ui/pricing.tsx). + // TEMP-PROMO: no plan can be bought while in-app purchases aren't live, so + // the Pro card takes waitlist signups instead of running a checkout — grep + // "TEMP-PROMO" repo-wide for every related spot (backend grant logic in + // web/src/models/subscription.ts plus the static UI in web/src/ui/pricing.tsx). // // TO RESTORE ONCE PAYMENT INTEGRATION SHIPS: delete the `const plans =` // line above and the `c.html(...)` call below, then uncomment the two @@ -1555,7 +1555,7 @@ export function uiRoutes(deps: { // if (plan && isPlanId(plan.slug)) currentPlanSlug = plan.slug; // } return c.html( - + // s.trim().toLowerCase()) + .pipe(z.email().max(254)), + source: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9_-]*$/), +}); + +// Anonymous public writer keyed on an attacker-chosen email: burst 5, refill +// 1 per 10s per IP. Far tighter than the analytics ingest — a human submits +// this form once, and the row it writes is not idempotent per-IP the way an +// event batch is. +const signupLimiter = tokenBucket(5, 0.1); + +export function waitlistRoutes(deps: { db: DB; clientIp: ClientIpResolver }) { + const r = new Hono(); + + r.post("/api/waitlist", async (c) => { + // Spoof-safe resolution (peer + trusted-proxy XFF walk); the IP is used + // only for this bucket and is deliberately never stored on the row. + const ip = deps.clientIp(c) ?? "unknown"; + if (!signupLimiter(ip)) return c.json({ ok: false, error: "RATE_LIMITED" }, 429); + + // A bare code, no `issues`: this endpoint answers any origin anonymously and + // neither client reads the detail — both pick their wording from the status — + // so echoing Zod's paths and received values back is reach with no caller. + const parsed = Signup.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ ok: false, error: "BAD_REQUEST" }, 400); + + // createMany + skipDuplicates emits INSERT ... ON CONFLICT DO NOTHING, so + // two concurrent submits of the same address cannot race into a unique + // violation — which would surface as app.onError's 500 and tell the caller + // the address was already on the list. + await deps.db.waitlistSignup.createMany({ + data: [{ email: parsed.data.email, source: parsed.data.source }], + skipDuplicates: true, + }); + + // Identical response whether the row was inserted or already existed: + // membership in the list is not something a stranger may probe for. + return c.json({ ok: true }, 200); + }); + + return r; +} diff --git a/web/src/ui/asset.ts b/web/src/ui/asset.ts index 0dc7d870..76d694e1 100644 --- a/web/src/ui/asset.ts +++ b/web/src/ui/asset.ts @@ -15,6 +15,7 @@ const ENTRIES = { checkout: "src/ui/entries/checkout.ts", dashboard: "src/ui/entries/dashboard.ts", devices: "src/ui/entries/devices.ts", + waitlist: "src/ui/entries/waitlist.ts", } as const; // Fonts reach the manifest as dependencies of styles.css, not as inputs, so diff --git a/web/src/ui/entries/waitlist.ts b/web/src/ui/entries/waitlist.ts new file mode 100644 index 00000000..88e7c914 --- /dev/null +++ b/web/src/ui/entries/waitlist.ts @@ -0,0 +1,143 @@ +/** + * Founding-price waitlist capture on /pricing. + * + * Not htmx: the target is the public JSON endpoint the marketing site posts to + * as well (POST /api/waitlist), so there is no fragment to swap and no redirect + * to follow — the same reason entries/devices.ts issues its own request rather + * than going through the vendored htmx bundle. + */ + +const IDLE_LABEL = "Join the waitlist"; +const BUSY_LABEL = "Joining…"; +const DONE_LABEL = "Joined"; + +/** Confirms in the button's own words. "Submitted" would leave the reader + * guessing whether the thing they joined is the thing that answered. */ +const SUCCESS_NOTE = "You're on the waitlist. Founding pricing at launch."; + +const STATUS_BASE = "text-xs text-center mt-3 min-h-10"; +const STATUS_TONE = { + idle: "text-faint", + ok: "text-ink2", + error: "text-error", +} as const; + +type Tone = keyof typeof STATUS_TONE; + +/** + * The endpoint answers a bare code, and a code is not an instruction — each + * status has to say what the server did with the address and what the reader + * does next. No apology: nothing here is broken, and "sorry" would be the only + * word in the sentence that carries no information. + */ +function messageForStatus(status: number): string { + if (status === 400) { + return "That address wasn't accepted. Check it and submit again."; + } + if (status === 429) { + return "Too many submissions from this network. Wait a minute, then submit again."; + } + // A 2xx that did not carry `ok` is not a rejection — something in front of the + // endpoint answered instead of it, and quoting its status would explain nothing. + if (status < 400) { + return "The waitlist didn't answer. Submit again in a moment."; + } + return `The server rejected the request (HTTP ${status}). Submit again in a moment.`; +} + +// The status line sits OUTSIDE the form, so it is reached through the card +// wrapper rather than the form — everything else is scoped to the form itself, +// which is what lets a second card on the page drive its own controls. +function bind(form: HTMLFormElement): void { + const input = form.querySelector('input[type="email"]'); + const button = form.querySelector("[data-waitlist-submit]"); + const status = form + .closest("[data-waitlist-card]") + ?.querySelector("[data-waitlist-status]"); + if (!input || !button || !status) return; + + const setStatus = (message: string, tone: Tone) => { + status.textContent = message; + status.className = `${STATUS_BASE} ${STATUS_TONE[tone]}`; + }; + + const toIdle = () => { + button.disabled = false; + button.textContent = IDLE_LABEL; + }; + + // Disabling the control a reader just activated blurs it and focus falls to + // , so their next Tab restarts at the top of the page. Reclaim it only if + // that is in fact where it went — someone who tabbed on keeps their place. Not + // folded into toIdle(), which also runs at bind time, when focus is legitimately + // on and stealing it would scroll the page to this card on load. + const reclaimFocus = (el: HTMLElement) => { + if (document.activeElement === document.body) el.focus(); + }; + + // The markup ships it disabled so a page whose script never ran cannot fire a + // native urlencoded POST at a JSON endpoint. Enabling it here is what says the + // handler below is attached. + toIdle(); + + form.addEventListener("submit", (ev) => { + ev.preventDefault(); + // `disabled` is the whole re-entry guard: it blocks the click and the + // Enter-key implicit submit alike, and it survives the success path, which + // is terminal. + if (button.disabled) return; + + button.disabled = true; + button.textContent = BUSY_LABEL; + + void (async () => { + try { + const res = await fetch(form.action, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: input.value.trim(), + source: form.dataset.waitlist, + }), + }); + // `ok` is checked on the body as well as the status: a 200 from anything + // that is not this endpoint (a maintenance page, an error interstitial) + // must not be reported to the reader as a signup that was stored. + const body = (await res.json().catch(() => null)) as { ok?: boolean } | null; + + if (res.ok && body?.ok) { + // The endpoint answers identically for an address already on the list, + // so there is nothing to tell apart here — and telling them apart is + // exactly what it refuses to leak. + input.readOnly = true; + button.textContent = DONE_LABEL; + setStatus(SUCCESS_NOTE, "ok"); + // The button stays disabled for good on this path, so the blur above is + // permanent unless focus is placed somewhere. The input is the landing + // spot rather than the status line: still focusable when read-only, it + // sits before the message in tab order, and aria-describedby already + // points at that message. + reclaimFocus(input); + return; + } + + setStatus(messageForStatus(res.status), "error"); + toIdle(); + reclaimFocus(button); + } catch { + setStatus( + "The request never reached the server. Check your connection and submit again.", + "error", + ); + toIdle(); + reclaimFocus(button); + } + })(); + }); +} + +for (const form of document.querySelectorAll("form[data-waitlist]")) { + bind(form); +} + +export {}; diff --git a/web/src/ui/pricing.tsx b/web/src/ui/pricing.tsx index a3a1fedd..eeeaefc4 100644 --- a/web/src/ui/pricing.tsx +++ b/web/src/ui/pricing.tsx @@ -1,25 +1,20 @@ -import { Layout, PageHead } from "./layout.js"; -import { - BETA, - displayPriceCents, - formatUsd, - FREE_WORKER_LIMIT, - TRIAL_DAYS, - type BillingEnv, -} from "../billing/plans.js"; +import { Layout } from "./layout.js"; +import { asset } from "./asset.js"; +import { FREE_WORKER_LIMIT } from "../billing/plans.js"; import type { PlanRow } from "../models/plan.js"; export type PricingPageProps = { user: { email?: string | null }; plans: PlanRow[]; - env: BillingEnv; }; -/** Why a plan can't be bought, in the CTA itself. "Coming soon" reads as - * half-built to someone who arrived from a site that told them the beta is - * free; naming the beta makes the disabled button an explanation. Keep the - * beta wording identical to PlanCard.astro's on the marketing site. */ -const UNAVAILABLE_CTA_LABEL = BETA ? "Available after beta" : "Coming soon"; +/** The public waitlist endpoint (web/src/routes/waitlist.ts), which the + * marketing site posts to as well — same origin as this page, so a relative + * action reaches it. `source` is the bounded slug its schema expects, naming + * the surface that captured the signup; the marketing site's own card sends + * "pricing", so this one has to differ or the two surfaces are one row. */ +const WAITLIST_ACTION = "/api/waitlist"; +const WAITLIST_SOURCE = "app_pricing"; /** Sales address for the contract-only plan. */ const ENTERPRISE_MAILTO = "mailto:contact@radhaai.com"; @@ -46,7 +41,10 @@ const PRO_YEARLY_FEATURES = [ const ENTERPRISE_FEATURES = [ "Unlimited seats", "Run agents on up to {workers} — per person", - "SSO, audit log & IP allowlist", + // Roadmap, not shipped: the capability flags exist on the plan model but + // nothing reads them yet. Keep in lockstep with the Enterprise strip on the + // marketing site (site/src/pages/pricing.astro) and support.md. + "SSO, audit log & IP allowlist — on the roadmap", "Invoiced annually", ] as const; @@ -108,31 +106,86 @@ function FeatureList({ ); } -function UnavailableCta({ footer }: { footer: string }) { +/** The founding-price capture, in the slot a plan's buy button will take back. + * + * Every element the script touches is found by data attribute from the form + * outwards, so a second copy of this card binds its own controls rather than + * driving the first one's. */ +function WaitlistCta({ email, id }: { email?: string | null; id: string }) { + // Ids are per instance for the same reason the script's lookups are scoped to + // the form: a second card on the page would otherwise duplicate them, and a + // duplicate `for` focuses the FIRST card's input from the second card's label. + const inputId = `${id}-email`; + const statusId = `${id}-status`; return ( -
- -

- {footer} +

+ {/* `action` names the real endpoint, but the submit button ships DISABLED + and the script enables it. Without that, a page whose script failed to + load would do a native urlencoded POST, and the endpoint reads JSON — + so the reader would be navigated off /pricing onto a raw error body. */} +
+ + {/* Prefilled with the signed-in address: /pricing is behind the session + gate, so asking for an address the page already knows reads as a + form that wasn't paying attention. Still editable — a personal + address is a fair answer to "tell me when this launches". */} + + +
+ {/* Idle note, error and confirmation all land here, on the `min-h-10` the + plan footers already reserve — so none of the three resizes the card. */} +

+ Founding pricing at launch.

+
); } export function PricingPage(props: PricingPageProps) { - const yearlyPrice = displayPriceCents("pro_yearly", props.env); - const trialPlan = props.plans.find((p) => p.slug === "trial"); const yearlyPlan = props.plans.find((p) => p.slug === "pro_yearly"); const enterprisePlan = props.plans.find((p) => p.slug === "enterprise"); return ( - {/* Headline and lede match PricingHeader.astro on the marketing site, - same as the beta CTA wording below — this is the same three plans for - the same reader, and "Simple, honest pricing" said nothing that the - site's line does not say better. Keep them in lockstep. */} + {/* Headline and lede match PricingHeader.astro on the marketing site — + this is the same three plans for the same reader, and "Simple, honest + pricing" said nothing that the site's line does not say better. Keep + them in lockstep. */}

Priced per person. Bring your own machines. @@ -145,62 +198,19 @@ export function PricingPage(props: PricingPageProps) {

- {trialPlan && ( - - )} -
{/* The free plan row is excluded from listActivePlans, so its worker count comes from the same constant that seeds it. */} - {yearlyPlan && } + {yearlyPlan && }
{enterprisePlan && } +