diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9716192..8a9b71a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,4 +17,5 @@ jobs: cache: npm cache-dependency-path: package.json - run: npm install + - run: npm test - run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2707b1b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to Agent Pit Stop. The format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver. + +## [0.1.0] — 2026-07-30 + +The first release: a complete, principled, contract-generating design system for agent interfaces. + +### Principles + +- Five argued chapters with live embedded demos: Legible Thinking, Interruptibility, Delegation Contracts, Calibrated Trust, Graceful Failure +- "Rules for the pit" overview: philosophy, ten rules, anti-patterns, the headlights design language, and motion rules + +### Components (10, shadcn-registry installable) + +- **Legibility:** Tool Call Card, Agent Task List, Agent Roster +- **Control:** Approval Gate, Interrupt Bar, Agent Inbox, Diff Review Card +- **Trust:** Confidence Meter, Context Budget, Citation Chip + Source Drawer +- Every component: single file, React + Tailwind tokens + Motion only, `className` pass-through, 44px touch targets, states labeled in words + +### Agent contract (generated, never hand-typed) + +- `/r/.json` registry items with full source; `/r/index.json` catalog; `/r/contract.json` dense mode with per-component props, principle tags, and five behavioral guarantees; `llms.txt` — all parsed from component source at build time + +### Enforcement + +- Interaction test suite asserting the contract's guarantees (stop preserves work, nothing irreversible without consent, bands not decimals, failures keep inputs, states never color alone); runs in CI on every PR + +### Theming + +- Twelve semantic tokens, dark (headlights) and light sets, both contrast-verified ≥ 4.5:1; retheme by overriding CSS variables, no component edits +- Dark/light toggle on every gallery card and component preview + +### Site + +- The Full Lap: every component driven through one interruptible scripted run +- How-it-works, contributor guide, component template, code of conduct, public roadmap as issues diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa546e9..1347762 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ Every component PR ships all five, or it isn't done: ## How changes land 1. Fork, branch from `main` (`feat/` or `fix/`). -2. `npm install && npm run dev` to work; `npm run build` must pass clean. +2. `npm install && npm run dev` to work; `npm test` and `npm run build` must pass clean. The test suite asserts the contract's behavioral guarantees — if your change fails one, the fix is the change, not the test. 3. Open a PR using the template. CI runs the build; a maintainer reviews for principle adherence, not just code. 4. Squash-merged. Releases roll weekly. diff --git a/README.md b/README.md index 9ce9275..efe1760 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ Most agentic design writing covers architecture (orchestration, tool use, memory ## What's inside -- **Principles** — essays on agent UX with live demos: legible thinking, interruptibility, delegation contracts, calibrated trust, graceful failure -- **Components** — production-quality React components, installable via the shadcn registry or copy-paste: tool call card, approval gate, agent task list, and more -- **Resources** — teardowns of production agent UIs, a Figma library, and a glossary +- **Principles** — five argued essays on agent UX with live demos: legible thinking, interruptibility, delegation contracts, calibrated trust, graceful failure +- **Components** — ten production React components across Legibility, Control, and Trust, installable via the shadcn registry or copy-paste +- **The contract** — machine-readable and generated from component source at build time: [llms.txt](https://agent-pitstop.vercel.app/llms.txt), a [catalog](https://agent-pitstop.vercel.app/r/index.json), and a [dense contract](https://agent-pitstop.vercel.app/r/contract.json) with behavioral guarantees that the test suite asserts in CI +- **The Full Lap** — [every component driven through one interruptible agent run](https://agent-pitstop.vercel.app/full-lap) ## Install a component @@ -29,7 +30,7 @@ npm run dev ## Status -Five principles, five components, three categories. One new chapter or component lands weekly. Watch the repo or follow [@acaspx](https://github.com/acaspx). +Five principles, ten components, three categories, dark and light themes, CI-enforced behavioral guarantees. One new piece lands weekly. Watch the repo or follow [@acaspx](https://github.com/acaspx). ## Contributing diff --git a/app/components/gallery.tsx b/app/components/gallery.tsx new file mode 100644 index 0000000..6ebf6b5 --- /dev/null +++ b/app/components/gallery.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { componentCategories, componentsIn, type ComponentEntry } from "@/lib/nav"; +import { ThemeToggle, type PreviewTheme } from "@/components/docs/theme-toggle"; +import { minis } from "./gallery-minis"; + +function GalleryCard({ entry }: { entry: ComponentEntry }) { + const [theme, setTheme] = useState("dark"); + + return ( +
+
+ {minis[entry.slug]} +
+ +
+
+ + {entry.title} + +

