Login: match WorkClaw's /sign-in form exactly (progressive, Clawmates)

The live /sign-in is a Clerk-rendered SignInForm (SSR is just a spinner); the
rendered design is the email-first progressive form. Reworked our /login to
match it visually while keeping our real email+password auth underneath.

- Email step: "Sign up with Slack" (cosmetic), OR, Work Email / Phone Number
  tabs, work-email field, "Continue with work email →", "Other SSO Options".
  Slack / Phone / SSO are cosmetic placeholders we don't support yet.
- Password step: "Continue" reveals the password field + "Sign in" (our actual
  POST to /auth/session). A Back link returns to the email step.
- Headline now mirrors WorkClaw ("Multiply your team's brainpower" / "Sign up
  to get early access").
- Right panel is now the role-card roster grid (PawPrint avatars, our own
  roles) + "The AI crew for your team" + the claws-at-desk art.
- Updated all eight E2E signIn helpers (and the load assertions) for the new
  two-step flow: fill Email → Continue → fill Password → Sign in. Labels
  (Email/Password) and the "Sign in" button name are preserved.
- A11y: bumped the inactive tab text to full slate (was 3.41:1).

typecheck/lint/build green · p0-shell (full sign-in) green · p6-a11y (login
clean) green · all six @visual baselines green (login regenerated) · deployed
and verified live.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-13 14:27:53 -05:00
co-authored by Claude Opus 4.8
parent a5ae0d6042
commit 8d9ea7faaa
12 changed files with 154 additions and 52 deletions
+6 -6
View File
@@ -7,9 +7,9 @@ export default async function LoginPage() {
const { SignIn } = await import("@clerk/nextjs"); const { SignIn } = await import("@clerk/nextjs");
return ( return (
<AuthShell <AuthShell
title="Your crew is" title="Multiply your"
accent="on deck" accent="team's brainpower"
subtitle="Sign in to your workspace" subtitle="Sign up to get early access"
> >
<div className="flex justify-center"> <div className="flex justify-center">
<SignIn routing="hash" /> <SignIn routing="hash" />
@@ -19,9 +19,9 @@ export default async function LoginPage() {
} }
return ( return (
<AuthShell <AuthShell
title="Your crew is" title="Multiply your"
accent="on deck" accent="team's brainpower"
subtitle="Sign in to your workspace" subtitle="Sign up to get early access"
> >
<LoginForm /> <LoginForm />
</AuthShell> </AuthShell>
+37 -10
View File
@@ -2,6 +2,17 @@ import Link from "next/link";
import { PawPrint } from "lucide-react"; import { PawPrint } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
const ROSTER: { name: string; role: string }[] = [
{ name: "Executive Assistant", role: "Operations" },
{ name: "CX to Product", role: "Product" },
{ name: "Design Coordinator", role: "Design" },
{ name: "User Researcher", role: "Research" },
{ name: "Engineering Coordinator", role: "Engineering" },
{ name: "Ops Assistant", role: "Operations" },
{ name: "Growth Coordinator", role: "Growth" },
{ name: "Analytics Assistant", role: "Data" },
];
/** Two-panel auth shell mirroring WorkClaw's /sign-in: a white form card on /** Two-panel auth shell mirroring WorkClaw's /sign-in: a white form card on
* the left, a periwinkle "meet the crew" panel on the right (hidden on * the left, a periwinkle "meet the crew" panel on the right (hidden on
* mobile). Light-themed via the `marketing` palette. The actual auth UI * mobile). Light-themed via the `marketing` palette. The actual auth UI
@@ -57,8 +68,9 @@ export function AuthShell({
</p> </p>
</div> </div>
{/* Right — crew panel (decorative; hidden on mobile) */} {/* Right — crew panel: role-card grid + caption + crew art (mirrors
<div className="relative hidden flex-col items-center justify-between overflow-hidden bg-[#ebf4fd] py-12 lg:flex"> WorkClaw's sign-in panel). Decorative; hidden on mobile. */}
<div className="relative hidden flex-col items-center justify-between overflow-hidden bg-[#ebf4fd] py-10 lg:flex">
<div <div
className="absolute inset-0 opacity-[0.5]" className="absolute inset-0 opacity-[0.5]"
style={{ style={{
@@ -67,16 +79,31 @@ export function AuthShell({
backgroundPosition: "center", backgroundPosition: "center",
}} }}
/> />
<div className="relative w-[140%] max-w-none opacity-90"> <div className="relative -mt-6 grid w-[112%] grid-cols-2 gap-2.5 px-3">
{/* eslint-disable-next-line @next/next/no-img-element */} {ROSTER.map((r) => (
<img <div
src="/images/marketing/claw-carousel-strip.png" key={r.name}
alt="" className="flex items-center gap-2.5 rounded-2xl bg-white/95 p-2.5 shadow-[0_8px_24px_-12px_rgba(22,29,39,0.25)]"
className="block h-auto w-full" >
/> <span
className="flex size-9 shrink-0 items-center justify-center rounded-xl"
style={{ backgroundColor: "rgba(233,86,86,0.1)" }}
>
<PawPrint aria-hidden size={18} className="text-marketing-coral" />
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-bold text-marketing-ink">
{r.name}
</span>
<span className="block truncate text-[11px] text-marketing-slate">
{r.role}
</span>
</span>
</div>
))}
</div> </div>
<p className="relative px-8 text-center text-xl font-semibold text-marketing-ink"> <p className="relative px-8 text-center text-xl font-semibold text-marketing-ink">
Your AI crew, all hands on deck The AI crew for your team
</p> </p>
<div className="relative w-[88%]"> <div className="relative w-[88%]">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
+89 -23
View File
@@ -1,14 +1,27 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, ArrowLeft } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
/** WorkClaw-style progressive sign-in: an email-first step (Slack + work
* email / phone tabs + "Continue with work email"), then a password step.
* Slack / phone / "Other SSO Options" are cosmetic placeholders — we don't
* support them yet. The real auth is our email + password POST. */
export function LoginForm() { export function LoginForm() {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<"email" | "password">("email");
const [email, setEmail] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) { function continueWithEmail(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!email.trim()) return;
setStep("password");
}
async function signIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setPending(true); setPending(true);
setFailed(false); setFailed(false);
@@ -16,10 +29,7 @@ export function LoginForm() {
const res = await fetch("/auth/session", { const res = await fetch("/auth/session", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ email, password: data.get("password") }),
email: data.get("email"),
password: data.get("password"),
}),
}); });
if (res.ok) { if (res.ok) {
router.push("/"); router.push("/");
@@ -30,32 +40,32 @@ export function LoginForm() {
setFailed(true); setFailed(true);
} }
if (step === "password") {
return ( return (
<form <form onSubmit={signIn} className="flex w-full flex-col gap-4">
onSubmit={handleSubmit} <button
className={`flex w-full flex-col gap-4 ${ type="button"
failed ? "motion-safe:animate-[shake_0.4s_var(--ease-app)]" : "" onClick={() => setStep("email")}
}`} className="flex items-center gap-1.5 self-start text-sm font-medium text-marketing-slate transition-colors hover:text-marketing-ink"
> >
<label className="flex flex-col gap-1.5 text-sm font-medium text-marketing-slate-dark"> <ArrowLeft aria-hidden size={16} /> Back
Email </button>
<input <p className="text-sm text-marketing-slate">
name="email" Signing in as{" "}
type="email" <span className="font-semibold text-marketing-ink">{email}</span>
required </p>
autoComplete="email"
placeholder="e.g. [email protected]"
className="rounded-xl border border-[#e2e8f0] bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors placeholder:text-marketing-slate/60 focus:border-marketing-coral-strong"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm font-medium text-marketing-slate-dark"> <label className="flex flex-col gap-1.5 text-sm font-medium text-marketing-slate-dark">
Password Password
<input <input
name="password" name="password"
type="password" type="password"
aria-label="Password"
required required
autoFocus
autoComplete="current-password" autoComplete="current-password"
className="rounded-xl border border-[#e2e8f0] bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors focus:border-marketing-coral-strong" className={`rounded-xl border bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors focus:border-marketing-coral-strong ${
failed ? "border-marketing-coral-strong" : "border-[#e2e8f0]"
}`}
/> />
</label> </label>
{failed && ( {failed && (
@@ -66,10 +76,66 @@ export function LoginForm() {
<button <button
type="submit" type="submit"
disabled={pending} disabled={pending}
className="mt-1 flex h-12 items-center justify-center rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark disabled:opacity-50" className="flex h-12 items-center justify-center rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark disabled:opacity-50"
> >
{pending ? "Signing in…" : "Sign in"} {pending ? "Signing in…" : "Sign in"}
</button> </button>
</form> </form>
); );
} }
return (
<div className="flex w-full flex-col gap-4">
<button
type="button"
className="flex h-12 items-center justify-center gap-2.5 rounded-full border border-[#e2e8f0] bg-white text-base font-semibold text-marketing-ink transition-colors hover:bg-[#f8fafc]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/images/marketing/icon-slack.svg" alt="" className="size-5" />
Sign up with Slack
</button>
<div className="flex items-center gap-3 text-xs font-medium text-marketing-slate">
<span className="h-px flex-1 bg-[#e2e8f0]" />
OR
<span className="h-px flex-1 bg-[#e2e8f0]" />
</div>
<div className="flex gap-1 rounded-full bg-[#f1f5f9] p-1 text-sm font-medium">
<span className="flex-1 rounded-full bg-white py-1.5 text-center text-marketing-ink shadow-sm">
Work Email
</span>
<span className="flex-1 py-1.5 text-center text-marketing-slate">
Phone Number
</span>
</div>
<form onSubmit={continueWithEmail} className="flex flex-col gap-4">
<input
name="email"
type="email"
aria-label="Email"
required
autoComplete="email"
placeholder="e.g. [email protected]"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-xl border border-[#e2e8f0] bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors placeholder:text-marketing-slate/60 focus:border-marketing-coral-strong"
/>
<button
type="submit"
className="flex h-12 items-center justify-center gap-2 rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark"
>
Continue with work email <ArrowRight aria-hidden size={18} />
</button>
</form>
<button
type="button"
className="text-center text-sm font-medium text-marketing-slate underline-offset-2 hover:underline"
>
Other SSO Options
</button>
</div>
);
}
+2
View File
@@ -9,6 +9,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: import("@playwright/test").Page) { async function signIn(page: import("@playwright/test").Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
@@ -28,6 +29,7 @@ test("unauthenticated visitors see the marketing landing", async ({ page }) => {
test("wrong password shows an error and stays on login", async ({ page }) => { test("wrong password shows an error and stays on login", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill("wrong-password"); await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
// Next.js's route announcer is also role="alert"; scope to the form's. // Next.js's route announcer is also role="alert"; scope to the form's.
+1
View File
@@ -10,6 +10,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
+1
View File
@@ -11,6 +11,7 @@ const GATED_PROMPT = "email the CEO [[scenario:gated-email]]";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
+1
View File
@@ -9,6 +9,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
+1
View File
@@ -12,6 +12,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
+1
View File
@@ -9,6 +9,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
+2 -1
View File
@@ -10,6 +10,7 @@ const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Clawmates" })).toBeVisible();
@@ -33,7 +34,7 @@ async function expectClean(page: Page, context: string) {
test("login page is clean", async ({ page }) => { test("login page is clean", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); await expect(page.getByRole("button", { name: /Continue with work email/ })).toBeVisible();
await expectClean(page, "login"); await expectClean(page, "login");
}); });
+2 -1
View File
@@ -17,6 +17,7 @@ const SCREENSHOT = {
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/login"); await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL); await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByRole("button", { name: /Continue with work email/ }).click();
await page.getByLabel("Password").fill(OWNER_PASSWORD); await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
// Wait for the real workspace home — the login page also has a lowercase // Wait for the real workspace home — the login page also has a lowercase
@@ -29,7 +30,7 @@ async function signIn(page: Page) {
test("login page looks right @visual", async ({ page }) => { test("login page looks right @visual", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); await expect(page.getByRole("button", { name: /Continue with work email/ })).toBeVisible();
await expect(page).toHaveScreenshot("login.png", SCREENSHOT); await expect(page).toHaveScreenshot("login.png", SCREENSHOT);
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 294 KiB