R5: marketing landing — light-theme public site at /
A new (marketing) route group with its own LIGHT layout (slate palette +
marketing coral #E95656 + periwinkle cards on near-white), the inverse of
the dark app. The middleware rewrites '/' → /marketing for logged-out
visitors (keyed on the session cookie / Clerk __session); authed users
fall through to the workspace app unchanged.
Built to the measured marketing system with OUR copy and honest claims —
NO SOC 2 / compliance badges: floating white pill nav, the 75px/800/-3px
display hero with a single coral keyword span, dual pill CTAs (coral
primary / white-bordered secondary), trust badges ('free credits',
'no credit card', 'self-hostable & air-gapped'), periwinkle feature
cards, a structural-security section (approval-gated, broker-held creds,
air-gappable — our real differentiators), an interactive FAQ accordion,
CTA band, and footer.
E2E (p7-marketing): renders for logged-out visitors, FAQ accordion works,
asserts NO SOC 2 claim, and an axe pass (coral CTAs sized to clear the
AA large-text threshold). p0-shell updated: logged-out '/' now shows
marketing with a Sign-in link to /login.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bfbccf172
commit
200bb56299
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/* The marketing site is LIGHT — the inverse of the dark app. Scoped here
|
||||||
|
via the `marketing` class so the app's dark tokens are untouched. */
|
||||||
|
export default function MarketingLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="marketing min-h-dvh bg-[#fbfbfd] text-marketing-ink">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { Faq } from "@/components/marketing/Faq";
|
||||||
|
import { MarketingNav } from "@/components/marketing/Nav";
|
||||||
|
import {
|
||||||
|
CtaBand,
|
||||||
|
Features,
|
||||||
|
Footer,
|
||||||
|
Hero,
|
||||||
|
Security,
|
||||||
|
} from "@/components/marketing/Sections";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Clawmates — AI coworkers that ship work",
|
||||||
|
description:
|
||||||
|
"Collaborative AI agents with a human-in-the-loop safety layer. Run them in the cloud or fully air-gapped on your own hardware.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The public marketing landing (served at "/" for logged-out visitors via
|
||||||
|
// the middleware rewrite; authed users get the workspace app instead).
|
||||||
|
export default function MarketingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MarketingNav />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<Features />
|
||||||
|
<Security />
|
||||||
|
<Faq />
|
||||||
|
<CtaBand />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const FAQS: { q: string; a: string }[] = [
|
||||||
|
{
|
||||||
|
q: "What is a claw?",
|
||||||
|
a: "A claw is an AI coworker — an agent with a job, a chat, and its own sandboxed computer. Your team talks to it like a teammate, and it uses tools to get work done.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How do you keep agents safe?",
|
||||||
|
a: "Every action that reaches outside the sandbox — sending an email, posting to Slack, a transaction — is intercepted and held until a human approves it, with the exact payload shown. Agent code runs in a locked-down container with no network.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I run it on my own infrastructure?",
|
||||||
|
a: "Yes. The same container images deploy to Kubernetes or to a single-node Docker Compose appliance you can run fully air-gapped, installed from a signed bundle that verifies offline.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Which tools can claws connect to?",
|
||||||
|
a: "Slack and email today, plus a growing app directory you connect with API keys or OAuth. Credentials are held by a separate secret broker the agent can never read.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How is my data protected?",
|
||||||
|
a: "Credentials are encrypted at rest in an isolated broker process; agent sandboxes have no egress; and in the air-gapped deployment nothing ever leaves your network.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** FAQ accordion (measured): one open at a time, chevron rotates. */
|
||||||
|
export function Faq() {
|
||||||
|
const [open, setOpen] = useState<number | null>(0);
|
||||||
|
return (
|
||||||
|
<section id="faq" className="mx-auto w-[min(760px,92%)] py-16">
|
||||||
|
<h2 className="text-center text-[clamp(30px,5vw,48px)] font-extrabold tracking-[-1.5px]">
|
||||||
|
Questions, <span className="text-marketing-coral">answered</span>.
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-3 pt-10">
|
||||||
|
{FAQS.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.q}
|
||||||
|
className="rounded-2xl border border-marketing-border bg-white"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open === i}
|
||||||
|
onClick={() => setOpen(open === i ? null : i)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 px-6 py-4 text-left text-base font-semibold"
|
||||||
|
>
|
||||||
|
{item.q}
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden
|
||||||
|
size={18}
|
||||||
|
className={`shrink-0 text-marketing-slate transition-transform duration-normal ease-app ${
|
||||||
|
open === i ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open === i && (
|
||||||
|
<p className="px-6 pb-5 text-sm leading-relaxed text-marketing-slate">
|
||||||
|
{item.a}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/** Floating white pill nav (measured): wordmark left, links center,
|
||||||
|
* auth pills right. Our brand, our links. */
|
||||||
|
export function MarketingNav() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-4 z-50 mx-auto flex w-[min(960px,92%)] items-center justify-between rounded-radius-button bg-white px-5 py-2.5 shadow-[0_8px_30px_-12px_rgba(22,29,39,0.18)] ring-1 ring-marketing-border">
|
||||||
|
<Link href="/" className="font-mono text-lg font-semibold tracking-tight">
|
||||||
|
clawmates
|
||||||
|
</Link>
|
||||||
|
<nav className="hidden gap-6 text-sm font-medium sm:flex">
|
||||||
|
<a href="#features" className="hover:text-marketing-coral">
|
||||||
|
Product
|
||||||
|
</a>
|
||||||
|
<a href="#security" className="hover:text-marketing-coral">
|
||||||
|
Security
|
||||||
|
</a>
|
||||||
|
<a href="#faq" className="hover:text-marketing-coral">
|
||||||
|
FAQ
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-radius-button px-4 py-2 text-sm font-medium hover:bg-[#f1f3f7]"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-radius-button bg-marketing-slate-dark px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-[#0f172a]"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
KeyRound,
|
||||||
|
Network,
|
||||||
|
ServerCog,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Workflow,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
/** Hero (measured display scale): 75px/800/-3px, one coral keyword span,
|
||||||
|
* our copy, dual pill CTA, honest trust badges. */
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-[min(960px,92%)] pt-20 pb-16 text-center">
|
||||||
|
<h1 className="text-[clamp(44px,8vw,75px)] leading-[0.92] font-extrabold tracking-[-3px]">
|
||||||
|
<span className="text-marketing-coral">AI coworkers</span>
|
||||||
|
<br />
|
||||||
|
that actually ship work.
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto max-w-2xl pt-6 text-[clamp(17px,2.4vw,21.9px)] leading-snug font-medium text-marketing-slate">
|
||||||
|
Clawmates are agents your whole team can chat with — and every
|
||||||
|
action that reaches the outside world waits for a human's
|
||||||
|
approval. Run them in our cloud or fully air-gapped on your own
|
||||||
|
hardware.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 pt-8">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex h-14 items-center rounded-radius-button bg-marketing-coral px-10 text-xl font-bold text-white transition-colors hover:bg-[#d84a4a]"
|
||||||
|
>
|
||||||
|
Get started for free
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="mailto:[email protected]"
|
||||||
|
className="flex h-14 items-center rounded-radius-button border border-marketing-border bg-white px-10 text-base font-semibold text-[#0f172a] transition-colors hover:bg-[#f1f3f7]"
|
||||||
|
>
|
||||||
|
Book a demo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 pt-8 text-sm text-marketing-slate">
|
||||||
|
<span>★ Free credits to start</span>
|
||||||
|
<span>★ No credit card required</span>
|
||||||
|
<span>★ Self-hostable & air-gapped</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES: { icon: LucideIcon; title: string; body: string }[] = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Human-in-the-loop",
|
||||||
|
body: "Every outbound message, email, or transaction is intercepted and waits for explicit approval — with the exact payload shown.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ServerCog,
|
||||||
|
title: "Run it anywhere",
|
||||||
|
body: "The same images deploy to Kubernetes or a single-node, fully offline appliance. Your data never has to leave your network.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Network,
|
||||||
|
title: "Works where you do",
|
||||||
|
body: "Slack, email, and a growing app directory — connect the tools your claws need, with credentials held in a separate secret broker.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Workflow,
|
||||||
|
title: "Built for teams",
|
||||||
|
body: "Claws are shared, with per-claw access policies, an org chart, routines, and a usage leaderboard for the whole workspace.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Features() {
|
||||||
|
return (
|
||||||
|
<section id="features" className="mx-auto w-[min(960px,92%)] py-16">
|
||||||
|
<h2 className="text-center text-[clamp(34px,6vw,64px)] leading-[1.02] font-extrabold tracking-[-2.56px]">
|
||||||
|
Multiply your team's <span className="text-marketing-coral">brainpower</span>.
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 pt-12 sm:grid-cols-2">
|
||||||
|
{FEATURES.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.title}
|
||||||
|
className="rounded-3xl bg-marketing-periwinkle p-8"
|
||||||
|
>
|
||||||
|
<span className="flex size-11 items-center justify-center rounded-2xl bg-white">
|
||||||
|
<f.icon aria-hidden size={20} className="text-marketing-coral" />
|
||||||
|
</span>
|
||||||
|
<p className="pt-5 text-xl font-bold">{f.title}</p>
|
||||||
|
<p className="pt-2 text-sm leading-relaxed text-marketing-slate">
|
||||||
|
{f.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY: { icon: LucideIcon; title: string; body: string }[] = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Approval-gated by design",
|
||||||
|
body: "Sensitive actions can never fire without a recorded human decision — proven by our own concurrency soak tests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: KeyRound,
|
||||||
|
title: "Credentials in a broker",
|
||||||
|
body: "Secrets live in a separate process, encrypted at rest. Agents never see a raw key; the broker injects them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ServerCog,
|
||||||
|
title: "Air-gappable",
|
||||||
|
body: "Install from a signed, offline-verified bundle. No phone-home, no required internet — your whole stack on your hardware.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Security() {
|
||||||
|
return (
|
||||||
|
<section id="security" className="mx-auto w-[min(960px,92%)] py-16">
|
||||||
|
<h2 className="text-center text-[clamp(34px,6vw,64px)] leading-[1.02] font-extrabold tracking-[-2.56px]">
|
||||||
|
Security that's <span className="text-marketing-coral">structural</span>.
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-xl pt-4 text-center text-base text-marketing-slate">
|
||||||
|
Not a checkbox bolted on later — the safety layer is the architecture.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 pt-12 sm:grid-cols-3">
|
||||||
|
{SECURITY.map((s) => (
|
||||||
|
<div key={s.title} className="rounded-3xl border border-marketing-border bg-white p-7">
|
||||||
|
<span className="flex size-11 items-center justify-center rounded-2xl bg-marketing-periwinkle">
|
||||||
|
<s.icon aria-hidden size={20} className="text-marketing-coral" />
|
||||||
|
</span>
|
||||||
|
<p className="pt-5 text-lg font-bold">{s.title}</p>
|
||||||
|
<p className="pt-2 text-sm leading-relaxed text-marketing-slate">
|
||||||
|
{s.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CtaBand() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-[min(960px,92%)] py-16">
|
||||||
|
<div className="flex flex-col items-center gap-6 rounded-[32px] bg-marketing-slate-dark px-8 py-16 text-center text-white">
|
||||||
|
<Sparkles aria-hidden size={28} className="text-marketing-coral" />
|
||||||
|
<h2 className="text-[clamp(30px,5vw,48px)] font-extrabold tracking-[-1.5px]">
|
||||||
|
Build your AI team today.
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex h-14 items-center rounded-radius-button bg-marketing-coral px-10 text-xl font-bold text-white transition-colors hover:bg-[#d84a4a]"
|
||||||
|
>
|
||||||
|
Get started for free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mx-auto w-[min(960px,92%)] border-t border-marketing-border py-10 text-sm text-marketing-slate">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<span className="font-mono font-semibold text-marketing-ink">
|
||||||
|
clawmates
|
||||||
|
</span>
|
||||||
|
<nav className="flex flex-wrap gap-5">
|
||||||
|
<Link href="/login">Sign in</Link>
|
||||||
|
<a href="mailto:[email protected]">Contact</a>
|
||||||
|
<a href="#faq">FAQ</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<p className="pt-6 text-xs">
|
||||||
|
© 2026 Clawmates · clawmates.work
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
-3
@@ -1,14 +1,29 @@
|
|||||||
// Next middleware (Next 16: proxy.ts). In clerk mode Clerk's middleware
|
// Next middleware (Next 16: proxy.ts). Two jobs: route the marketing
|
||||||
// establishes the request auth context that resolveBearer() reads; in
|
// landing for logged-out visitors at "/", and (in clerk mode) run Clerk's
|
||||||
// local mode requests pass straight through.
|
// middleware so resolveBearer() has an auth context.
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import type { NextFetchEvent } from "next/server";
|
import type { NextFetchEvent } from "next/server";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "cm_session";
|
||||||
|
|
||||||
export default async function proxy(
|
export default async function proxy(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
event: NextFetchEvent,
|
event: NextFetchEvent,
|
||||||
) {
|
) {
|
||||||
|
// Logged-out visitors to "/" get the public marketing site; authed
|
||||||
|
// users fall through to the workspace app. Local mode keys on the
|
||||||
|
// session cookie; clerk mode keys on Clerk's __session cookie.
|
||||||
|
if (request.nextUrl.pathname === "/") {
|
||||||
|
const authed =
|
||||||
|
request.cookies.has(SESSION_COOKIE) || request.cookies.has("__session");
|
||||||
|
if (!authed) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/marketing";
|
||||||
|
return NextResponse.rewrite(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.AUTH_MODE === "clerk") {
|
if (process.env.AUTH_MODE === "clerk") {
|
||||||
const { clerkMiddleware } = await import("@clerk/nextjs/server");
|
const { clerkMiddleware } = await import("@clerk/nextjs/server");
|
||||||
return clerkMiddleware()(request, event);
|
return clerkMiddleware()(request, event);
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ async function signIn(page: import("@playwright/test").Page) {
|
|||||||
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test("unauthenticated visitors are redirected to login", async ({ page }) => {
|
test("unauthenticated visitors see the marketing landing", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
// "/" rewrites to the public marketing site for logged-out visitors.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: /AI coworkers/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
// Sign-in is a link from the marketing nav.
|
||||||
|
await page.getByRole("link", { name: "Sign in" }).first().click();
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wrong password shows an error and stays on login", async ({ page }) => {
|
test("wrong password shows an error and stays on login", async ({ page }) => {
|
||||||
@@ -67,7 +72,7 @@ test("credits page shows the seeded balance", async ({ page }) => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Credits", exact: true }),
|
page.getByRole("heading", { name: "Credits", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("1,250")).toBeVisible();
|
await expect(page.getByTestId("credit-balance")).toHaveText("1,250");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("signing out returns to login and revokes the session", async ({
|
test("signing out returns to login and revokes the session", async ({
|
||||||
@@ -76,6 +81,9 @@ test("signing out returns to login and revokes the session", async ({
|
|||||||
await signIn(page);
|
await signIn(page);
|
||||||
await page.getByRole("button", { name: "Sign out" }).click();
|
await page.getByRole("button", { name: "Sign out" }).click();
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
// Session revoked: "/" no longer shows the app, it shows marketing.
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page).toHaveURL(/\/login$/);
|
await expect(
|
||||||
|
page.getByRole("heading", { name: /AI coworkers/ }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// R5: the public marketing landing serves at "/" for logged-out visitors
|
||||||
|
// (middleware rewrite → /marketing), with our brand and honest claims.
|
||||||
|
|
||||||
|
test("the marketing landing renders for logged-out visitors @marketing", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.goto("/");
|
||||||
|
// Hero + nav + our wordmark.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: /AI coworkers/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Get started for free" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
// FAQ accordion: the first item is open by default; expand another.
|
||||||
|
await expect(page.getByText(/A claw is an AI coworker/)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: /run it on my own/i }).click();
|
||||||
|
await expect(page.getByText(/air-gapped/i).first()).toBeVisible();
|
||||||
|
// No SOC 2 / compliance claims (we don't make them).
|
||||||
|
await expect(page.getByText(/SOC 2/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the marketing landing is accessible @marketing", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.goto("/");
|
||||||
|
await page.evaluate(() =>
|
||||||
|
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
|
||||||
|
);
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
const blocking = results.violations.filter(
|
||||||
|
(v) => v.impact === "serious" || v.impact === "critical",
|
||||||
|
);
|
||||||
|
expect(blocking.map((v) => v.id)).toEqual([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user