{entry.description}

+
+ +
+
+ ); +} + +export function Gallery() { + return ( + <> + {componentCategories.map((cat) => ( +
+

{cat}

+
+ {componentsIn(cat).map((c) => ( + + ))} +
+
+ ))} + + ); +} diff --git a/app/components/page.tsx b/app/components/page.tsx index fd2de02..de478cc 100644 --- a/app/components/page.tsx +++ b/app/components/page.tsx @@ -1,42 +1,21 @@ -import Link from "next/link"; -import { componentCategories, componentsIn } from "@/lib/nav"; -import { minis } from "./gallery-minis"; +import { Gallery } from "./gallery"; export const metadata = { title: "Components — Agent Pit Stop", - description: "Every component, with live previews. Installable via the shadcn registry.", + description: "Every component, with live previews in both themes. Installable via the shadcn registry.", }; - export default function ComponentsOverview() { return (

Components

- Every component, live. Each installs with one shadcn command or copies as a single - file. Built with React 19, Tailwind 4, and Motion. + Every component, live. Flip any preview between dark and light: the components + never change, only the twelve tokens underneath them. Each installs with one + shadcn command or copies as a single file.

- {componentCategories.map((cat) => ( -
-

{cat}

-
- {componentsIn(cat).map((c) => ( - -
{minis[c.slug]}
-
- {c.title} -
-
{c.description}
- - ))} -
-
- ))} +
); } diff --git a/app/globals.css b/app/globals.css index 20b9f19..64d9b8b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -30,6 +30,22 @@ html { background: color-mix(in srgb, var(--color-pit) 30%, transparent); } +/* light theme: same tokens, daylight values. All text pairs verified >= 4.5:1. */ +[data-theme="light"] { + --color-track: #f7f7f8; + --color-asphalt: #ffffff; + --color-carbon: #fbfbfc; + --color-barrier: #eceef1; + --color-line: #d9dbe1; + --color-chalk: #17171a; + --color-smoke: #4b4b55; + --color-ash: #6d6d78; + --color-signal: #047857; + --color-caution: #a16207; + --color-flag: #dc2626; + --color-pit: #b45309; +} + /* headlights language: faint dot-grid texture for large surfaces */ .bg-dots { background-image: radial-gradient(color-mix(in srgb, var(--color-line) 55%, transparent) 1px, transparent 1px); diff --git a/app/how-it-works/page.tsx b/app/how-it-works/page.tsx index 5d1b59d..851191d 100644 --- a/app/how-it-works/page.tsx +++ b/app/how-it-works/page.tsx @@ -154,6 +154,26 @@ export default function HowItWorksPage() { --color-signal: #34d399; /* success */ --color-caution: #fde047; /* attention */ --color-flag: #f87171; /* danger */ +}`} + /> + +

+ Light mode is the same twelve tokens with daylight values, every text pair + verified at 4.5:1 or better. Scope them under a{" "} + data-theme="light"{" "} + attribute and toggle it anywhere in the tree; every gallery card and component + preview has a dark/light toggle so you can check both. +

+
+
diff --git a/components/docs/preview-pane.tsx b/components/docs/preview-pane.tsx index ac986fb..f116861 100644 --- a/components/docs/preview-pane.tsx +++ b/components/docs/preview-pane.tsx @@ -11,6 +11,7 @@ import { useState, type ReactNode } from "react"; import type { Format } from "@/lib/nav"; +import { ThemeToggle, type PreviewTheme } from "./theme-toggle"; const formatMeta: Record = { web: { @@ -88,11 +89,13 @@ export interface PreviewPaneProps { export function PreviewPane({ formats, children, variants, chatPrompt }: PreviewPaneProps) { const [format, setFormat] = useState(formats[0] ?? "web"); + const [theme, setTheme] = useState("dark"); const content = variants?.[format] ?? children; return (
+ {formats.map((f) => ( + ))} +
+ ); +} diff --git a/package.json b/package.json index 16bf7d0..067614f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "prebuild": "node scripts/build-registry.mjs", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run" }, "dependencies": { "geist": "^1.4.0", @@ -23,6 +24,9 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.0", + "@testing-library/react": "^16.1.0", + "jsdom": "^25.0.0", + "vitest": "^2.1.0", "@types/mdx": "^2.0.13", "@types/node": "^22.0.0", "@types/react": "^19.0.0", diff --git a/tests/guarantees.test.tsx b/tests/guarantees.test.tsx new file mode 100644 index 0000000..9d6d361 --- /dev/null +++ b/tests/guarantees.test.tsx @@ -0,0 +1,140 @@ +/** + * The contract's guarantees, asserted. + * + * /r/contract.json declares five behavioral guarantees. These tests are + * their enforcement: a change that breaks a guarantee fails CI, so the + * philosophy cannot be broken silently. Each describe block names the + * guarantee it protects. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { InterruptBar } from "@/registry/interrupt-bar/interrupt-bar"; +import { ApprovalGate } from "@/registry/approval-gate/approval-gate"; +import { ConfidenceMeter } from "@/registry/confidence-meter/confidence-meter"; +import { ToolCallCard } from "@/registry/tool-call-card/tool-call-card"; +import { AgentTaskList } from "@/registry/agent-task-list/agent-task-list"; + +describe("guarantee: stopping preserves partial work", () => { + it("stop shows a receipt naming what survived and never discards silently", async () => { + const onStop = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Stop" })); + expect(onStop).toHaveBeenCalledOnce(); + expect(await screen.findByText(/Stopped by you/)).toBeTruthy(); + expect(await screen.findByText(/14 results kept/)).toBeTruthy(); + }); + + it("steering redirects without stopping the run", () => { + const onSteer = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText("Steer the agent"), { + target: { value: "aisle seats only" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expect(onSteer).toHaveBeenCalledWith("aisle seats only"); + // still running: the stop control is still present + expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); + }); +}); + +describe("guarantee: nothing irreversible without explicit consent", () => { + it("approval fires only on an explicit click, and irreversible scope is labeled", async () => { + const onApprove = vi.fn(); + render( + , + ); + // labeled in words, not color alone + expect(screen.getByText(/Can't undo/)).toBeTruthy(); + expect(onApprove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + expect(onApprove).toHaveBeenCalledOnce(); + // collapses to a labeled receipt + expect(await screen.findByText("Approved")).toBeTruthy(); + }); + + it("denying is as available as approving and leaves a receipt", async () => { + const onDeny = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(onDeny).toHaveBeenCalledOnce(); + expect(await screen.findByText("Denied")).toBeTruthy(); + }); +}); + +describe("guarantee: confidence renders as bands, never decimals", () => { + it("shows a band word and no raw percentage", () => { + render(); + expect(screen.getByText("High confidence")).toBeTruthy(); + expect(screen.queryByText(/87/)).toBeNull(); + expect(screen.queryByText(/%/)).toBeNull(); + }); + + it("below the threshold the meter becomes a verification request", () => { + const onVerify = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Check this" })); + expect(onVerify).toHaveBeenCalledOnce(); + }); + + it("above the threshold no verification pressure is applied", () => { + render(); + expect(screen.queryByRole("button", { name: "Check this" })).toBeNull(); + }); +}); + +describe("guarantee: failures keep their inputs visible", () => { + it("a failed tool call still exposes its arguments beside the error", () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { expanded: false })); + expect(screen.getByText(/B6-616/)).toBeTruthy(); + expect(screen.getByText(/FareExpiredError/)).toBeTruthy(); + }); + + it("a failed plan step stays in the list with its error attached", () => { + render( + , + ); + expect(screen.getByText("Hold fare")).toBeTruthy(); + expect(screen.getByText("Fare expired")).toBeTruthy(); + expect(screen.getByText("Search")).toBeTruthy(); // done stays visible too + }); +}); + +describe("guarantee: state is never communicated by color alone", () => { + it("tool call states carry text labels", () => { + const { rerender } = render(); + expect(screen.getByText("Running")).toBeTruthy(); + rerender(); + expect(screen.getByText("Failed")).toBeTruthy(); + rerender(); + expect(screen.getByText("Done")).toBeTruthy(); + }); + + it("the approval gate's waiting state is labeled in words", () => { + render(); + expect(screen.getByText(/Waiting for you/)).toBeTruthy(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..01ac9c6 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,19 @@ +import { MotionGlobalConfig } from "motion/react"; + +// animations resolve instantly in tests, so AnimatePresence exits complete +// and receipts/state changes are assertable synchronously +MotionGlobalConfig.skipAnimations = true; + +// jsdom shims for Motion +if (typeof window !== "undefined" && !window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..f840347 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + esbuild: { jsx: "automatic" }, + resolve: { + alias: { "@": path.resolve(__dirname) }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + include: ["tests/**/*.test.tsx"], + }, +});