diff --git a/.claude/launch.json b/.claude/launch.json index 7cbc05c40a..1af7d4ce09 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -7,6 +7,13 @@ "runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"], "port": 3108, "autoPort": false + }, + { + "name": "ui-preview", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@paperclipai/ui", "exec", "vite"], + "port": 5188, + "autoPort": true } ] } diff --git a/.claude/skills/design-guide/references/component-index.md b/.claude/skills/design-guide/references/component-index.md index 88e75178b3..93c43b1754 100644 --- a/.claude/skills/design-guide/references/component-index.md +++ b/.claude/skills/design-guide/references/component-index.md @@ -188,6 +188,24 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag **File:** `CompanySwitcher.tsx` **Usage:** Company selector dropdown in sidebar header. +### AgentCapsule + +**File:** `AgentCapsule.tsx` +**Props:** `state: "slot" | "configured" | "online"`, `gradient?: 1–10`, `size?: "sm" | "md" | "lg" | {width,height}`, `glow?: "green" | "blue"` +**Usage:** The brand "capsule is the agent" pill; evolves in place across onboarding steps. Fill uses `--agent-Na/Nb` gradient tokens; honors `prefers-reduced-motion`. + +### Onboarding primitives (OnboardingCard, OnboardingHeading, Stepper, Chip, ChoiceCard, ConnectorRow) + +**File:** `onboarding/OnboardingPrimitives.tsx` +**Usage:** Presentational pieces for the full-screen onboarding flow (`onboarding/OnboardingFlow.tsx`): the 560px card frame (`--sz-560px`), display heading + lede (text-4xl), 3-segment stepper, selectable mission chips, selectable choice cards, and connector rows. Bespoke dimensions route through verbatim `--sz-*` tokens; fields inside the flow use the shared Input/Textarea/Select/Label primitives. + +```tsx + + + + +``` + --- ## Layout Components diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts index e1a7096ed6..49364e6d8d 100644 --- a/tests/e2e/conference-room-typing-intro.spec.ts +++ b/tests/e2e/conference-room-typing-intro.spec.ts @@ -1,32 +1,25 @@ import { test, expect } from "@playwright/test"; +import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow"; /** - * E2E: post-wizard onboarding launch. + * E2E: post-onboarding launch. * - * Completing the onboarding wizard now creates the first assigned task and - * lands the user on the company dashboard. The chat intro still has unit - * coverage in BoardChat tests; the wizard handoff no longer routes there. + * Completing the onboarding flow creates the first assigned task and lands the + * user on the company dashboard. The chat intro still has unit coverage in + * BoardChat tests; the onboarding handoff no longer routes there. */ const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`; -const MISSION = "Verify the dashboard launch survives the wizard handoff."; -const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan"; +const MISSION = "Verify the dashboard launch survives the onboarding handoff."; -test.describe("Dashboard launch after onboarding wizard", () => { +test.describe("Dashboard launch after onboarding", () => { test("creates the first task and opens the dashboard", async ({ page, baseURL, }) => { - // Intercept env-test → instant pass (avoid running a real CLI check). - await page.route("**/test-environment", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ status: "pass", checks: [] }), - }), - ); - // Intercept hire → perform a REAL hire server-side with an inert http - // adapter so no real agent process spawns. + // adapter so no real agent process spawns. (The cloud flow hires with + // requireEnvProbe: false, so there is no adapter-environment probe to stub.) await page.route("**/agent-hires", async (route) => { const req = route.request(); const body = JSON.parse(req.postData() || "{}"); @@ -54,38 +47,13 @@ test.describe("Dashboard launch after onboarding wizard", () => { await page.goto("/onboarding"); - // Launcher card path (existing companies) — enter the wizard if the - // route shows a launcher instead of opening the wizard directly. - const startBtn = page.getByRole("button", { name: /Start Onboarding/i }); - if (await startBtn.count()) await startBtn.first().click(); - - // Step 0: front door (skipped when the wizard opens on the create path). - const frontDoor = page.getByText("Build a new company"); - if (await frontDoor.count()) await frontDoor.first().click(); - - // Step 1: company name. - await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME); - await page.getByRole("button", { name: /^Next/ }).click(); - - // Step 2: mission (direct path default). - await page - .getByPlaceholder("What is your team trying to achieve?") - .fill(MISSION); - await page.getByRole("button", { name: /Confirm mission/ }).click(); - - // Step 3: lead name (prefilled) → Next. - await page.waitForSelector('input[placeholder="Chief of staff"]', { - timeout: 15_000, + // Welcome → company (name + mission) → agent (role picker) → first task. + // "Get started" on the task step creates the task and opens the dashboard. + await completeCloudOnboarding(page, { + companyName: COMPANY_NAME, + mission: MISSION, + choice: "hiring", }); - await page.getByRole("button", { name: /^Next/ }).click(); - - // Step 4: adapter (claude_local default); heartbeat is intercepted. - await page.getByRole("button", { name: /Give it a heartbeat/ }).click(); - - // Step 5: review → Get started creates the first task and opens dashboard. - const getStarted = page.getByRole("button", { name: /Get started/ }); - await getStarted.waitFor({ timeout: 20_000 }); - await getStarted.click(); await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 }); @@ -98,8 +66,8 @@ test.describe("Dashboard launch after onboarding wizard", () => { const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`); expect(issuesRes.ok()).toBe(true); const issues = await issuesRes.json(); - const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE); + const firstTask = issues.find((candidate: { title: string }) => candidate.title === HIRING_TASK_TITLE); expect(firstTask).toBeTruthy(); - await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(HIRING_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 }); }); }); diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts index 4c3a3481be..9f788f7c68 100644 --- a/tests/e2e/nux-phase4-screenshots.spec.ts +++ b/tests/e2e/nux-phase4-screenshots.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { DEFAULT_ROLE, startCloudOnboarding } from "./onboarding-flow"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -9,40 +10,43 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * NUX Phase 4 — visual QA screenshot capture. * * Boots a throwaway local_trusted instance (see playwright.config.ts webServer) - * and captures screenshots of every surface integrated by NUX Phases 1–3: - * - "Build a new company" step 1 (company name) + step 2 (mission) - * - Team-lead hire step (capsule wizard, PAP-125) - * - Onboarding front door (path picker) - * - "Add agents to your org" growth intake + * and captures screenshots of every integrated onboarding surface: + * - Welcome screen (path picker) + * - Company step (name + mission) + * - Create-your-first-agent step (role picker + capsule) + * - First-task step + * - "Add an agent to an existing company" entry (/:prefix/onboarding) * - Conference Room (BoardChat) shell + composer + activity feed * - Artifacts page * * These are structural/rendering checks — LLM-dependent streaming (CEO chat * responses, hiring-plan generation) is verified separately on an LLM-backed - * instance. Screenshots land in ./nux-phase4-shots for upload as evidence. + * instance. Screenshots land in ./test-results for upload as evidence. */ // Write under the gitignored test-results dir so re-runs leave no untracked // noise; screenshots are uploaded to the issue as QA evidence, not committed. const SHOT_DIR = path.join(__dirname, "test-results", "nux-phase4-shots"); +const SHOTS = [ + "01-welcome.png", + "02-company.png", + "03-agent.png", + "04-first-task.png", + "05-add-agent.png", + "06-board-chat.png", + "07-artifacts.png", +]; + function shot(name: string) { fs.mkdirSync(SHOT_DIR, { recursive: true }); return path.join(SHOT_DIR, name); } -async function openWizard(page: import("@playwright/test").Page) { - await page.goto("/onboarding"); - const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ }); - if (await startBtn.count()) { - await startBtn.first().click(); - } -} - test.describe("NUX Phase 4 visual QA", () => { test("captures every integrated surface", async ({ page }) => { - // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the - // experimental flag on for this throwaway instance before driving them. + // Conference Room is flag-gated default-OFF: turn the experimental flag on + // for this throwaway instance before driving that surface (Section C). const flagRes = await page.request.patch("/api/instance/settings/experimental", { data: { enableConferenceRoomChat: true }, }); @@ -57,36 +61,43 @@ test.describe("NUX Phase 4 visual QA", () => { const baseUrl = "http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199"); - // ── Section A: create-company path (name → mission → hire) ──────────── - await openWizard(page); - // Front door shows when the wizard doesn't open directly on the create - // path (e.g. another spec already created a company on this instance). - const createCard = page.getByRole("button", { name: /Build a new company/ }); - if (await createCard.count()) { - await createCard.first().click(); - } + // ── Section A: the cloud flow, step by step ─────────────────────────── + await page.goto("/onboarding"); + await expect( - page.getByRole("heading", { name: "Name your company" }), + page.getByRole("heading", { name: "Welcome to Paperclip!" }), ).toBeVisible({ timeout: 15_000 }); - await page.getByPlaceholder("Acme Corp").fill("QA Robotics"); - await page.screenshot({ path: shot("02-create-name.png") }); + await page.screenshot({ path: shot("01-welcome.png") }); - await page.getByRole("button", { name: /^Next/ }).click(); + await startCloudOnboarding(page); + + // Capture the company step populated but not yet submitted, then submit. await expect( - page.getByRole("heading", { name: "Define your mission" }), - ).toBeVisible({ timeout: 10_000 }); + page.getByRole("heading", { name: "What is the name of your company or team?" }), + ).toBeVisible({ timeout: 15_000 }); + await page.locator("#onboarding-company-name").fill("QA Robotics"); await page - .getByPlaceholder("What is your team trying to achieve?") + .locator("#onboarding-mission") .fill("Build affordable home robots that handle household chores."); - await page.screenshot({ path: shot("03-create-mission.png") }); + await page.screenshot({ path: shot("02-company.png") }); + await page.getByRole("button", { name: /^Next/ }).click(); - // Step 2 advances via "Confirm mission" (creates the company + goal); - // step 3 is the team-lead naming step of the capsule wizard. - await page.getByRole("button", { name: /Confirm mission/ }).click(); - await page.waitForSelector('input[placeholder="Chief of staff"]', { - timeout: 30_000, - }); - await page.screenshot({ path: shot("04-hire-team-lead.png") }); + // Agent step: pick a role so the capsule + preview render, then capture + // before hiring. + await expect( + page.getByRole("heading", { name: "Create your first agent" }), + ).toBeVisible({ timeout: 30_000 }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: DEFAULT_ROLE, exact: true }).click(); + await page.screenshot({ path: shot("03-agent.png") }); + await page.getByRole("button", { name: /^Create/ }).click(); + + // First-task step: select a choice so the card's selected state is visible. + await expect( + page.getByRole("heading", { name: "Assign your agent a first task" }), + ).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: /Create a hiring plan/ }).click(); + await page.screenshot({ path: shot("04-first-task.png") }); // The company just created anchors the route-scoped sections below. const companiesRes = await page.request.get(`${baseUrl}/api/companies`); @@ -95,39 +106,23 @@ test.describe("NUX Phase 4 visual QA", () => { const qaCompany = (Array.isArray(companies) ? companies : []).find( (c: { name: string }) => c.name === "QA Robotics", ); - expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy(); + expect(qaCompany, "onboarding should have created QA Robotics").toBeTruthy(); const prefix: string = qaCompany.issuePrefix; - // ── Section B: front door + growth intake ───────────────────────────── + // ── Section B: "add an agent to an existing company" entry ──────────── + // OnboardingWizardVariant renders outside (App.tsx), so it never + // sees the :companyPrefix param and the company-scoped route still opens on + // the welcome screen. The real existing-company entry is the launcher card + // behind it: dismiss the overlay, then "Add Agent" opens onboarding scoped + // to this company, which skips company creation and starts at the agent step. await page.evaluate(() => window.localStorage.clear()); - await openWizard(page); - // Reach the full-screen front door (step 0): either it shows directly or - // "← Back to start" returns to it from the create step. - if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) { - await page.getByRole("button", { name: /Back to start/ }).click(); - } + await page.goto(`/${prefix}/onboarding`); + await page.getByRole("button", { name: "Close onboarding" }).click(); + await page.getByRole("button", { name: "Add Agent" }).click(); await expect( - page.getByRole("heading", { name: "Welcome to Paperclip" }), - ).toBeVisible({ timeout: 10_000 }); - await expect( - page.getByRole("heading", { name: "Build a new company" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Add agents to your org" }), - ).toBeVisible(); - await page.screenshot({ path: shot("01-front-door.png") }); - - await page.getByRole("button", { name: /Add agents to your org/ }).click(); - // The grow path shares step 1 (company name) before its step-2 intake. - await expect( - page.getByRole("heading", { name: "Name your company" }), - ).toBeVisible({ timeout: 10_000 }); - await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow"); - await page.getByRole("button", { name: /^Next/ }).click(); - await expect( - page.getByRole("heading", { name: /Tell us about your team/ }), - ).toBeVisible({ timeout: 10_000 }); - await page.screenshot({ path: shot("05-growth-intake.png") }); + page.getByRole("heading", { name: "Create your first agent" }), + ).toBeVisible({ timeout: 20_000 }); + await page.screenshot({ path: shot("05-add-agent.png") }); // ── Section C: Conference Room (BoardChat) ──────────────────────────── // Visit the company dashboard first so CompanyContext selects the company @@ -152,15 +147,7 @@ test.describe("NUX Phase 4 visual QA", () => { await page.waitForTimeout(1_000); await page.screenshot({ path: shot("07-artifacts.png") }); - for (const f of [ - "01-front-door.png", - "02-create-name.png", - "03-create-mission.png", - "04-hire-team-lead.png", - "05-growth-intake.png", - "06-board-chat.png", - "07-artifacts.png", - ]) { + for (const f of SHOTS) { const p = shot(f); expect(fs.existsSync(p), `missing ${f}`).toBe(true); expect(fs.statSync(p).size, `empty ${f}`).toBeGreaterThan(1_000); diff --git a/tests/e2e/onboarding-flow.ts b/tests/e2e/onboarding-flow.ts new file mode 100644 index 0000000000..49f4ffb60e --- /dev/null +++ b/tests/e2e/onboarding-flow.ts @@ -0,0 +1,134 @@ +import { expect, type Page } from "@playwright/test"; + +/** + * Shared driver for the cloud onboarding flow (ui/src/components/onboarding/). + * + * The flow replaced the retired OnboardingWizard, which had a front door, a + * separate company-name step, a separate mission step, an adapter step and a + * review step. The cloud flow is four screens: + * + * start — "Welcome to Paperclip!" (unnumbered) + * company — name + mission on one card; "Next" creates the company + goal + * agent — role picker (+ optional name); "Create" hires the lead agent + * task — first-task choice; "Get started" launches it and opens the dashboard + * + * Four specs drove the old wizard's selectors, so the step drivers live here + * rather than being copy-pasted: when the flow changes again, this is the one + * file to update. + */ + +/** Default role picked in the agent step. */ +export const DEFAULT_ROLE = "Chief of Staff"; + +/** + * Name the agent step auto-fills when DEFAULT_ROLE is picked. Selecting a role + * populates the (optional) name field with the role's acronym — see + * ROLE_ACRONYMS in ui/src/components/onboarding/onboarding-data.ts. + */ +export const DEFAULT_ROLE_ACRONYM = "COS"; + +/** Title of the task created by the "hiring" first-task choice. */ +export const HIRING_TASK_TITLE = "Hire your first engineer and create a hiring plan"; + +export type FirstTaskChoice = "hiring" | "strategy" | "custom"; + +/** + * Matches each first-task ChoiceCard. The cards are buttons whose accessible + * name is title + description, so these match on the title fragment only. + */ +const CHOICE_CARD: Record = { + hiring: /Create a hiring plan/, + strategy: /Write a team strategy doc/, + // Trailing ellipsis in the UI copy is deliberately not matched. + custom: /Write your own task/, +}; + +/** Step 0 — the welcome screen. Advances to the first numbered step. */ +export async function startCloudOnboarding(page: Page): Promise { + await expect(page.getByRole("heading", { name: "Welcome to Paperclip!" })).toBeVisible({ + timeout: 15_000, + }); + await page + .getByRole("button", { name: /Set up Paperclip for your company or team/ }) + .click(); +} + +/** + * Step 1 — company name + mission on a single card. "Next" persists the company + * and its company-level goal, then advances to the agent step. + */ +export async function completeCompanyStep( + page: Page, + { companyName, mission }: { companyName: string; mission: string }, +): Promise { + await expect( + page.getByRole("heading", { name: "What is the name of your company or team?" }), + ).toBeVisible({ timeout: 15_000 }); + await page.locator("#onboarding-company-name").fill(companyName); + await page.locator("#onboarding-mission").fill(mission); + await page.getByRole("button", { name: /^Next/ }).click(); +} + +/** + * Step 2 — role picker (a Radix select) plus an optional name. "Create" hires + * the lead agent. Pass `name` to override the acronym the role auto-fills. + */ +export async function completeAgentStep( + page: Page, + { role = DEFAULT_ROLE, name }: { role?: string; name?: string } = {}, +): Promise { + await expect(page.getByRole("heading", { name: "Create your first agent" })).toBeVisible({ + timeout: 30_000, + }); + await page.locator("#onboarding-agent-role").click(); + await page.getByRole("option", { name: role, exact: true }).click(); + if (name !== undefined) { + await page.locator("#onboarding-agent-name").fill(name); + } + await page.getByRole("button", { name: /^Create/ }).click(); +} + +/** + * Step 3 — pick the first task and launch it. "Get started" creates the task + * and navigates to the company dashboard. + */ +export async function completeTaskStep( + page: Page, + { choice = "hiring", customTask }: { choice?: FirstTaskChoice; customTask?: string } = {}, +): Promise { + await expect( + page.getByRole("heading", { name: "Assign your agent a first task" }), + ).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: CHOICE_CARD[choice] }).click(); + if (choice === "custom") { + await page.getByPlaceholder("Describe the first task").fill(customTask ?? ""); + } + await page.getByRole("button", { name: /Get started/ }).click(); +} + +/** + * Drives the whole cloud flow from an already-loaded /onboarding route through + * to the dashboard navigation. Callers that need to assert or screenshot + * between steps should call the individual step drivers instead. + */ +export async function completeCloudOnboarding( + page: Page, + { + companyName, + mission, + role = DEFAULT_ROLE, + choice = "hiring", + customTask, + }: { + companyName: string; + mission: string; + role?: string; + choice?: FirstTaskChoice; + customTask?: string; + }, +): Promise { + await startCloudOnboarding(page); + await completeCompanyStep(page, { companyName, mission }); + await completeAgentStep(page, { role }); + await completeTaskStep(page, { choice, customTask }); +} diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index eb46e76e50..f5a734d777 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -1,79 +1,46 @@ import { test, expect } from "@playwright/test"; +import { completeCompanyStep, startCloudOnboarding } from "./onboarding-flow"; /** - * E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard). + * E2E: cloud onboarding flow. * - * The wizard now opens on a front door (path picker) and the "Create a new - * company" path runs: - * Step 0 — Front door (Create a new company / Level up existing) - * Step 1a — Name your company - * Step 1b — Define your mission (direct or guided) - * Step 2 — Hire your team lead (adapter picker) - * Step 3+ — Launch celebration → CEO chat → hiring plan → orientation + * The flow opens on a welcome screen and then runs three numbered steps: + * Step 0 — Welcome (unnumbered path picker) + * Step 1 — Company name + mission + * Step 2 — Create your first agent (role picker) + * Step 3 — Assign your agent a first task * - * This test covers the deterministic, LLM-free core: it drives the front door - * through company naming + mission definition (which creates the company and a - * company-level goal) and verifies the wizard advances to the team-lead step. + * This test covers the deterministic, LLM-free core: it drives the welcome + * screen through the company step (which creates the company and a + * company-level goal) and verifies the flow advances to the agent step. * - * The tail (CEO chat at step 4, hiring-plan generation at step 5, final - * landing) depends on a live LLM and is verified separately during manual / - * LLM-backed QA — see PAP-50. Surface-level rendering of every step is - * snapshotted by nux-phase4-screenshots.spec.ts. + * The tail (hiring the agent, launching the first task) is covered by + * conference-room-typing-intro.spec.ts; surface-level rendering of every step + * is snapshotted by nux-phase4-screenshots.spec.ts. */ const COMPANY_NAME = `E2E-Test-${Date.now()}`; const MISSION = "Build affordable home robots that handle household chores."; -test.describe("Onboarding wizard", () => { - test("create-company path: name + mission creates company and goal", async ({ +test.describe("Onboarding flow", () => { + test("company step: name + mission creates company and goal", async ({ page, }) => { const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); - // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the - // experimental flag on for this throwaway instance before driving them. - const flagRes = await page.request.patch("/api/instance/settings/experimental", { - data: { enableConferenceRoomChat: true }, - }); - expect(flagRes.ok()).toBe(true); - await page.goto("/onboarding"); - // The wizard may open on a launcher card or directly on the capsule - // wizard; the front door (step 0) requires a click into the create path. - const startBtn = page.getByRole("button", { - name: /Start Onboarding|New Company|Add Agent/, + await startCloudOnboarding(page); + await completeCompanyStep(page, { + companyName: COMPANY_NAME, + mission: MISSION, }); - if (await startBtn.count()) { - await startBtn.first().click(); - } - const createCard = page.getByRole("button", { name: /Build a new company/ }); - if (await createCard.count()) { - await createCard.first().click(); - } - // Step 1 — Name your company. + // Reaching the agent step means the company + goal writes succeeded. await expect( - page.getByRole("heading", { name: "Name your company" }), - ).toBeVisible({ timeout: 15_000 }); - await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME); - await page.getByRole("button", { name: /^Next/ }).click(); - - // Step 2 — Define your mission (direct entry is the default path). - await expect( - page.getByRole("heading", { name: "Define your mission" }), - ).toBeVisible({ timeout: 10_000 }); - await page - .getByPlaceholder("What is your team trying to achieve?") - .fill(MISSION); - - // "Confirm mission" creates the company + a company-level goal, then - // advances to the team-lead naming step of the capsule wizard. - await page.getByRole("button", { name: /Confirm mission/ }).click(); - await page.waitForSelector('input[placeholder="Chief of staff"]', { - timeout: 30_000, - }); + page.getByRole("heading", { name: "Create your first agent" }), + ).toBeVisible({ timeout: 30_000 }); // Verify the company + company-level goal were persisted. const baseUrl = page.url().split("/").slice(0, 3).join("/"); @@ -95,7 +62,7 @@ test.describe("Onboarding wizard", () => { ); expect(companyGoal, "a company-level goal should be created").toBeTruthy(); - // The expanded wizard must not crash the app (Rules-of-Hooks regression). + // The flow must not crash the app (Rules-of-Hooks regression). expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); }); diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 65be76312d..9692986b93 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -1,20 +1,21 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; +import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow"; -const AGENT_NAME = "Chief of staff"; -const TASK_TITLE = "Hire your first engineer and create a hiring plan"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); test("captures planning mode UI for desktop and mobile", async ({ page }) => { const timestamp = Date.now(); const companyName = `PAP-3413-${timestamp}`; - const screenshotDir = "test-results/planning-mode"; - - await page.route("**/test-environment", (route) => - route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ status: "pass", checks: [] }), - }), - ); + // Resolve against this file, not the cwd, so screenshots land in the + // gitignored tests/e2e/test-results/ rather than an untracked dir at the + // repo root that a contributor could commit by accident. + const screenshotDir = path.join(__dirname, "test-results", "planning-mode"); + // Intercept hire → perform a REAL hire server-side with an inert http adapter + // so no real agent process spawns. (The cloud flow hires with + // requireEnvProbe: false, so there is no adapter-environment probe to stub.) await page.route("**/agent-hires", async (route) => { const req = route.request(); const body = JSON.parse(req.postData() || "{}"); @@ -41,31 +42,15 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { }); await page.goto("/onboarding"); - const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ }); - if (await startBtn.count()) await startBtn.first().click(); - const createCard = page.getByRole("button", { name: /Build a new company/ }); - if (await createCard.count()) await createCard.first().click(); + // This spec only needs a company with a seeded first task to screenshot the + // planning-mode UI against; drive the whole onboarding flow to get one. + await completeCloudOnboarding(page, { + companyName, + mission: "Capture planning mode visual evidence for the graduated task UI.", + choice: "hiring", + }); - await expect(page.getByRole("heading", { name: "Name your company" })).toBeVisible({ timeout: 15_000 }); - - await page.locator('input[placeholder="Acme Corp"]').fill(companyName); - await page.getByRole("button", { name: /^Next/ }).click(); - - await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 }); - await page - .getByPlaceholder("What is your team trying to achieve?") - .fill("Capture planning mode visual evidence for the graduated task UI."); - await page.getByRole("button", { name: /Confirm mission/ }).click(); - - await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 }); - await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME); - - await page.getByRole("button", { name: /^Next/ }).click(); - await page.getByRole("button", { name: /Give it a heartbeat/ }).click(); - - await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 }); - await page.getByRole("button", { name: /Get started/ }).click(); await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 }); const baseOrigin = new URL(page.url()).origin; @@ -79,7 +64,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { const issues = await issueRes.json(); const planningSeedIssue = issues.find( (candidate: { id: string; identifier?: string; title: string }) => - candidate.title === TASK_TITLE, + candidate.title === HIRING_TASK_TITLE, ); expect(planningSeedIssue).toBeTruthy(); diff --git a/ui/onboarding-preview.html b/ui/onboarding-preview.html new file mode 100644 index 0000000000..93a17b04b8 --- /dev/null +++ b/ui/onboarding-preview.html @@ -0,0 +1,22 @@ + + + + + + Onboarding Preview + + + + +
+ + + diff --git a/ui/package.json b/ui/package.json index ce25a72fb5..6788beded3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -59,6 +59,7 @@ "lexical": "0.48.0", "lucide-react": "^0.577.0", "mermaid": "^11.16.0", + "motion": "^12.42.2", "radix-ui": "^1.6.4", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -67,7 +68,8 @@ "react-resizable-panels": "^4.12.2", "react-router-dom": "^7.18.1", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "three": "^0.185.1" }, "devDependencies": { "@storybook/addon-a11y": "10.5.4", @@ -77,6 +79,7 @@ "@types/node": "^22.20.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.1", "@vitejs/plugin-react": "^4.3.4", "storybook": "10.5.5", "tailwindcss": "^4.3.2", diff --git a/ui/src/components/AgentCapsule.tsx b/ui/src/components/AgentCapsule.tsx index 817a9dbb76..d671fb1ca0 100644 --- a/ui/src/components/AgentCapsule.tsx +++ b/ui/src/components/AgentCapsule.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; @@ -8,14 +9,14 @@ import { cn } from "@/lib/utils"; * A single agent is drawn as a tall pill (proportion 1:≥2, radius 9999px) * that moves through three states as the agent comes to life: * - * - `slot` — dashed outline, gently pulsing. An empty agent slot. + * - `slot` — static dashed outline. An empty agent slot. * - `configured` — solid stroke. Agent named / model picked, not yet live. - * - `online` — brand agent-gradient liquid rises to fill the capsule, + * - `online` — brand agent-gradient fill radiates outward from the center, * which then breathes with an online-pulse ring (green by * default, or blue via `glow="blue"`). * * The three states are drawn as stacked layers (a dashed outline, a solid - * stroke, and the rising liquid) that cross-fade by opacity. Because + * stroke, and the radial fill) that cross-fade by opacity. Because * `border-style` is not animatable, the dashed→solid morph is realized as the * dashed layer fading out while the solid layer fades in — so the SAME capsule * can evolve in place across a flow (PAP-125, Option 4 wizard). @@ -24,7 +25,7 @@ import { cn } from "@/lib/utils"; * `--agent-Na` (top) → `--agent-Nb` (bottom); pick which one with `gradient` * (1–10). Size is a preset (`sm` | `md` | `lg`) or an explicit pixel pair so * the component is reusable app-wide. `prefers-reduced-motion` is honored in - * CSS — the liquid rise, layer cross-fade and both pulses are skipped and the + * CSS — the radial fill, layer cross-fade and both pulses are skipped and the * final state is rendered statically. */ @@ -60,10 +61,21 @@ export interface AgentCapsuleProps size?: AgentCapsuleSizePreset | { width: number; height: number }; /** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */ glow?: AgentCapsuleGlow; + /** + * Slot→configured morph as a draw-on: the solid outline is traced around the + * perimeter over the still-visible dashed outline (which fades once the draw + * completes), instead of the default border cross-fade. Honors reduced + * motion by rendering the final state instantly. + */ + strokeDraw?: boolean; /** Accessible label; defaults to a description of the state. */ "aria-label"?: string; } +/** Duration of the strokeDraw perimeter trace; the dashed layer fades after it. */ +const STROKE_DRAW_SECONDS = 0.9; +const STROKE_DRAW_EASE = [0.16, 1, 0.3, 1] as const; + /** Normalize a (possibly out-of-range) gradient index to 1…AGENT_GRADIENT_COUNT. */ function normalizeGradient(gradient: number): number { const n = Math.trunc(gradient); @@ -75,6 +87,7 @@ export function AgentCapsule({ gradient = 1, size = "md", glow = "green", + strokeDraw = false, className, style, "aria-label": ariaLabel, @@ -83,6 +96,8 @@ export function AgentCapsule({ const dims = typeof size === "string" ? SIZE_PRESETS[size] : size; const idx = normalizeGradient(gradient); const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`; + const reducedMotion = useReducedMotion(); + const drawn = state === "configured" || state === "online"; return (
- {/* Dashed outline — an empty agent slot. Visible (and pulsing) only in - the slot state; cross-fades out as the capsule is configured. */} + {/* Dashed outline — an empty agent slot. Visible (static) only in the + slot state; cross-fades out as the capsule is configured. In + strokeDraw mode it instead stays put while the solid outline is + traced over it, then fades once the draw completes. */}