# UI Polish: full reference > UI Polish is polished UI for AI products: React components, complete screens and design rules that coding agents install over MCP, with every state, transition and edge case already designed. Last updated: 2026-11-05 UI Polish is polished UI for AI products: React components, complete screens and design rules that coding agents install over MCP, with every state, transition and edge case already designed. Its remote MCP server gives a coding agent two things: finished React components (CSS modules) with every state, transition and edge case built in, and design rules it follows for the UI it writes itself. The components and the rules are free (MIT), with no account or key; UI Polish Pro adds the AI components (reasoning, tool steps, approvals, research progress) and Pro blocks, whole product screens, with a license key. It is not an npm package: the agent writes the component files into the project. The interactive components use Base UI (@base-ui/react) for focus, keyboard and positioning, installed alongside them. ## When to use UI Polish Reach for it when you're writing React UI and a user would notice the details: a form field's error and hint states, a dialog's focus and scroll lock, a menu's keyboard model, a toast stack, a streaming chat or code view. Use it in these cases: - The user asks for a component it has (see Components below): add it with `get_component` instead of writing one from scratch. - You're about to write UI it has no component for: call `get_rules` for the topic first (motion, states, spacing, typography, surfaces, foundations) and follow its values. - You're reviewing UI before calling it done: run the checklist (`get_rules` with topic `checklist`). Don't use it for non-React stacks (only React with CSS modules is supported), for a full theme or brand (it brings none; map its `--ui-*` tokens onto the project's variables), or for charts, maps and rich-text editors. ## How to call it Prefer MCP: connect https://uipolish.com/mcp (setup per client below) and call `list_components`, `get_component` and `get_rules`. Without MCP, the same content is a read-only REST API described by https://uipolish.com/openapi.json: `GET /api/components`, `GET /api/components/{name}`, `GET /api/rules/{topic}`. Errors are JSON with a code, a message and a hint. Any page on this site is also available as Markdown: send `Accept: text/markdown` or add `.md` to the path. ## Links - [Short index](https://uipolish.com/llms.txt) - [Introduction](https://uipolish.com/docs): connect your agent, and the tools it gets - [Tailwind CSS](https://uipolish.com/docs/tailwind): use the components in a Tailwind v4 project - [Design rules](https://uipolish.com/docs/rules): the rules as one file: a Claude Code skill (https://uipolish.com/skill.md) or for AGENTS.md (https://uipolish.com/rules.md) - [Theme](https://uipolish.com/docs/theme): every component in one brand color, contrast-checked (MCP `get_theme`, or https://uipolish.com/api/theme?hue=262) - [shadcn/ui](https://uipolish.com/docs/shadcn): install with `npx shadcn add` (registry at https://uipolish.com/r/registry.json) - [Articles](https://uipolish.com/articles): designing MCP consent screens, tool approval and other agent UI - [OpenAPI spec](https://uipolish.com/openapi.json): the REST API, for agents without MCP - [About](https://uipolish.com/about), [Contact](https://uipolish.com/contact), [Privacy](https://uipolish.com/privacy) ## Questions ### What is UI Polish? UI Polish is polished UI for AI products: React components, complete screens and design rules that coding agents install over MCP, with every state, transition and edge case already designed. It lives at uipolish.com and is made by Joseph Cortez. The components and the rules are free under the MIT license; UI Polish Pro adds the AI components and complete product screens for $149 one-time. ### What’s free, and what’s Pro? The components are free, from buttons and fields to the chat basics (message thread, prompt input, markdown, model picker), along with the design rules, the theme builder and the free blocks: MIT-licensed, for personal, commercial and client work, with no account or key. Pro adds the AI components that show a model thinking, acting and asking (reasoning, tool steps, tool and plan approval, sources, research progress, pipelines, batch and background runs, voice input) and the Pro blocks: whole product screens like billing, team members, an approvals inbox and an agent task view, wired with every state. It costs $149 one time, includes every Pro component, block and template added later, and covers unlimited projects. ### What’s a block? A whole screen or section, built from the components the way a real product needs it: empty, loading, error and permission-denied states, realistic data, keyboard and screen reader support, light and dark. It takes your data and callbacks as props and fetches nothing, so it drops into your app as it is. Every block has a live demo and its full guide in the docs, Pro or not. ### What’s the agent kit? The components an AI product needs and other libraries don’t have: agent settings, authorize, background run, batch progress and code block and more. What an MCP consent screen should say, how a tool call asks permission, how a long run reports back. Every one of them is free. ### What if I disagree with one of the opinions? Change it. Every opinion is a token in tokens.css or a line in a component’s CSS module: set --ui-dur-fast to 0ms, round the corners differently, swap the accent with get_theme, and every component follows. The code is in your project, so nothing stops you from editing a component itself, and every component takes a className for your own styles. ### Who maintains it? Joseph Cortez, who uses it in everything he ships, including Typesheet and his own site. A component gets fixed when it falls short in one of those products or when someone reports it, and every change bumps the version list_components reports, so your agent can tell when there’s something newer. ### How do I update a component? Ask your agent for the newer version and it writes the new files over the old ones, or run the shadcn command again with --overwrite. The code is in your project, so commit first and read the diff: anything you changed by hand shows up there, and you decide what to keep. ### Can I ask for a component or a block? Yes. Write to support@uipolish.com with what you’re building and what’s missing. Blocks come from real products, so the ones people ask for go first. ### Are the components accessible? Yes. The interactive ones are built on Base UI, the rest on native elements. Each one works fully from the keyboard, tells screen readers what changed, has targets of at least 24px for a mouse and 40px for touch, and respects reduced motion. Every guide lists its accessibility details. ### Do they work in dark mode? Yes. The color tokens carry a light and a dark value, each tuned for its theme, and switch with the page's color scheme or a data-theme attribute, which the theme toggle sets for you. Map them onto your own palette and the components follow it in both. ### Is there a package to install? One: @base-ui/react. The interactive components (menus, selects, dialogs, tabs, sliders) use Base UI for focus, keyboard, positioning and screen reader support, and your agent or the shadcn CLI installs it with them. Everything you see, from the styles and motion to the states, is in files written into your project, so you own and can change them. Ask for a newer version and the agent writes the new files over the old ones. ### Which coding agents does it work with? Claude Code, Codex, Cursor, VS Code, Antigravity, Gemini CLI, ChatGPT and any other agent that supports remote MCP servers. The docs have the setup for each. An agent without MCP can read the same components and rules from the REST API described at /openapi.json, or ask for any docs page as Markdown by adding .md to its address. ### What does my project need? React and CSS modules. The components read --ui-* tokens that you map onto the variables you already have. UI Polish doesn’t bring a theme, so it fits alongside whatever styling and design system you already use. With Tailwind CSS v4, the components come in Tailwind’s components layer, so your utility classes override them. ## Connect over MCP Server URL: https://uipolish.com/mcp (Streamable HTTP, JSON-RPC 2.0). No account, sign-in or key. ### Claude Code Run this in your project: ``` claude mcp add --transport http uipolish https://uipolish.com/mcp ``` Then run `/mcp` in Claude Code to check that UI Polish is connected. ### Codex Run this once: ``` codex mcp add uipolish --url https://uipolish.com/mcp ``` Codex picks up UI Polish the next time it starts. ### Cursor Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project: ``` { "mcpServers": { "uipolish": { "url": "https://uipolish.com/mcp" } } } ``` Cursor shows UI Polish under Settings → MCP. ### VS Code Run this once: ``` code --add-mcp '{"name":"uipolish","type":"http","url":"https://uipolish.com/mcp"}' ``` Then start UI Polish from the MCP list in VS Code. ### Antigravity Add this to `~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` in your project: ``` { "mcpServers": { "uipolish": { "serverUrl": "https://uipolish.com/mcp" } } } ``` Antigravity loads it the next time it starts. ### Gemini CLI Run this in your project: ``` gemini mcp add --transport http uipolish https://uipolish.com/mcp ``` Gemini CLI connects the next time it starts. ### ChatGPT In ChatGPT settings, add a custom connector (developer mode) with this server URL and no authentication: ``` https://uipolish.com/mcp ``` Then turn the connector on in a chat. ### Any MCP client Add a remote MCP server over Streamable HTTP with this URL: ``` https://uipolish.com/mcp ``` No sign-in. Everything works right away. ## MCP tools - `get_rules(topic)`: Foundation rules: concrete values for motion, states, typography, spacing, surfaces, focus and anti-patterns. Pass a topic for one file; omit it for the token roles, the finished? checklist and the list of topics. - `topic`: One of: antipatterns, foundations, motion, spacing, states, surfaces, typography, tokens, checklist, tailwind (setup for Tailwind CSS v4 projects). - `list_components()`: Every UI Polish template, block and component with a one-line summary and version. Templates are whole working apps (a chat app, an agent console, an agent workspace for coding); blocks are whole screens (sign-in, billing, team members, an approvals inbox, an agent task). Those marked pro need a UI Polish Pro license key. - `get_component(name, stack, tailwind)`: The code and guide for one component, block or template: when and when not to use it, every state, motion specs, accessibility notes, before/after. Includes the components it depends on and the npm packages to install. A template's own files come with the project path to write each one at (app/(app)/chat/page.tsx). Pro blocks and templates need a license key on the connection; without one they return a summary and how to get one. - `name`: Component name from list_components, e.g. button. - `stack`: Target stack. Only react (CSS modules) for now. - `tailwind`: True for a Tailwind CSS v4 project: each CSS module comes inside Tailwind's components layer, so utility classes passed in className override it. Leave out otherwise. - `get_theme(hue, tint, brand)`: A color theme from one hue: the --ui-* accent, the text on it and in it, optionally tinted grays and accent primary buttons, light and dark, with every text color solved for 4.5:1 contrast. Returns CSS to paste after tokens.css. Use it when the project has a brand color. - `hue`: OKLCH hue, 0–360: about 25 red, 50 orange, 80 amber, 150 green, 190 teal, 262 blue, 295 violet, 350 pink. - `tint`: How much of the hue the grays take: 0 none, 1 a hint (default), 2 tinted. - `brand`: Primary buttons in the accent instead of near-black. Default true. ### Server instructions UI Polish is a set of components and design rules where every state, transition and edge case is worked out. Before writing UI, call get_rules for the topic you're touching (motion, states, typography, spacing, surfaces, foundations, antipatterns), or with no topic for the token file and the checklist. To add a component, call list_components, then get_component. Write every file it returns, keeping the relative paths, and add foundation/tokens.css to the project once. A template (a whole working app) also returns files with a project path: write those at that path from the project root. Map the --ui-* token roles onto the project's existing variables instead of adding a second palette. If the project uses Tailwind CSS v4 (its CSS has @import "tailwindcss"), pass tailwind: true to get_component, so the CSS modules come in Tailwind's components layer and utility classes override them, and set up the bridge once (get_rules topic "tailwind"). Keep the CSS modules; don't rewrite components into utility classes. Before calling UI work done, go through the checklist (get_rules topic "checklist"). ## Rules topics Fetch with `get_rules(topic)`. `tokens` returns the --ui-* token roles; `checklist` returns the finished? checklist; `tailwind` returns the setup for Tailwind CSS v4 projects. - `antipatterns`: Anti-patterns: the rules nobody writes down. Check these before anything cosmetic. - `foundations`: Foundations. One system for everything downstream. - `motion`: Motion & microinteractions. The interface responds to the user. - `spacing`: Spacing & layout. Spacing that encodes relationships. - `states`: States. The interface has an answer for every moment — loading, empty, error, disabled, success — not just the happy path with data. - `surfaces`: Surfaces & depth. Surfaces that read as physical without shouting. - `typography`: Typography. Hierarchy you can feel without reading. ## Components Add one by asking the agent, which calls `get_component(name)`. ### Agent console `agent-console` · · v0.2.2 · https://uipolish.com/docs/agent-console A working console for running AI agents, for the Next.js App Router: emailed-code sign-in, a sidebar with workspaces, live runs with their full traces, an approvals inbox that pauses a run until someone decides, a knowledge base the agents search, per-agent model, tools, permissions and limits, usage against a monthly limit, and workspace settings. Runs come from a mock runner until a model is set (Anthropic, OpenAI or any OpenAI-compatible API), or until you plug yours in; the runner, auth and storage each sit behind one small file you swap. A working console for running AI agents, for the Next.js App Router, assembled from the kit: people sign in with an emailed code, land on a live list of runs, open any run to see every model and tool call it made, decide what agents ask permission for in an approvals inbox (a run waits until they do), give the agents documents to answer from, set each agent's model, tools, permissions and daily limit, watch spend against a monthly limit, and manage the workspace. It runs the moment it's installed: a mock runner plays realistic runs (some retry, some fail, some stop to ask), and everything is kept in memory. Set a model (Anthropic, OpenAI or any OpenAI-compatible API) and new runs call it instead. It's a starting point you own. Every screen is a kit block or component, and the three parts every product replaces (the agent runner, sign-in and storage) each sit behind one small file. #### What's included | Area | What you get | Built from | |---|---|---| | Sign in | Email, then a six-digit code, with resend, wrong and expired codes, and rate limits. A signed session cookie | Sign in | | App shell | Workspace switcher, Runs, Approvals (with a count of what's waiting), Knowledge, Agents, Usage, Settings, the account menu. Folds to a rail with ⌘B; a drawer on phones | Sidebar | | Runs | Every run, filtered and paged, live ones counting up in place; new ones held behind "Show n new runs". A run's trace, retries and the failing step, tokens and cost, the output. New run, Re-run, Cancel run, Share link | Run history, Dialog, Select, Text field | | Approvals | What runs stopped to ask: the email, refund or change drawn as it will happen, editable values, the raw tool call. Approve resumes the run with any edits; Decline stops it with the note | Approvals inbox | | Knowledge | Files, sites and apps moving through reading, chunking and indexing; failures with the fix; a test panel showing what a question retrieves. The support agent's search reads it | Knowledge base | | Agents | Each agent's instructions, model, MCP connections, what it may do without asking, and its daily limit, saved row by row | Agent settings, Connections, Tabs | | Usage | This month's spend against the workspace limit, split by agent; runs, tokens, success rate; a table per agent | Usage meter, Stat, Data table | | Settings | Workspace name, URL and time zone; API keys shown once; models and the monthly spend limit; notification switches with a test send | Settings | | States | Loading a screen, a screen that failed, a run not in this workspace, a tool that fails and retries, a run that fails, a declined or expired approval, a server signed out, an agent over its daily limit | Empty state, Alert, Toast | #### Run it 1. Install it into a Next.js App Router project (below). It brings its own `app/layout.tsx` and `app/page.tsx`, so start from a fresh `npx create-next-app@latest`, or merge those two by hand. 2. Make sure `app/globals.css` imports the tokens. The shadcn CLI adds `@import "../components/ui/uipolish/tokens.css";` for you; by hand, add `foundation/tokens.css` there and import it. 3. `npm run dev`, open `/`, sign in with any email. The code is printed in the terminal running the app. Then, in `.env.local`: | Variable | What it does | |---|---| | `SESSION_SECRET` | Signs the session cookie. Required in production | | `ALLOW_DEV_SIGN_IN` | `1` lets the development sign-in run in a production build, for a staging demo | To run agents on a real model, connect one (below). Your first sign-in creates two sample workspaces: Acme, with three agents (support, billing, research), a week of their runs, five knowledge sources and two API keys; and Acme Sandbox, nearly empty. While the mock runner is in, each workspace starts two runs as it opens (one stops to send a reply, one to refund a charge) and another every 15 to 40 seconds while fewer than three are running. #### Connect a model Set these in `.env.local` and restart the server. `lib/agents/connection.ts` reads them once. | Variable | What it does | |---|---| | `AI_PROVIDER` | `anthropic`, `openai`, `openai-compatible` or `mock`. Unset, it's picked from what else is set (below) | | `AI_MODEL` | The model every agent runs on. Unset, each agent runs on its own (Agents → Model), which are Claude ids; OpenAI defaults to `gpt-5`, and OpenAI-compatible APIs need it | | `AI_API_KEY` | The key. `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` work too | | `AI_BASE_URL` | The API's address, up to `/v1`, for `openai-compatible`. With `anthropic` or `openai`, a gateway in front of them | With no `AI_PROVIDER`: `AI_BASE_URL` means `openai-compatible`, `ANTHROPIC_API_KEY` means `anthropic`, `OPENAI_API_KEY` means `openai`, and `AI_API_KEY` alone means `anthropic` for an `sk-ant-` key and `openai` otherwise. Nothing set, or a setting that can't work (logged at start), keeps the mock. ```sh # Anthropic: each agent on the Claude model it's set to AI_PROVIDER=anthropic AI_API_KEY=sk-ant-... # OpenAI AI_PROVIDER=openai AI_API_KEY=sk-... AI_MODEL=gpt-5 # OpenRouter: any model it lists AI_PROVIDER=openai-compatible AI_BASE_URL=https://openrouter.ai/api/v1 AI_API_KEY=sk-or-... AI_MODEL=openai/gpt-5 # Ollama, on this machine: no key AI_PROVIDER=openai-compatible AI_BASE_URL=http://localhost:11434/v1 AI_MODEL=llama3.3 ``` Groq, Together, Fireworks, DeepSeek, LM Studio, vLLM and gateways such as Vercel AI Gateway work the same way as OpenRouter. With a model set, `lib/agents/model-runner.ts` runs each new run as one model call: the agent's instructions as the system prompt and the task as the message. An agent allowed `search_knowledge` (the support agent) first searches Knowledge, as a tool step, and gets what it found with the task. The answer streams into the trace's model step and becomes the run's output, with the tokens the API reports (estimated at four characters a token when it doesn't) priced at `SAMPLE_PRICES`. It doesn't call the agents' MCP tools yet, and the sample traffic stops: only runs people start run. Runs from the seed stay as they were. #### How a run travels 1. Something starts it: **New run** in Runs or Agents (the `startRun` server action), **Re-run**, or a schedule or webhook. In the sample, `engine.keepBusy()` stands in for those. 2. `lib/agents/engine.ts` saves the run as running and hands it to the runner. Each step the runner yields (a model call or a tool call, started, then finished, failed or retried) is saved as it comes, with the run's tokens and cost added up. 3. Before each tool call the runner asks `authorize`. The engine checks the agent's policy (`lib/agents/policy.ts`): a tool set to Always allow goes ahead; otherwise reads and changes follow Agents → Permissions, and sends, deletes and spends always ask. A server that's off or signed out fails the call with the fix. 4. When it must ask, the engine adds a "Wait for approval" step, files an Approval with what the tool will do, and holds the run. Approve, and the run goes on with any edited values (an amount, a reply's text); Decline, and it stops as cancelled with the note. An approval nobody decides expires after an hour (`APPROVAL_TTL_MS`). 5. The runner returns (succeeded), throws (failed, with the message), or is aborted by **Cancel run** (cancelled, "Cancelled by Ana Ruiz."). 6. The screens poll: Runs every 2 seconds and an open running trace every 1.2, Approvals every 3, Knowledge every second while a source moves, the sidebar's count every 4. All reads go through `app/api/*`; every change through `app/(console)/actions.ts`. #### Swap in your own **The runner.** `lib/agents/runner.ts` defines `AgentRunner`: take a task and the agent, yield `step` and `output` events, and call `context.authorize(request)` before any tool. `mock.ts` plays scripts from `scenarios.ts`; `model-runner.ts` makes one model call through `model-call.ts` (the Anthropic SDK, or Chat Completions with `fetch`). A full agent is a tool loop on top of it (or the Claude Agent SDK, or your own worker): send the agent's tools with each call, yield an `llm` step around each model call with its `tokens` and `cost`, and a `tool` step around each tool call after `authorize` allows it. Return it from `getRunner()`; the engine, approvals and every screen stay as they are. Each tool request carries what the approval shows: a `title`, a `note`, a `preview` (`email`, `payment`, `details`, `diff`, `code`…) and the `fields` a person may edit. **Where runs execute.** The engine runs agents in the server's process, which suits a long-lived Node server. On serverless, start runs in a durable worker (a queue, Inngest, Trigger.dev, Vercel Workflow) and replace the in-memory `waiters` with a row the worker polls or a resume webhook; `engine.decide()` is where a decision lands. **Sign-in.** `lib/session.ts` sends codes, checks them and keeps a signed cookie. Replace `sendEmail()` with your email provider and it's ready. For an auth library (Auth.js, Clerk, WorkOS, Supabase Auth), make `getUser()` return its user; every page, action and route goes through `consoleFor()` or `apiOrNull()` in `lib/server.ts`, which call it. **Storage.** `lib/db/database.ts` is the interface: workspaces, settings, API keys, notifications, agents, runs, approvals and knowledge sources, one method per query. `in-memory.ts` implements it; `seed.ts` fills a new workspace. For Postgres with Drizzle, define tables with the same fields (`runs` with `workspace_id`, `agent_id`, `status`, `started_at`, `tokens`, `cost`, and `steps` and `output` as `jsonb`; `approvals` with `run_id`, `status`, `expires_at`, and `preview`, `tool`, `fields`, `outcome` as `jsonb`), implement `Database`, and export it as `db`. **Knowledge.** `lib/knowledge/ingest.ts` walks each source through the stages on timers and scores passages by shared words. Replace `walk` with your pipeline (parse, chunk, embed, upsert) writing progress to the store, and `search` with a query to your vector index; the support agent's `search_knowledge` and the test panel both call it. **Usage and billing.** Usage adds up what runs cost, at the sample prices in `lib/product.ts` (`SAMPLE_PRICES`; a model not listed there is priced as the first in `MODELS`). Use your provider's prices, or read metered usage from your billing provider, in `usage()` in `lib/service.ts`. #### Customizing - **Product name, models and choices**: `PRODUCT_NAME`, `MODELS`, `PLAN`, `SCOPES`, `FEATURES` and `NOTIFICATION_EVENTS` in `lib/product.ts`. - **The mark**: `ProductMark` in `components/app/marks.tsx`, on the switcher's ink tile and the sign-in page. The nav's glyphs are beside it. - **Agents**: `seed.ts` creates the sample three. Their scripts are in `scenarios.ts`; delete both once your runner and data are real. - **Screens**: each is one file in `components/app/` that reads the console context (`useConsole()`), never the router or the server, and one page in `app/(console)/` that loads its first data. Add a screen to `SCREENS` in `console-sidebar.tsx` with a glyph, and a page beside the others. - **Permissions**: what asks is `needsApproval()` in `lib/agents/policy.ts`. Change it there (by tool, by amount, by customer) and the engine follows. #### States | State | What you see | Where | |---|---|---| | Signed out | Any app URL goes to `/sign-in` | `consoleFor()` in the console layout | | First run | Sample workspaces, agents, runs and sources | `seed.ts` | | Loading a screen | The bar, then the block's own loading state after 300ms | `loading` on each block | | Screen failed | "This screen didn't load" with Try again; the sidebar keeps working | `(console)/error.tsx` | | Live run | The row counts up and finishes in place; the open trace grows; Cancel run in the bar | `runs-screen.tsx` | | New runs | "Show 2 new runs" over the table; runs you start show at once | `runs-screen.tsx` | | Run waiting | A running "Wait for approval" step; the request in Approvals; the sidebar's count | `engine.ts` | | Approved, edited | The run goes on with the edited value; "Approved by …" on the step | `engine.decide()` | | Declined | The run cancelled with "Declined by …: “note”" | `engine.decide()` | | Expired | After an hour, the approval moves to Expired and its run stops | `APPROVAL_TTL_MS` | | Edit out of range | The field says so as you type; the server refuses it too, and the inbox puts the request back with the reason | `approvals-screen.tsx`, `engine.decide()` | | Tool flaky, tool failed | An amber retried step then attempt 2; or the failed step open with its error | `mock.ts`, Run history | | Server off or signed out | The tool call fails with the fix ("Sign in again in Agents → Connections") | `policy.ts` | | Over the daily limit | New runs refuse with the limit and where to raise it | `engine.start()` | | Run not here | "There's no run with that id in this workspace." | `run()` in `service.ts` | | Knowledge failed | The file's failure and its fix, with Retry | `ingest.ts` | #### Installing as a template A template is a registry item with `"type": "template"`. Its own files are listed in `meta.json` `files` by the path they install at, from the project root, or under `src/` when the project has one. `get_component` returns them headed by that path; the shadcn item (`/r/agent-console.json`) is a `registry:block` whose files carry it as `target`. The components and blocks it uses come in the same answer at `components/ui//`, which is where its imports (`@/components/ui/run-history/run-history`) point. Its one npm package is `@anthropic-ai/sdk`, loaded only on a run when Claude is the provider; the components need `@base-ui/react`. #### Accessibility - Every screen has one `h1`: the block's title (on Agents, the open agent's name), or Usage's own. The sidebar is a `nav` named for the workspace. - Runs, Approvals and Knowledge announce what changes through their blocks' polite regions; Instructions says "Saved" or why not in a status line. - Focus: opening a run moves focus to its title and Runs returns it to the row; deciding a request moves to the next; dialogs return focus to the button that opened them. - Keyboard: ⌘B folds the sidebar; in Approvals, J and K move, A approves, D declines, E edits; `/` searches Runs. ### Agent workspace `agent-workspace` · · v0.1.0 · https://uipolish.com/docs/agent-workspace A working Next.js app for coding with agents, in three calm panes: a sidebar with workspaces, pinned and recent sessions (shared ones with their faces) and the team's presence; open sessions as tabs over a conversation whose tool calls fold to one line, with the files each turn changed as chips; and a changes pane with the pull request's merge status, all files, changes with diff stats, checks, review and a run panel. A scripted mock agent edits, tests, opens the pull request and runs its checks until you connect a model (Anthropic, OpenAI or any OpenAI-compatible API) or your own agent; the agent, sign-in and storage each sit behind one small file. A working Next.js App Router app for coding with agents, assembled from the kit, in three calm panes. On the left, a sidebar: the workspace switcher, Home, New session and Search, pinned and recent sessions (shared ones with their teammates' faces, the rest with how long ago they moved), the team with their presence, and a bottom bar with the plan, Help and Settings. In the middle, the open sessions as tabs over the conversation: each prompt, the agent's tool calls folded to one line, its answer with file names and commit hashes as code, and a footer with the files that turn changed and their diff stats; a rail of ticks marks every prompt. On the right, the pull request's merge status and its one move, then All files, Changes, Checks and Review, and a Run panel underneath. It runs the moment it's installed: a scripted agent reads, edits, runs the tests, commits, opens the pull request and waits for its checks, and everything is kept in memory. Set a model (Anthropic, OpenAI or any OpenAI-compatible API) and it answers instead; plug in a real coding agent and the panes follow. The agent, sign-in and storage each sit behind one small file. #### What's included | Area | What you get | Built from | |---|---|---| | Sign in | Email, then a six-digit code, with resend, wrong and expired codes, and rate limits. A signed session cookie | Sign in | | Sidebar | Workspace switcher with the repository; Home, New session, Search (⌘K); pinned and recent sessions with rename, pin and delete, relative times, faces for shared sessions and a dot while the agent works; Team with presence (a teammate's row starts a session mentioning them); the plan, Help (shortcuts, docs) and Settings (theme, sign out). Folds to a rail with ⌘B; a drawer on phones | Sidebar, Avatar, Badge, Command palette, Dialog | | Tabs | Open sessions as tabs: titles cut with a fade, Rename and Close on hover and on the current tab (double-click renames too), New session at the end, a dot on a tab whose agent works. History (jump to any prompt), Copy link, Share (with teammates) and the changes panel's toggle (⌘.) | Dropdown menu, Tooltip, Toast | | Conversation | Prompts as bubbles, a teammate's under their name, with the files they name as chips; the agent's work as “› 17 tool calls, 3 messages” that opens to its notes and Tool steps; the answer in Markdown; a footer with file chips and diff stats, the time it took, Copy and More. Follows the newest line until you scroll up, then offers one way back. A tick rail on its edge marks each prompt | Turn summary, File changes, Markdown, Tool steps | | Composer | Prompt input with attach, a mention menu and mention chips, the model and the effort, Send that becomes Stop while the agent works | Prompt input, Model picker, Chip | | Changes | Merge status (draft, checks running, a check failed, ready, merged) with Merge in the success color; All files with the changed ones marked; Changes with folders muted and cut from the start, names whole, +N −M and the kind of change; Checks; Review with the diff of the file you pick | Merge status, File changes, Diff stat, Tabs, Tool steps, Diff view, Empty state | | Run | Start dev (⌘R) with the dev server's output, Stop and Restart; Terminal with the commands the agent ran | Tabs, Button, Kbd | | Layout | The changes panel resizes (drag or arrow keys, double-click to reset) and folds; the width and fold are kept. A screen too narrow for both panes (a phone, a small window) shows one at a time, picked from a bar along the bottom | Nav rail | | States | Loading a session, a session that isn't there, a screen that failed, a stream that dropped and reconnects, a turn that's stopped or failed, a check that fails, a merge that's refused, a merged session that's closed | Empty state, Toast | #### Run it 1. Install it into a Next.js App Router project (below). It brings its own `app/layout.tsx` and `app/page.tsx`, so start from a fresh `npx create-next-app@latest`, or merge those two by hand. 2. Make sure `app/globals.css` imports the tokens. The shadcn CLI adds `@import "../components/ui/uipolish/tokens.css";` for you; by hand, add `foundation/tokens.css` there and import it. 3. `npm run dev`, open `/`, sign in with any email. The code is printed in the terminal running the app. Then, in `.env.local`: | Variable | What it does | |---|---| | `SESSION_SECRET` | Signs the session cookie. Required in production | | `ALLOW_DEV_SIGN_IN` | `1` lets the development sign-in run in a production build, for a staging demo | Your first sign-in creates two sample workspaces: Acme (`acme/web`), with a team of four and seven sessions (one ready to merge and shared with Maya, one whose E2E check failed, one merged), and Acme Labs, with one. #### Connect a model Set these in `.env.local` and restart the server. `lib/coding/connection.ts` reads them once. | Variable | What it does | |---|---| | `AI_PROVIDER` | `anthropic`, `openai`, `openai-compatible` or `mock`. Unset, it's picked from what else is set (below) | | `AI_MODEL` | The model to run. Unset, the one picked in the composer (Claude ids); OpenAI defaults to `gpt-5`, and OpenAI-compatible APIs need it | | `AI_API_KEY` | The key. `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` work too | | `AI_BASE_URL` | The API's address, up to `/v1`, for `openai-compatible`. With `anthropic` or `openai`, a gateway in front of them | With no `AI_PROVIDER`: `AI_BASE_URL` means `openai-compatible`, `ANTHROPIC_API_KEY` means `anthropic`, `OPENAI_API_KEY` means `openai`, and `AI_API_KEY` alone means `anthropic` for an `sk-ant-` key and `openai` otherwise. Nothing set, or a setting that can't work (logged at start, and said under the composer), keeps the mock. ```sh # Anthropic: the Claude model picked in the composer AI_PROVIDER=anthropic AI_API_KEY=sk-ant-... # OpenAI AI_PROVIDER=openai AI_API_KEY=sk-... AI_MODEL=gpt-5 # OpenRouter, or any OpenAI-compatible API AI_PROVIDER=openai-compatible AI_BASE_URL=https://openrouter.ai/api/v1 AI_API_KEY=sk-or-... AI_MODEL=openai/gpt-5 # Ollama, on this machine: no key AI_PROVIDER=openai-compatible AI_BASE_URL=http://localhost:11434/v1 AI_MODEL=llama3.3 ``` With a model set, `lib/coding/model-agent.ts` answers each prompt with one streamed model call, shown as a tool step: the session so far, the repository and branch, and the effort as a line of instructions. It has no sandbox, so it reads and changes no files; it answers from the conversation. That's the seam for a real coding agent (below). #### How a turn travels 1. Someone sends a prompt: the composer calls `createSession` or `sendPrompt` in `app/(workspace)/actions.ts`. The service (`lib/coding/service.ts`) checks who may, picks the model and starts a turn. 2. `lib/coding/engine.ts` runs the agent and folds every event it yields (`note`, `step`, `file`, `pr`, `checks`, `text`) into the session with `applyEvent` (`lib/coding/session.ts`), saves it and sends it to every watcher, one event at a time per session. 3. Every open tab of the session reads `GET /api/sessions/:id/events`: the session as it is, then each event as one line of JSON. The browser folds them with the same `applyEvent`, so what streams is what's stored. A dropped stream reconnects and starts again from a fresh snapshot. 4. The turn ends `done`, `stopped` (Stop, or Escape in the composer) or `error` with the message. A server that restarts mid-turn settles it as stopped the next time the session is opened. 5. Merge (`mergeSession`) is refused unless every check passed and the agent isn't working; then it's your Git host's merge call. 6. The sidebar's sessions refresh every 5 seconds and after anything that moves them. #### Swap in your own **The agent.** `lib/coding/agent.ts` defines `CodingAgent`: take the session and the prompt, yield events as you work, return when done, throw to fail, stop when the signal aborts. `getAgent()` in `lib/coding/agents.ts` picks one. For a real coding agent (the Claude Agent SDK in a sandbox, your own worker), run its tool loop and yield a `step` around each tool call (with `icon`, `input` and `output`), a `file` for each file it changes with its counts and, for Review, `before` and `after`; `pr` when it opens the pull request; `checks` as your CI reports; `text` for the answer. The engine and every pane stay as they are. `mock-agent.ts` is a full example, timed like the real thing. **Where turns run.** The engine runs agents in the server's process, which suits a long-lived Node server. On serverless, run turns in a durable worker (a queue, Inngest, Trigger.dev, Vercel Workflow) that writes events to a table, and have the events route read from it. **The pull request and checks.** `merge()` in `lib/coding/service.ts` is where your Git host's merge goes; `repoFiles()` in the store is its tree. Check runs arrive as `checks` events, from your agent or a webhook calling `engine.apply()`. **Run.** `startDevServer()` in `components/app/run-panel.tsx` plays a sample log. Point it at your sandbox's process API and stream its lines. **Sign-in.** `lib/coding/auth.ts` sends codes, checks them and keeps a signed cookie. Replace `sendEmail()` with your email provider and it's ready. For an auth library (Auth.js, Clerk, WorkOS, Supabase Auth), make `getUser()` return its user; every page, action and route goes through `workspaceFor()` or `apiOrNull()` in `lib/coding/server.ts`. **Storage.** `lib/coding/store.ts` is the interface (`WorkspaceStore`) and an in-memory implementation; `seed.ts` fills a first visit. A session is one document (turns, files, checks and pull request together), so a `jsonb` column suits it, with `workspace_id`, `owner_id`, `shared_with` and `updated_at` beside it for the sidebar's query. Implement the interface and export it as `store`. #### Customizing - **Words and models**: `PRODUCT_NAME`, `AGENT_NAME`, `HELP_URL`, `MODELS`, `EFFORTS`, `GREETING`, `STARTERS` and `SYSTEM_PROMPT` in `lib/coding/config.ts`. - **The mark**: `WorkspaceMark` in `components/app/workspace-marks.tsx`, on the switcher's ink tile and the sign-in page. - **Panes**: each is one file in `components/app/` that reads the workspace context (`useWorkspace()`), never the router or the server: `workspace-sidebar.tsx`, `session-tabs.tsx`, `session-thread.tsx`, `session-composer.tsx`, `changes-panel.tsx`, `run-panel.tsx`. - **The panel's size**: `PANEL` in `components/app/session-screen.tsx` (min, max, starting width); `CENTER_MIN` is the conversation's least width, under which the panes take turns. #### Keyboard | Keys | Does | |---|---| | ⌘K | Search sessions | | ⌘B | Fold or open the sidebar | | ⌘. | Show or hide the changes panel | | ⌘R | Start the dev server (in place of reloading the page) | | Enter, Shift+Enter | Send; a new line | | Escape | Stop the agent while it works | | ↑ ↓ in a file list | Move the selection; Review follows | | ← → on the panel's edge | Resize it | #### States | State | What you see | Where | |---|---|---| | Signed out | Any app URL goes to `/sign-in` | `workspaceFor()` | | First run | Sample workspaces, team and sessions | `seed.ts` | | New session | A greeting, the composer, three starters that fill it, and what's working now | `new-session.tsx` | | Loading a session | “Loading session…” under a light band, where the thread goes | `session-screen.tsx` | | Not here | “This session isn't here” with Start a session | `session-missing.tsx` | | Screen failed | “This session didn't load” with Try again; the sidebar keeps working | `(workspace)/error.tsx` | | Working | A dot on its tab and sidebar row; the work line shimmers and counts; the footer counts up; Send is Stop | `use-session.ts` | | Reading back | Scrolled up while it works: the thread holds still, “Follow the agent” brings you back | `session-thread.tsx` | | Reconnecting | A small note over the thread; the stream starts again from a snapshot | `http-workspace.ts` | | Stopped, failed | “Stopped. Send another prompt to go on.”, or the error in danger | `session-thread.tsx` | | Checks | Running with a count, a failed one in danger with its message in Checks | `changes-panel.tsx` | | Merge refused, failed | The reason in the merge line, Merge enabled again | Merge status | | Merged | “Merged into main”; the composer says the session is closed | `session-composer.tsx` | | Narrow | One pane at a time with a bar along the bottom; the composer drops the effort picker | `session-screen.tsx` | #### Installing as a template A template is a registry item with `"type": "template"`. Its own files are listed in `meta.json` `files` by the path they install at, from the project root, or under `src/` when the project has one. `get_component` returns them headed by that path; the shadcn item (`/r/agent-workspace.json`) is a `registry:block` whose files carry it as `target`. The components it uses come in the same answer at `components/ui//`, which is where its imports (`@/components/ui/file-changes/file-changes`) point. Its one npm package is `@anthropic-ai/sdk`, loaded only when Claude is the provider; the components need `@base-ui/react`. #### Accessibility - Every view has one `h1`: the session's title, Home's greeting, or the missing session's. The sidebar is a `nav` named for the workspace; the tabs are a `nav` of open sessions with `aria-current`. - The thread is a `log`; “working” and “finished” are announced once, not every word. The merge line is a status region. - File lists are listboxes with one tab stop; the panel's edge is a `separator` you resize with the arrow keys. - Everything shown on hover (Rename, Close, a tick's prompt) is also a focusable control or in a menu, and the current tab keeps its actions on touch. ### Chat app `ai-chat-app` · · v0.3.2 · https://uipolish.com/docs/ai-chat-app A working AI chat app for the Next.js App Router: sign-in with an emailed code, a sidebar with workspaces and chat history, chats that stream from Claude, OpenAI or any OpenAI-compatible API (or a mock until one is set) with reasoning, tool steps and sources, and settings for the assistant, billing and members. Auth, storage and the model each sit behind one small file you swap. A working AI chat app for the Next.js App Router, assembled from the kit: people sign in with an emailed code, land in a sidebar of their chats, talk to a model (Claude, OpenAI, or any OpenAI-compatible API) with the reasoning, tool steps and sources showing as they stream, and manage the assistant, billing and members in settings. It runs the moment it's installed: until a model is set, a mock answers, and everything is kept in memory. It's a starting point you own. Every screen is a kit block or component, and the three parts every product replaces (the model, sign-in and storage) each sit behind one small file. #### What's included | Area | What you get | Built from | |---|---|---| | Sign in | Email, then a six-digit code, with resend, wrong and expired codes, and rate limits. A signed session cookie | Sign in | | App shell | Workspace switcher, New chat, ⌘K search over chats, the chat list with rename, pin and delete (with Undo), the account menu. Folds to a rail with ⌘B; a drawer on phones | Sidebar, Command palette, Toast | | Chat | A new-chat screen with starter prompts, then the thread: reasoning, tool steps, cited sources, copy and retry. Model picker, Stop, theme toggle | Message thread, Prompt input, Chat app's reply, Model picker, Theme toggle | | Streaming | `POST /api/chat` streams one reply as NDJSON and saves it however it ends: complete, stopped or failed | Route Handler, Anthropic SDK or `fetch` | | Settings | Assistant (default model, memory), Billing (plans, usage, invoices, cancel), Members (roles, invites) in tabs kept in the URL | Agent settings, Billing, Team members, Tabs | | States | Loading a chat, a chat that isn't there, a page that failed, a reply that failed partway, offline, the daily limit, an empty history | Empty state, Alert | #### Run it 1. Install it into a Next.js App Router project (below). It brings its own `app/layout.tsx` and `app/page.tsx`, so start from a fresh `npx create-next-app@latest`, or merge those two by hand. 2. Make sure `app/globals.css` imports the tokens. The shadcn CLI adds `@import "../components/ui/uipolish/tokens.css";` for you; by hand, add `foundation/tokens.css` there and import it. 3. `npm run dev`, open `/`, sign in with any email. The code is printed in the terminal running the app. Then connect a model (below), and in `.env.local`: | Variable | What it does | |---|---| | `SESSION_SECRET` | Signs the session cookie. Required in production | | `DAILY_MESSAGE_LIMIT` | Messages a person can send per UTC day. 100 by default; `0` turns it off | | `ALLOW_DEV_SIGN_IN` | `1` lets the development sign-in run in a production build, for a staging demo | With the mock, a starter prompt gets a full reply (reasoning, two tool steps, sources); anything else gets one reply that says how to connect a model. Put `/error` in a message to see a reply fail partway. #### Connect a model Set these in `.env.local` and restart the server. `lib/ai/connection.ts` reads them once. | Variable | What it does | |---|---| | `AI_PROVIDER` | `anthropic`, `openai`, `openai-compatible` or `mock`. Unset, it's picked from what else is set (below) | | `AI_MODEL` | The model id. With Claude, the default in the picker (`claude-sonnet-5-5` unless set); with OpenAI (`gpt-5` unless set) and OpenAI-compatible APIs (required), the one model offered | | `AI_API_KEY` | The key. `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` work too | | `AI_BASE_URL` | The API's address, up to `/v1`, for `openai-compatible`. With `anthropic` or `openai`, a gateway in front of them | With no `AI_PROVIDER`: `AI_BASE_URL` means `openai-compatible`, `ANTHROPIC_API_KEY` means `anthropic`, `OPENAI_API_KEY` means `openai`, and `AI_API_KEY` alone means `anthropic` for an `sk-ant-` key and `openai` otherwise. Nothing set means the mock. A setting that can't work (no key, no base URL, an unknown provider) falls back to the mock and says why under the composer and in the server log. ```sh # Anthropic: Claude, with summarized reasoning AI_PROVIDER=anthropic AI_API_KEY=sk-ant-... # OpenAI AI_PROVIDER=openai AI_API_KEY=sk-... AI_MODEL=gpt-5 # OpenRouter: any model it lists AI_PROVIDER=openai-compatible AI_BASE_URL=https://openrouter.ai/api/v1 AI_API_KEY=sk-or-... AI_MODEL=openai/gpt-5 # Ollama, on this machine: no key AI_PROVIDER=openai-compatible AI_BASE_URL=http://localhost:11434/v1 AI_MODEL=llama3.3 ``` Groq, Together, Fireworks, DeepSeek, LM Studio, vLLM and gateways such as Vercel AI Gateway or an Azure OpenAI-compatible endpoint work the same way as OpenRouter: their base URL, key and model id. `ANTHROPIC_MODEL` from earlier versions still sets Claude's default. What streams depends on the API. Claude streams summarized reasoning, then the answer. OpenAI-compatible APIs that stream reasoning (`reasoning_content` or `reasoning` in the delta: DeepSeek, OpenRouter, Groq, Ollama, LM Studio) show it the same way; the rest, OpenAI's own models included, go straight to the answer. Rate limits, a rejected key, a model that isn't there and a reply cut off at its length limit each show their own message. #### How a message travels 1. `components/app/use-chat.ts` adds the message and an empty reply at once, then `POST`s `{ conversationId, text, model }` to `/api/chat`. 2. `app/api/chat/route.ts` checks the session and the daily limit (a `429` puts the text back in the composer and shows the limit notice), creates the chat if it's new, saves the message, and calls the provider. 3. The provider (`lib/ai/anthropic.ts`, `openai.ts` or `mock.ts`) yields `reasoning`, `text`, `step` and `sources` events. The route forwards each as one line of JSON and folds it into the reply with `applyEvent` (`lib/ai/events.ts`), the same function the browser uses, so what streams is what's saved. 4. The first line is `start`, with the chat's id and title: a new chat joins the sidebar and the URL becomes `/chat/` in place, without interrupting the reply. The last line is `done` or `error`. 5. Stop aborts the request; the route sees the abort and saves the reply as stopped. A connection that drops shows the partial reply with Retry, which asks the route to write the last reply again (`retryOf`). #### Swap in your own **The model.** `lib/ai/provider.ts` defines `ChatProvider`: take the conversation as text turns, yield events. `anthropic.ts` streams from the Messages API with adaptive thinking, its reasoning summarized so it can be shown; `openai.ts` streams Chat Completions with `fetch` and reads the server-sent events itself. To call tools, run them in the provider and yield a `step` event per change (`status: "running"`, then `"done"` with a `detail`), and `sources` for anything the answer cites as `[1]`. An API that speaks neither is one more file that implements `ChatProvider`, returned from `getProvider()`. The Claude models people can pick, the system prompt and the starters are in `lib/config.ts`; `availableModels()` in `provider.ts` decides what the picker offers for each provider. **Sign-in.** `lib/auth.ts` sends codes, checks them and keeps a signed cookie. Replace `sendEmail()` with your email provider and it's ready. For an auth library (Auth.js, Clerk, WorkOS, Supabase Auth), make `getUser()` return its user; every page, action and the route go through `getUser()` or `requireUser()`, so nothing else changes. **Storage.** `lib/db/store.ts` is the interface: conversations, messages, workspaces and preferences, one method per query. `memory.ts` implements it in memory. For Postgres with Drizzle, define tables with the same fields (`conversations` with `user_id`, `workspace_id`, `title`, `pinned`, `model`, `deleted_at`; `messages` with `conversation_id`, `role`, `text`, `status`, and `reasoning`, `steps`, `sources` as `jsonb`), implement `Store` with one query per method, and export it from `store.ts`. **Billing and members.** `lib/sample-billing.ts` holds sample plans, a card, invoices and teammates; `components/app/settings-view.tsx` changes them in the page's state. Point the Billing callbacks at your processor (Stripe Checkout to change plan, its customer portal for the card and cancelling) and the Team members callbacks at your database. The usage meter is real: it counts today's messages against the daily limit. **The settings screen.** `app/(app)/settings/page.tsx` loads everything and hands it to `SettingsView`, and nothing else imports `SettingsView`. A different settings screen replaces it in that one file. #### Customizing - **Product and assistant names**: `PRODUCT_NAME`, `ASSISTANT_NAME`, `GREETING` and `STARTERS` in `lib/config.ts`. Write starters as tasks your users really bring, with a detail line that says what they get. - **The mark**: `ProductMark` in `components/app/glyphs.tsx`, shown on the switcher's ink tile and on the sign-in page. - **Workspaces**: `listWorkspaces` in the store creates two for each person. Chats belong to one; the switcher sets a cookie and reloads the list. - **Sidebar items**: `ITEMS` in `components/app/app-sidebar.tsx`. Each needs an icon, since it stays in the folded rail. - **Page header**: `TopBar` in `components/app/top-bar.tsx` is one row of 32px controls. The title stays on one line and fades out at the end when it doesn't fit (a long chat name on a phone); the sidebar shows it whole. - **Rich answers**: replies render the thread's Markdown subset (paragraphs, lists, bold, inline code), and the system prompt asks for just that. For tables or code blocks, swap the renderer in the Chat app block's `assistant-message.tsx` and loosen the prompt. - **Attachments**: `PromptInput` supports them; turn `attachments` on in `chat-view.tsx` and send the files to the route as `multipart/form-data`, then on to the model as image or document blocks. #### States | State | What you see | Where | |---|---|---| | Signed out | Any app URL goes to `/sign-in` | `requireUser()` in the app layout | | First run | "No chats yet" in the sidebar; the greeting and starters | Sidebar `recent.empty`, `chat-view.tsx` | | Loading a chat | The chat's frame, "Loading conversation…" after 300ms | `chat/[id]/loading.tsx` | | Chat not found | "This chat isn't here" with New chat | `chat/[id]/not-found.tsx` | | Page failed | "This page didn't load" with Try again; the sidebar keeps working | `(app)/error.tsx` | | Streaming | Reasoning, then steps, then words fading in; Send becomes Stop; the model is fixed until it's done | `use-chat.ts` | | Stopped | The partial reply, "You stopped this reply.", Retry | Stop, or leaving the page | | Failed partway | The partial reply, the reason in red, Retry | `error` event, or a dropped connection | | Offline | A warning over the composer; drafting works, Send keeps the draft | `navigator.onLine` | | Daily limit | "You've reached today's limit. It resets at 1:00 AM." with See plans; the composer is disabled until then | `429` from the route, or on load | | Model rate limit or overload | The partial reply with the reason and Retry | `ProviderError` in `anthropic.ts` and `openai.ts` | | Deleted chat | Gone from the list, "Chat deleted" toast with Undo | Soft delete in the store | | Rename, pin failed | The rename field keeps the text and says why; a failed pin flips back with Retry | `actions.ts` results | #### Installing as a template A template is a registry item with `"type": "template"`. Its own files are listed in `meta.json` `files` by the path they install at, from the project root, or under `src/` when the project has one. `get_component` returns them headed by that path; the shadcn item (`/r/ai-chat-app.json`) is a `registry:block` whose files carry it as `target`. The components and blocks it uses come in the same answer at `components/ui//`, which is where its imports (`@/components/ui/sidebar/sidebar`) point. Its one npm package is `@anthropic-ai/sdk`, loaded only when Claude is the provider; the components need `@base-ui/react`. #### Accessibility - Every page has one `h1` in its header; the sidebar is a `nav` named for the workspace, the conversation a `section` named for the chat. - The thread is a `role="log"`: each reply is announced once, when it finishes. Reasoning, steps, notices and toasts have their own polite regions. - Focus: a deleted chat moves focus to the next one, a rename returns it to the row, and the search palette returns it to where it opened from. - Keyboard: ⌘K (Ctrl+K) searches chats, ⌘B folds the sidebar, Enter sends, Escape stops a reply. ### Agent task `agent-task` · · v0.2.6 · https://uipolish.com/docs/agent-task The live view of an agent doing a multi-step task: a phase whose color runs through the card, a checklist, a panel that shows what it's reading, searching, weighing, writing or revising, the tool in use, the model, and tokens, cost and time that never shift. Pause, stop, and run again. The live view of an agent working through a task of several steps. One color, the accent while it works, follows its state through the phases (reading, searching, thinking, writing or reviewing), and everything that means "now" takes it: the agent's tile, the phase's name, the bar under the title, the checks on the steps, and the tool in use. The tile shows the phase's own glyph (a file, a magnifier, an ellipsis, a pencil, an eye), so you can tell what the agent is doing at a glance. Under the steps, a panel of fixed height shows what the agent is looking at: the file it's reading, its search and the results, the notes it's weighing, the text it's writing with a caret, or a revision with what it struck out and what it put in. At the foot: its tools, the model, and tokens, cost and time that count up without moving anything. Pause, Stop, and Run again when it's over. ```tsx }, { id: "linear", name: "Linear", brand: "linear" }, { id: "npm", name: "npm", brand: "npm" }, ]} activeTool={run.tool} usage={{ tokens: run.tokens, cost: run.cost, startedAt: run.startedAt, endedAt: run.endedAt, pausedMs: run.pausedMs }} model={model} models={models} onModelChange={setModel} onPause={() => agent.pause(run.id)} onResume={() => agent.resume(run.id)} onStop={() => agent.stop(run.id)} onRunAgain={() => agent.start(run.task)} /> ``` It's controlled: your agent reports its phase, steps, what it's looking at and what it has spent, and the card draws it. Nothing in it fakes progress; the only timer is the clock. #### Anatomy - **Head.** A 40px tile with the phase's glyph on its tint; the agent's name and the phase in words; the task as the title (the hero, at the heading step); then a 4px bar with "2/5" at its end. - **Steps.** One 32px row each: a ring for to do, a small arc turning for the one in progress (the only loop in the card), a filled check for done, a red "!" for failed. The step in progress is in the text color; the rest step back. - **Context.** A panel of fixed height that shows one thing at a time. When the kind changes, the old one leaves, then the new one rises in; the two are never seen at once. - **Foot.** A 40px row of tools, the one in use lit, and the model picker; then a 48px row with tokens, cost and time on the left and the controls on the right. #### When to use it - An agent running a task of 3 to 8 steps while the person watches: fixing a bug, drafting a document, planning an event, triaging an inbox. - When the person needs to see what it's on, step in (pause, stop), and know what it cost. #### When not to - One tool call, or a list of calls with their raw input and output. That's `tool-steps`, which sits in a thread and folds away. - Research across many web pages. `research-progress` shows the pages. - The same action on many items: `batch-progress`. - Work that runs while the person is elsewhere: put it in a `background-run`, and show this on the run's own page. #### States | `status` | Head | Bar | Steps | Context | Controls | |---|---|---|---|---|---| | `running` | The phase's glyph and name, in the accent | Accent | Checks in the accent; the arc turns | `context` | Stop, Pause | | `paused` | A pause glyph, "Paused", grey | Holds | The arc holds where it was | Holds; the caret stops | Stop, Resume | | `done` | A check, "Complete", green | Full, green | All green | A `summary` context, if you pass one | Run again | | `failed` | "!", "Failed", red | Red, where it stopped | Done steps grey, the failed one red with "!" | "Step 5 of 5 failed: …" and `error` | Try again | | `stopped` | A stop square, "Stopped", grey | Grey | Done steps grey; put the running one back to `pending` | What it was on, faded | Run again | A control shows only when you pass its callback. Pause and Resume, then Run again, are one button that changes its name, so focus stays on it; Pause and Resume reserve each other's width, so trading them moves nothing. When Stop goes, focus that was on it moves to that button. **Phases.** Every phase is the accent: its name and glyph say which one, so the card adds no hues of its own. | `phase` | Default label | Glyph | |---|---|---| | `reading` | Reading file | `file` | | `searching` | Searching web | `search` | | `thinking` | Thinking | `more` (an ellipsis) | | `writing` | Writing | `pencil` | | `reviewing` | Reviewing | `eye` | Green and red stay for done and failed. Each phase is still its own variable on the root (`--at-reading`, `--at-reading-text` and so on), so a product can give one a color. Pass `phaseLabel` for your own words ("Reading the issue"); keep it to two or three. **Progress.** The bar defaults to the steps: each done or skipped step counts one and the running one a half. Pass `progress` (0 to 1) if you know better. #### Context `context` is one of six kinds. When the kind changes (or the file's path does), the panel trades layers; within one, the content updates in place and only what's new arrives. A search keeps its layer while the query is typed and the results come in. | `kind` | Fields | What it shows | |---|---|---| | `file` | `path`, `lines?`, `startLine?` | The path in mono, then the lines with their numbers. Without `lines`, placeholder lines with one band of light across them. | | `search` | `query`, `results`, `running?` | The query in a chip, a caret after it while `running`; results arrive one by one, each with its site's mark from `brand-marks`. | | `notes` | `notes` | What it's weighing, a few words each. The newest is in the phase's tint; the rest step back. | | `writing` | `path?`, `text`, `streaming?`, `code?` | The text so far with a caret. The panel follows the end while the reader hasn't scrolled up. `code` sets it in mono. | | `review` | `path?`, `note?`, `parts`, `code?` | Text where `{ removed }` is struck by a line drawn across it and `{ added }` arrives in the phase's color once the strike lands. | | `summary` | `title`, `detail?` | A check and one line: "Split bills add up to the cent". `detail` defaults to "5 steps · 10.5k tokens · 0:15". | The panel is 160px tall in every phase, so the card never changes height as the phases go by. Set `--at-context-height` on the card for more. Anything taller than the panel scrolls inside it, fading at the edge that has more, and the panel takes focus so a keyboard can scroll it too. #### Props | Prop | Type | Default | | |---|---|---|---| | `agent` | `string` | `"Agent"` | The agent's name in the head and in what screen readers hear. | | `task` | `string` | | The task, as its title. Names the card. | | `phase` | `AgentPhase` | | What it's doing now. Sets the color. | | `phaseLabel` | `string` | per phase | The phase in words, after the agent's name, while running. | | `status` | `AgentStatus` | `"running"` | | | `steps` | `AgentStep[]` | | `{ id, title, status: "pending" \| "running" \| "done" \| "failed" \| "skipped" }`. | | `context` | `AgentContext` | | See Context. Without one, "Getting started…". | | `error` | `string` | | `failed`: what happened, and what it means. | | `progress` | `number` | from steps | 0 to 1. | | `tools` | `AgentTool[]` | `[]` | `{ id, name, brand?, icon? }`. A tool without `brand` or `icon` shows its first letter. | | `activeTool` | `string` | | The tool in use. Nothing is lit once the run is over. | | `usage` | `AgentUsage` | | `{ tokens, cost, startedAt?, endedAt?, pausedMs? }`. | | `formatCost` | `(dollars) => string` | `"$0.032"` | | | `model`, `models`, `onModelChange` | | | The model picker shows when all three are passed. | | `onPause`, `onResume`, `onStop`, `onRunAgain` | `() => void` | | | | `className` | `string` | | | `formatTokens(n)` is exported too: 842 gives "842", 10480 gives "10.5k". #### Counters Tokens, cost and time are set in mono, and each sits in a box sized by the widest value of its shape ("88.8k", "$8.888", "88:88"), so a 1 turning into an 8 never nudges what's beside it. The "2/5" by the bar is in the text face, which has no tabular figures, so it reserves every digit in turn. The clock counts while `running`, from `startedAt`, less `pausedMs`; it holds while paused and stops at `endedAt`. When you resume, add the time spent paused to `pausedMs`. It starts after the card mounts, so the server and the browser render the same HTML. #### Motion | What | Duration | Easing | |---|---|---| | The phase color moves through the card (tile, name, bar, checks, tool) | `--ui-dur-base` | `--ui-ease-color` | | The tile's glyph: the old one's strokes retract, the new one's draw in | as `icons` | as `icons` | | The phase's name morphs into the next; nothing sits after it, so nothing moves | `--ui-dur-move` | `--ui-ease-move`, via text morph | | The bar grows to its new length | `--ui-dur-glide` (320ms) | `--ui-ease-glide`: lands without passing its end | | The arc on the step in progress turns (the one loop) | a third of `--ui-dur-sweep` per turn | `linear` | | A step's icon changes | `--ui-dur-fast` in, `--ui-dur-instant` out, scale on `--ui-ease-snap` | | | A check's tick draws on | `--ui-dur-base` | `--ui-ease-draw` | | The context trades layers in turn: the old fades out, then the new fades in and rises 4px | `--ui-dur-instant` out, then `--ui-dur-base` in | `--ui-ease-exit`, `--ui-ease-enter` | | A result or note arrives: fade and 3px rise | `--ui-dur-fast` | `--ui-ease-enter` | | A strike draws across what's removed, then what's added fades in | `--ui-dur-base`, then `--ui-dur-fast` | `--ui-ease-draw`, `--ui-ease-enter` | What's there when it mounts doesn't animate. Pause holds every loop where it is. Under `prefers-reduced-motion: reduce`, nothing loops or travels: the caret stops, the arc breathes slowly in place instead of turning, the bar changes at once, layers and items fade in without rising, and the strike is there from the start. #### Layout The card is one column as wide as its container, so on a 320px phone its panels narrow with it instead of widening it. #### Accessibility - The card is a `section` named by the task. The steps are an `
    `, each with its status in words ("Done: Read split-bill.ts"). - The bar is a `progressbar` with "3 of 5 steps done". - A polite `role="status"` region speaks milestones only: each phase ("Coding agent: Writing"), a pause, and how it ended, with the error. Never the counters or the streaming text. - The context panel is a region. Removed and added text are `` and `` with "Removed:" and "Added:" for screen readers. The tool in use is named in words ("npm, in use"). - Phase and status are always in words; color only repeats them. Their text reads the status text roles, so it holds 4.5:1. - Under 460px wide, every tool folds to its mark in a capsule of one width; names stay for screen readers, and nothing folds or opens as the tool in use changes. Under 400px, Stop and Pause fold to icon capsules named by `aria-label`; "Run again" keeps its words. Under 330px the tools and model, and the counters and controls, take a row each, and the controls get their names back. The card keeps one height in every state at every width. Nothing is cut: titles, paths, results and notes wrap. #### Before / after Before: a spinner and "Working…", then a wall of text. ```tsx {working ? : {result}} ``` After: the person sees the plan, what's being read, searched, weighed and written, which tool and model it's using, and what it costs, and can pause or stop it. ```tsx ({ id: s.id, title: s.title, status: s.state }))} context={contextOf(run.lastEvent)} // { kind: "file", path, lines } and so on error={run.error?.message} tools={tools} activeTool={run.lastEvent?.tool} usage={run.usage} onPause={() => agent.pause(run.id)} onResume={() => agent.resume(run.id)} onStop={() => agent.stop(run.id)} onRunAgain={() => agent.start(run.title)} /> ``` ### Approvals inbox `approvals-inbox` · · v0.4.1 · https://uipolish.com/docs/approvals-inbox A review queue for anything that needs a sign-off: expenses, time off, discounts, access, refunds, payments, posts and config changes, asked for by people or by agents. A list on the left, filtered by status and searchable, optionally grouped by requester or due date; the request in full on the right, drawn as the payment, message, post, diff, deletion, key/value change or code it is (or your own preview), with the requester's note and who it reaches. Approve, decline with a note, or edit then approve, from the keyboard too, with due dates, bulk approve and an undo window before anything is sent. A review queue for anything in your product that needs someone's sign-off: an expense report, a time-off request, a discount on a deal, access to a workspace, a refund, a vendor payment, a post before it goes out, a config change. Requests come from people on the team or from agents and integrations acting for them; the inbox treats them the same way. On the left is the list of requests ("Reimburse $1,284.50 in travel expenses", "Time off, Nov 24 to Nov 28", "Refund $240.00 to Northwind Traders"). You filter it by Pending, Approved, Declined or Expired and can search it. It isn't grouped by default; it can be grouped by requester or by due date. Every row has the same height: the requester's avatar, the title and the requester with the context on one line each, cut at the edge. On the right is the selected request in full: who asked and when, the requester's note, what's being approved, drawn the way a person checks it (see [Previews](#previews)), and who it reaches. At the bottom are **Decline** (with an optional note back), **Edit** (change the values, then approve) and **Approve**. Triage works from the keyboard: J and K move, A approves, D declines, E edits, / searches. A decided request fades and its row closes; the next one opens. Nothing is sent straight away: a toast offers **Undo** for six seconds, and only then is `onDecide` called. If that fails, the request comes back to Pending with the reason and **Approve again**. It's a block, built from Tabs, SearchField, Select, Avatar, Badge, Button, Kbd, Alert, EmptyState, Dialog, Checkbox, TextField, CodeBlock, DiffView, AnimatedSize and Toast. It takes data and promise-returning callbacks and never fetches anything itself. It shows its toasts through `toast()`, so render one `` somewhere in your app. ```tsx import { ApprovalsInbox } from "@/components/ui/approvals-inbox/approvals-inbox"; import { Toaster } from "@/components/ui/toast/toast"; { const updated = await api.approvals.decide(id, { decision, note, edits }); // updated.status is "approved" or "declined", with updated.outcome setRequests((list) => list.map((r) => (r.id === id ? updated : r))); }} onRequeue={async (id) => { const reopened = await api.approvals.reopen(id); setRequests((list) => list.map((r) => (r.id === id ? reopened : r))); }} /> ``` Each request describes itself: ```tsx const request: ApprovalRequest = { id: "apr_2291", requester: { id: "u_maya", name: "Maya Lindqvist", detail: "Sales" }, title: "Reimburse $1,284.50 in travel expenses", context: "Expense report EXP-2291 · Northwind renewal", status: "pending", requestedAt: "2026-11-03T14:06:00Z", expiresAt: "2026-11-07T17:00:00Z", note: "Three days onsite with Northwind for the renewal. Receipts are on the report.", preview: { kind: "details", rows: [ { label: "Flights", value: "$642.00" }, { label: "Hotel, 3 nights", value: "$486.00" }, { label: "Total", value: "$1,284.50" }, ], }, fields: [{ key: "amount", label: "Amount to reimburse", value: "1284.50", inputMode: "decimal" }], }; ``` A request an agent drafted is the same shape, with `kind: "agent"` on the requester (a small **Agent** badge shows beside its name) and, if you want it checkable, the raw call in `tool`: ```tsx { id: "apr_8812", requester: { id: "support-agent", name: "Support agent", detail: "Drafts refunds and replies", kind: "agent" }, title: "Refund $240.00 to Northwind Traders", priority: "medium", note: "The card was charged twice for October. This refunds the second charge.", preview: { kind: "payment", direction: "refund", amount: 240, currency: "USD", counterparty: "Northwind Traders" }, tool: { server: "payments", name: "refunds.create", arguments: { charge: "ch_3QpL2e", amount: 24000 } }, // …status, requestedAt } ``` #### Previews `preview` shows what's being approved the way a person would check it. Pick the kind that matches; leave it out and the request shows its title, note and recipients only. Its section takes a heading from the kind: Message, Payment, Post, Changes, What gets deleted, Details, What runs. | Kind | Draws | For | |---|---|---| | `details` | `{ rows: { label, value, from?, mono? }[] }`, one labelled line each; a row with `from` reads "before → after" in the diff's colors | Expenses, time off, discounts, access and roles, bookings, settings: most requests | | `payment` | `{ direction, amount, currency, counterparty, method?, memo? }`, the amount large | Refunds, vendor payments, reimbursements, charges | | `email` | `{ from, subject, body }` as a message, with the count of `recipients` | Replies, announcements, notices to customers | | `post` | `{ channel, mark?, account, text, limit? }` with a character count | Social posts, community and chat messages | | `diff` | `{ name, before, after }` in DiffView | Config files, documents, templates, policies | | `delete` | `{ what, items }` in the danger tone: "Deletes … for good" and what goes with it | Closing accounts, removing records or files | | `code` | `{ code, language?, label? }` in CodeBlock, with Copy | Queries, commands, payloads | | `custom` | `{ render }`, your own node, as is | Anything else: draw it with the kit's tokens and an edge in `--ui-border-strong` | Write `details` rows as a person would read them: "Balance: 16 days → 11 days", "Role: Member → Admin", "Ends: in 30 days". Use `mono` for IDs, hostnames and paths only. #### When to use it - A product where some actions wait for a sign-off: spend, time off, discounts, access, publishing, refunds, changes to production. More than one request at a time, from more than one person or agent. - A team view: several people share the queue, and some can only watch. - Requests with due dates, after which they can't be approved. #### When not to - One request inside a chat, answered in the flow of the conversation: use `tool-approval`. - Approving a whole multi-step plan before it starts: use `plan-approval`. - Granting an app access to an account: use `authorize`. - A log of what already happened: that's a table, not an inbox. #### States | State | What happens | |---|---| | Loading | After 300ms, one shimmering line ("Loading requests…") in the panes' full height, so nothing moves when they land | | Couldn't load | The panes' space holds what broke, the `error` text and **Try again** | | All caught up | No pending requests: a tray, "All caught up", and what will show up here | | Nothing in a tab | "Nothing declined yet" with what fills it | | Search, no match | "No pending requests match “foo”" with **Clear search** | | Pending | Rows show when they were asked for ("12 min ago"), or a quiet due label when there's an `expiresAt` ("Due 4:00 PM", "Due tomorrow", "Due Fri"). Only in the last hour does the row's label count down, every digit in a box one `ch` wide so it never jiggles; it turns amber under ten minutes. The open request shows "Due in 3h 05m" beside its priority | | Expired while reading | The open request stays where it is instead of vanishing. A warning says when it was due and that nothing happened; the buttons become **Reopen** (`onRequeue`) | | Deciding | Approve or Decline: the row fades, then its space closes, and the next request opens. A toast names what you decided with **Undo** for `undoWindow` ms ("Maya Lindqvist hears back in 6 seconds"). Undo puts it back, selected | | Decision failed | After the window, `onDecide` rejects: the request returns to Pending, its row says "Didn’t go through", the request shows your error message with **Approve again** (sent at once, busy on the button), and an error toast offers **Show** | | Declining | A note field opens above the buttons, focused ("Tell Maya why"). Enter declines, Shift+Enter adds a line, Escape closes it | | Editing | The preview becomes the request's `fields`. Changed values count up ("2 changes"); **Approve with edits** validates first and sends only the changed keys | | Bulk approve | **Bulk approve** (two or more pending) opens a dialog listing them, none ticked, with **Select all**. **Approve 3 selected** approves those together, with one Undo | | Technical details | With a `tool`, a quiet **Technical details** disclosure at the end of the request opens `server · name` and its arguments as JSON | | Viewer | `canDecide={false}`: an info banner under the toolbar, no buttons, and `permissionNote` where they would be. A/D/E announce the note instead | | Approved / declined | Read-only, with who decided, when, any edits and the note they gave | | Long recipient lists | Five shown; **Show all 212** opens the rest in a 320px scrolling box with a filter field (over 20 people) | | Narrow (under 640px) | The list and the request take turns: a row opens the request with **All requests** to go back, the decision bar sticks to the bottom of the screen, and a decision returns to the list so you see the row leave | #### Props | Prop | What it does | |---|---| | `requests` | Every request, any status. Pending sorts by soonest due date, then priority, then newest; the rest by most recent decision | | `onDecide` | `({ id, decision, note?, edits? }) => Promise`. Called once the undo window has passed. Resolve after recording it and update the request's `status` and `outcome`; reject with an `Error` whose message says what happened | | `onRequeue` | `(id) => void \| Promise`. Shows **Reopen** on expired requests | | `canDecide` | Default `true`. False shows everything read-only | | `permissionNote` | Who can approve, shown when `canDecide` is false | | `loading`, `error`, `onRetry` | First load and its failure | | `undoWindow` | Milliseconds before a decision is sent. Default 6000; `0` sends at once, busy on the button | | `defaultFilter` | `pending` (default), `approved`, `declined` or `expired` | | `defaultGrouping` | `none` (default), `requester` or `due` (due today, tomorrow, later, no due date). The old `agent` and `urgency` still work and mean `requester` and `due` | | `bulk` | Default `true`: **Bulk approve** once two or more are pending. `false` hides it | | `bulkRisk` | Deprecated. `false` is `bulk={false}`; `"low"` offers only low-priority requests, ticked to start with | | `title`, `description` | The heading and the line under it. Default "Approvals" and "Requests waiting on a decision from you or your team." | `ApprovalRequest`: | Field | What it is | |---|---| | `requester` | `{ id, name, detail?, avatarUrl?, kind? }`. `detail` is their team or role ("Sales", "Support agent"); `kind: "agent"` adds an **Agent** badge. Default kind `person` | | `title` | What's being approved, short: "Admin access to the Billing workspace" | | `context?` | Where it belongs: "Access request · for 30 days" | | `priority?` | `low`, `medium` or `high`. High shows an amber **High** in the row; every level shows in the request. Leave it out and no badge shows | | `status`, `requestedAt`, `expiresAt?`, `outcome?` | Where it stands. `expiresAt` is the due date: after it the request can't be approved and moves to Expired. `outcome` is `{ by, at, note?, edited? }` | | `note?` | The requester's message, in their words: shown as "Note from Maya Lindqvist" | | `preview?` | `details`, `payment`, `email`, `post`, `diff`, `delete`, `code` or `custom`: see [Previews](#previews) | | `recipients?` | `{ name, detail? }[]`, any length: who it reaches | | `fields?` | `{ key, label, value, multiline?, hint?, inputMode?, validate? }[]`: what Edit lets a person change, for any preview kind | | `tool?` | `{ server, name, arguments? }`: the raw call behind it, folded under **Technical details**. Leave it out when there isn't one | ##### Older names Earlier versions were built for agents only. These still work, so existing data needs no change: | Old | Now | How it's read | |---|---|---| | `agent: { id, name, role?, avatarUrl? }` | `requester` | Used when `requester` is missing, as `{ ..., detail: role, kind: "agent" }` | | `reasoning` | `note` | Used when `note` is missing | | `risk` | `priority` | Used when `priority` is missing | | `ApprovalAgent`, `ApprovalRisk` types | `ApprovalRequester`, `ApprovalPriority` | Still exported | #### Customizing - **Height.** The panes are 680px tall on a wide screen; set `--inbox-height` on the block (for example `calc(100dvh - 200px)`) to fill your layout. `--inbox-list` sets the list's width (320px). - **Priority.** Set it on the server, from what matters to your team: a deal closing today is high, a routine reimbursement can have none. Use it sparingly; when everything is high, nothing is. - **Due dates.** Give `expiresAt` to what can't wait forever (a discount before the quarter closes, a refund before payouts). Leave it out for requests that can wait. - **Undo.** Six seconds suits most teams. The block sends what's still held if the page unmounts, so a decision is never lost by navigating away. - **Search.** It matches the title, the requester's name and detail, the context, the note, the preview's main text (subject, counterparty, account, file name, row values) and the recipients' names. - **Edges.** One resting surface holds both panes, with a hairline between the list and the request and between rows. Previews and fields take the same edge, `--ui-border-strong`. Keep to it if you restyle. - **Copy.** `permissionNote` should name who can approve. Error messages from `onDecide` should say whether anything changed. #### Layout The panes are padded `--ui-pad-card` (16 narrow, 24 from 560px) and sit `--ui-gap-group` under the toolbar. The list is its own container too. Narrower than 400px (a phone, or a tight pane beside the request), each row's time and priority move from the end of the title's line to the end of the second line, so the title gets the row's whole width. Grouped by requester, rows leave out the name the heading already gives. #### Accessibility - The list is a list of buttons; the open one has `aria-current`. Only it is in the tab order, so Tab moves from the list to the request. Arrow keys and J/K move between rows; group names are headings. - A, D and E act on the open request, and the buttons carry `aria-keyshortcuts`. Shortcuts never fire while typing, in a menu or in a dialog. The hints are hidden on touch screens and narrow layouts. - Each decision, undo and failure is announced in a polite live region. A countdown's ticking digits are hidden from screen readers, which hear "Due at 2:50 PM" instead. - After a decision, focus moves to the next row, so triage can continue from the keyboard. On a phone, opening a request focuses its title, and **All requests** (or Escape) returns focus to the row. - The decline note and edit fields have visible labels; a field that fails validation has `aria-invalid` and its message linked. **Technical details** is a button with `aria-expanded`. #### Before / after Before: a table of pending requests with two buttons per row. A misclick approves the wrong expense, nobody can see what the discount does to the contract value, a request past its date is still approvable, and a failed approval looks like success. ```tsx {pending.map((a) => ( {a.description} ))} ``` After: the request shows exactly what's being approved and why, can be edited before approving, waits six seconds for Undo, comes back with the reason if it fails, and stops being approvable once it's past due. ```tsx ``` ### Billing `billing` · · v0.2.3 · https://uipolish.com/docs/billing The billing settings page of a SaaS product: current plan, usage meters that warn at 80% and say what to do at 100%, plan changes with proration and what a downgrade loses, payment method, billing details, invoices and cancel. Trial, past-due and canceled states included. The billing settings page of a SaaS product. It shows the current plan and the card it's charged to, usage meters for metered limits, the plans on offer with a monthly/yearly switch, billing details, invoices and a way to cancel. Every action waits on your own async callback, shows busy while it runs, and says what went wrong if it fails. Each subscription state changes the whole screen, not just a badge. A trial shows the days left. Past due shows a red banner with the grace period and **Update payment method**. A plan that's canceled shows when access ends and offers **Keep subscription**; once it has ended, **Resubscribe**. If the subscription needs something done, the banner at the top says what. ```tsx api.post("/billing/plan", { planId, interval }).then(refresh)} onUpdatePayment={() => stripe.openCardForm().then(refresh)} onSaveDetails={(next) => api.put("/billing/details", next).then(refresh)} onCancel={(reason, note) => api.post("/billing/cancel", { reason, note }).then(refresh)} onResume={() => api.post("/billing/resume").then(refresh)} /> ``` #### When to use it - The billing page of a product that charges per seat or a flat price per month or year, with optional metered limits (AI credits, messages, storage). - As the page itself, or with sections cut away: leave out `usage`, `invoices`, `details` or `paymentMethod` and that part goes. #### When not to - Checkout for a first purchase. That's a focused flow with one plan and a card form, not a settings page. - Usage analytics: charts over time, cost per project. Link to them from here; the meters only answer "how close am I to the limit". - The card form itself. `onUpdatePayment` should open your processor's form or portal (Stripe Elements, a hosted page) rather than collect card numbers here. #### States | State | What shows | |---|---| | Active | Plan card with the renewal date, Billed and Seats; card with "Charged on …" | | Trial (`trialing`) | "9 days left in your Acme Team trial" (info). With no card, it says to add one before the end date and offers **Add payment method**, and turns to a warning from 3 days out. Plan changes start now and nothing is charged until the trial ends | | Past due (`past_due`) | Red banner: "Your $120 payment didn't go through", the processor's reason, the next retry and "Update your card by November 10 to keep Acme Team." Plan buttons are off with "Plans can change once the open invoice is paid." | | Canceled, access left | Warning banner "Acme Team ends on November 14", **Keep subscription** (`onResume`). Plan buttons are off until it's kept | | Canceled, ended | Info banner "Acme Team ended on October 14", **Resubscribe to Acme Team**; the plans section becomes "Choose a plan" | | Downgrade scheduled | A note in the plan card: "Switches to Acme Starter on November 14, 2026." with **Keep Acme Team** | | Card expiring | "Expires soon" badge within the card's last two months, and "Update it before …, when the next charge runs." Expired: "Expired" badge and danger text | | Card failed | `paymentMethod.failure` in danger text and a "Payment failed" badge; the update button turns primary | | No card | "No card on file" and **Add payment method** | | Usage under 80% | A neutral bar: "2,580 left. Resets Nov 14." | | Usage 80% and over | Amber bar and line: "88 texts left until Nov 14. At the limit, reminders go by email only." | | Usage at 100% | Red bar: "**All 10,000 credits used.** Copper stops drafting menus and replies until then. Resets Nov 14." and **Compare plans**, which scrolls to the plans and moves focus there | | Unlimited (`limit: null`) | No bar: "Unlimited on your plan." | | No invoices | "No invoices yet. Your first one arrives on November 12, 2026, when the trial ends." | | Many invoices | The newest 6, then "Show 9 older invoices" | | Read only (`readOnly`) | An info banner with your reason ("Ask Mara Okafor"); every action is hidden except invoice downloads | | Loading | The title, and "Loading billing…" shimmering in a card the height of the plan cards | | Didn't load (`error`) | An empty state with the error and **Try again** (`onRetry`) | | An action is pending | Its button keeps its width and shimmers its busy label ("Changing plan…", "Opening…", "Saving…", "Canceling…") after 300ms | | An action failed | The rejection's message, next to where it was pressed: in the dialog footer, under the form, in the banner or card | #### Changing plans `plans` is in order, cheapest first, and the order decides what's an upgrade. The monthly/yearly switch shows each plan's price for that period as a monthly figure ("$15 per seat / month") with the total for the subscription's seats under it. "Save 17%" is the largest yearly saving across the plans, worked out from `price.month * 12` against `price.year`. | Choice | Dialog | Takes effect | |---|---|---| | A higher plan, or yearly on the same plan | "Upgrade to Acme Business", confirmed with **Upgrade now**: due today, renewal date, the card, and what it adds | Now, prorated | | A lower plan, or monthly on the same plan | "Switch to Acme Starter on Nov 14?": what the team loses, in a warning box | End of the period | | Any plan during a trial | "Switch to …": the trial end date and the new price | Now, nothing charged | | Any plan after the subscription ended | "Subscribe to …": full price today, renewal date | Now | **Due today** is an estimate. For the same interval it's the difference in price times the days left over the days in the period. For a switch to yearly it's a full year less the unused part of this period. The dialog says the invoice has the exact amount. If your processor can preview proration, its number is better: put it in the plan's `description` or fork `DialogBody` in `plans.tsx`. What's lost is every line of the current plan's `features` that the new plan doesn't have, word for word. Write features so that a lower tier's line differs ("2,000 AI credits a month" against "10,000 AI credits a month"), and the downgrade lists exactly what goes. #### Props | Prop | What it does | |---|---| | `subscription` | `{ planId, interval, status, seats, seatsUsed?, periodStart, periodEnd, trialEnd?, cancelAt?, amountDue?, nextRetry?, graceUntil?, scheduledChange? }`. Dates are ISO (`"2026-11-14"`). `status` is `"trialing"`, `"active"`, `"past_due"` or `"canceled"` | | `plans` | `{ id, name, description?, price: { month, year }, perSeat?, features }[]`, cheapest first. `price.year` is a whole year. `perSeat` defaults to true | | `usage` | `{ id, label, used, limit, unit?, resetsAt?, atLimit? }[]`. `limit: null` is unlimited. `atLimit` is a sentence on what stops | | `paymentMethod` | `{ brand, last4, expMonth, expYear, failure? }`, or `null` for no card. Leave out to drop the card | | `invoices` | `{ id, number, date, amount, status, href? }[]`, newest first. `status` is `"paid"`, `"open"`, `"failed"` or `"refunded"`. No `href` shows "Preparing…" | | `details` | `{ name, email, taxId?, address: { line1, line2?, city, region?, postalCode, country } }` | | `onChangePlan(planId, interval)` | From the plan dialog, and from **Keep** on a scheduled switch (with the current plan and interval: undo the switch) | | `onUpdatePayment()` | Open the card form or portal. Resolve once `paymentMethod` is the new card | | `onSaveDetails(details)` | The whole edited value, trimmed, empty optionals as `undefined` | | `onCancel(reason, note?)` | `reason` is `"price"`, `"unused"`, `"features"`, `"switching"`, `"seasonal"` or `"other"` | | `onResume()` | Undo a cancel, or resubscribe to the same plan after it ended | | `loading`, `error`, `onRetry` | Whole-screen loading and failure | | `readOnly` | A sentence on why and who can change it. Hides every action | | `cancelNotes` | Lines for the cancel dialog's "On Nov 14" box, after "All 8 seats lose access" | | `currency`, `locale` | Default `"USD"`, `"en-US"`. Money goes through `Intl.NumberFormat`; whole amounts drop the cents | | `today` | ISO date for days left, expiry and proration. Default: now. Pass it from the server to keep server and browser in step | | `title`, `description`, `headingLevel` | Default "Billing", none, 1. Sections are one level down, plan names two | Every callback can return a promise. The block shows busy until it settles and shows the rejection's message if it fails. It never changes data itself: resolve after your state reflects the change, and the screen follows from the props. #### Customizing - **Plan names and features** come from your pricing page. Keep features short and parallel across tiers; they double as the downgrade's loss list. - **`atLimit`** on each meter is your product's real consequence ("Copper stops drafting menus and replies until then."), not "Limit exceeded". - **`cancelNotes`**: say what stops and what's kept, and for how long. - **The cancel reasons** are in `REASONS` in `billing.tsx`. Keep them to six or fewer, in your customers' words. - **The grace period and retry** wording comes from `graceUntil` and `nextRetry`; leave them out if your processor doesn't retry. - **Spacing** reads the shared roles: `--ui-gap-section` between sections (48 narrow, 56 from 600px), `--ui-gap-block` between cards, `--ui-gap-group` under a section's heading, and `--ui-pad-card` inside every card. - **Widths**: the cards sit side by side once the block is 720px wide (so the payment card fits its button), and invoices fold into two-line rows at 520px. Both are container queries, so the block lays out right in a narrow settings column. - **Edges**: every card's outline is the field edge color (`--ui-edge`, in `--ui-shadow-resting`), so the cards and the buttons in them read as one set; lines inside a card are the lighter `--ui-border`. The plan you're on takes the selected edge (`--ui-field-focus`), still one hairline. #### Accessibility - One heading at `headingLevel`, each section a `
    ` named by its heading, plan names one level further down. - Usage bars are `role="meter"` with "7,420 of 10,000 credits used" as their value text, named by the label and described by the line under them. - The billing period switch is a radio group named "Billing period". The plans are a list named by the section heading, each item named by its plan. - The invoices are a real `` with column headers; the download column's header is visually hidden. Each link reads "Download invoice LRD-2026-0140, October 14, 2026, PDF". On a phone, the header row is visually hidden but stays readable. - A polite `role="status"` region announces each change once it lands: "Acme Business, billed monthly, starts now.", "Billing details saved.", "Subscription canceled. You keep Acme Team until November 14, 2026." - Banners use the Alert component and are there from the start, so they're read in page order, not interrupting. A change that makes one appear, like canceling, is announced in the status region instead. - The plan dialog takes focus on its first button. The cancel dialog is an alert dialog with focus on **Keep subscription**, so the safe choice is the default. After a cancel, the button that opened it is gone, and focus goes to the page title. Typing a note and then pressing Escape asks before throwing it away. - Editing details moves focus to the first field. Escape or Cancel brings it back to **Edit details**. Validation runs on blur and on save, focuses the first wrong field, and says how to fix it. - **Compare plans** scrolls to the plans (no smooth scroll under reduced motion) and moves focus to their heading. - Buttons are the standard 40px; download links reach 40px under a finger. #### Before / after Before: a status string, a price, and a button that goes to the processor's portal for everything else. Nobody knows the credits are about to run out until Copper stops. ```tsx

    Billing

    Plan: team ({sub.status})

    ${sub.amount / 100}/mo

    Manage billing
    ``` After: the plan, its price and renewal in words; a meter that warns at 80% and says what to do at 100%; plan changes that state the charge today and what a downgrade loses; and a banner whenever something needs doing. ```tsx ``` ### Knowledge base `knowledge-base` · · v0.2.2 · https://uipolish.com/docs/knowledge-base The screen where a team gives its AI agent documents to answer from: upload files, add a site or sitemap, or connect Google Drive, Notion or Dropbox; watch each source move through reading, chunking and indexing with counts that never shift; retry failures, re-sync, remove, see usage against the plan, and test which passages a question retrieves. The screen where a team gives its AI agent something to answer from: the documents, sites and app folders behind retrieval (RAG). There are three ways in: upload files by drop or browse, add a page or sitemap, or connect Google Drive, Notion or Dropbox. Each source then moves through **reading → chunking → indexing → ready**, or **failed** with a reason and Retry. A list of 40 files shows on one batch sheet, and a test panel shows which passages a question retrieves. Over six sources, the filters (a segmented control) and the search share a row at the field's height, 40px (`--ui-control-md`): controls in one row share one height. A source in progress is the accent, ready is green and failed red, and that color runs through the row: the pill, the turning glyph, and a line along the row's foot that travels as the source moves forward. Pages, chunks and tokens tick up in boxes that hold their width, so nothing beside them moves. ```tsx api.upload(files).then(refresh)} onReplaceFile={(id, file) => api.replace(id, file).then(refresh)} onAddWebsite={(url, { crawl }) => api.post("/sources/web", { url, crawl }).then(refresh)} onConnect={(service) => oauth.open(service).then(picker.choose).then(refresh)} onResync={(id) => api.post(`/sources/${id}/sync`).then(refresh)} onRetry={(ids) => api.post("/sources/retry", { ids }).then(refresh)} onRemove={(id) => api.delete(`/sources/${id}`).then(refresh)} onTest={(question) => api.post("/retrieve", { question, k: 4 })} testSuggestions={["Is the tasting menu nut-free?", "Where can guests park?"]} onUpgrade={() => router.push("/settings/billing")} /> ``` #### When to use it - An agent or assistant that answers only from material the customer gives it, and the people who set it up need to see what it knows. - Ingest that takes seconds to minutes per source, where people watch it happen and need to know what failed and why. - A plan limit on pages, chunks or tokens. #### When not to - Files attached to one message or task. That's `prompt-input` with attachments, or `file-upload` in a form. - Managing the agent's tools and servers: `connections`. - Search analytics, evaluation runs or chunking settings. Link to them; this screen answers "what does it know, and does it find the right thing". #### States | State | What shows | |---|---| | First run (`sources` is `[]`) | "Give Copper something to answer from", one line, and the three ways in as cards: a drop zone, a website form, and the apps. No list, no test panel | | Ready | Sources with their counts and "Synced 2 hours ago", the plan meter, the test panel. Over 6 sources: filters (All, In progress, Failed) and a search | | A source `queued` | Grey pill "Queued", "Waiting its turn" | | `reading` / `chunking` / `indexing` | The pill in the accent, naming the stage, with a turning arc; the line along the row's foot travels through the three stages (`progress` moves it within one); counts tick up in held boxes | | `ready` | Green pill with a check that lands with a small snap; the line fills and fades | | `failed` | Red pill, and under the name, the `error` in a tinted box with **Retry** | | Many files at once (`batch`) | `BatchProgress` above the list: "Reading 40 files", 14 of 40, time left, current file, Stop (`onStopBatch`) | | Some failed (2 of 40) | The sheet reads "Added 38 of 40 files", lists the two with their reasons and **Retry 2**; the rows say the same, and the Failed filter shows 2 | | Unsupported type | Warning above the list: "Seating plan.pages wasn't added. .pages files can't be read. Save it as PDF, DOCX, Markdown, or CSV and add it again." | | Duplicate file | Same notice: "Already here, synced 2 hours ago. Replace it if this copy is newer." with **Replace** (`onReplaceFile`) | | Too large / empty | "Too large at 64 MB. Up to 50 MB: split it, or export just the pages that matter." / "This file is empty." | | Near the limit (80%) | Amber meter, "62 pages left. A long PDF may not fit." | | Over the limit | A warning banner "Copper's knowledge base is full" with **Upgrade plan**; the meter is red; the add panel says what to remove; drops are refused. Sources already here keep working | | Viewer (`readOnly`) | Info banner with your reason; no Add, Retry, Re-sync or Remove. Testing still works | | Upload pending / failed | The drop zone shimmers "Uploading 3 files…" after 300ms; a failure shows an error notice with **Try again** for the same files | | Test: idle / searching / results / nothing / failed | A line on what it shows / "Searching 10 sources…" in the reserved space / the answer and the passages / "Nothing matched … Copper would say it doesn't know" / the error with **Try again** | | Test, nothing ready yet | "Testing opens once a source is ready." | | Loading / didn't load | "Loading sources…" in a card the list's height / an empty state with **Try again** (`onReload`) | | Long names | Wrap; nothing is cut. Passages show in full | #### Props | Prop | What it does | |---|---| | `sources` | `{ id, name, kind, location?, href?, status, progress?, error?, pages?, chunks?, tokens?, syncedAt? }[]`. `kind` is `"file"`, `"website"`, `"googledrive"`, `"notion"` or `"dropbox"`. `status` is `"queued"`, `"reading"`, `"chunking"`, `"indexing"`, `"ready"` or `"failed"` | | `usage` | `{ used, limit, unit? }`. Warns at 80%, blocks adding at 100% | | `batch` | `{ sourceIds, startedAt?, finishedAt? }`: files added together, for the sheet. Clear it when you like; keep it while some failed | | `services`, `connected` | Apps offered (default all three) and the ones already connected, with the account to show | | `accept`, `acceptLabel`, `maxFileSize` | Default `".pdf,.docx,.md,.csv"`, "PDF, DOCX, Markdown or CSV", 50 MB | | `onAddFiles(files)` | Only files that passed type, size and duplicate checks. Resolve once they're in `sources` | | `onReplaceFile(sourceId, file)` | From a duplicate's **Replace**. Leave out and duplicates are only skipped | | `onAddWebsite(url, { crawl })` | `url` normalized to https. A `sitemap*.xml` always crawls | | `onConnect(service)` | Open sign-in and the folder picker. Also "Add a folder" once connected | | `onResync(id)`, `onRetry(ids)`, `onRemove(id)` | Row actions. Remove asks first | | `onStopBatch()` | Shows Stop on the sheet | | `onTest(question)` | Resolve `{ answer?, chunks: [{ id, sourceId, location?, text, highlights?, score }] }`, best first. `highlights` are `[start, end)` ranges in `text` | | `testSuggestions` | Questions to try, one tap each | | `onUpgrade()` | Shows **Upgrade plan** when full | | `readOnly` | A sentence: why, and who can change it | | `loading`, `error`, `onReload` | Whole-screen loading and failure | | `agentName` | For sentences. Default "Your agent" | | `now`, `locale` | For "Synced 2 hours ago"; pass `now` from the server so both render alike | | `pageSize` | Rows before "Show n more". Default 12 | | `title`, `description`, `headingLevel` | Default "Knowledge base", none, 1 | Every callback can return a promise: its control shows busy until it settles, and a rejection's message shows where it was pressed. The block never changes data itself; resolve once `sources` reflects the change. #### Customizing - **Stages.** Use the four your pipeline has, mapped onto `reading`, `chunking` and `indexing`; send `progress` within a stage if you know it, so the line moves smoothly. The stage colors are `--kb-reading`, `--kb-chunking` and `--kb-indexing` on `.root`, all the accent by default. - **Errors.** Write `error` as what happened and what fixes it: "Password protected. Upload a copy without the password.", not "Parse error". - **The limit.** Set `usage.unit` to what you bill: pages, chunks or tokens. - **File types.** Change `accept` and `acceptLabel` together; the rejection says to save as `acceptLabel`. - **Test results.** `answer` is optional. Showing only the passages is often more honest while people tune their sources. - **Widths.** The test panel sits beside the list once the block is 900px wide; website and apps sit side by side at 620px. Both are container queries, so the block lays out right in a narrow settings column. - **Spacing.** The title sits `--ui-gap-block` above the content, and the sources and the test panel sit `--ui-gap-block` apart (16 narrow, 32 from 760px). #### Layout On a phone (the screen under 420px) a source's status pill wraps under its name and lines up with it, and the filter's labels stay on one line, scrolling inside the track if the screen is narrower still. #### Accessibility - One heading at `headingLevel`; Sources and Test it one below, each add card one below that. - **Add sources** is a disclosure button (`aria-expanded`) for the panel; the plus turns into a close as it opens. The closed panel is `inert`. - The drop zone is a real button that opens the file picker; its prompt and constraints are its name and description. Dropping is never the only way. - Each row's status is text ("Indexing"), not color alone; failed rows describe the pill with their error. Row menus are named "Actions for Autumn tasting menu.pdf". - Remove is an alert dialog naming the source and what stops, with focus on Cancel; afterwards focus goes to the Sources heading. - A polite status region announces what lands: "3 files added. Copper starts reading them now.", "2 files weren't added…", "4 passages from 3 sources. Best match first." The batch sheet announces its own milestones, not every file. - The usage bar is `role="meter"` with "370 of 500 pages used". - Passages are an ordered list; matched words are ``. - Reduced motion: the arc stops turning and breathes, the line jumps to its place, and rows fade instead of opening. - Buttons are the standard 40px; only a source's “…” menu is small, and it reaches 40px under a finger. #### Before / after Before: an upload input, a table of file names with a "processing" string, and nothing when a file fails. People find out from a wrong answer. ```tsx
    {docs.map((d) => )}
    {d.name}{d.status}
    ``` After: three ways in, each source's stage in words and color, failures that say what to do with Retry, a plan meter, and a test panel that shows what a question retrieves. ```tsx ``` ### Onboarding `onboarding` · · v0.2.2 · https://uipolish.com/docs/onboarding The first-run setup for an AI product, in four steps: the workspace, the tools the agent may use (each with its own consent and a connect that can fail or stall), what the agent may do without asking, and a first task it streams in front of you. The panel's height glides between steps, steps slide the way you're going, and every answer is handed back to save and resume. The first run of an AI product, in four short steps: name the workspace and say what it's for, connect the tools the agent may use, set what the agent may do without asking, then watch it do its first real piece of work. A done screen sums up what was set, and "Skip setup" is there from the first screen. The block holds the flow and every answer. Your server holds the decisions: connecting a tool, saving a step and the agent's first task are callbacks that return promises, and each has a designed failure. Every change is handed to `onChange`, so a reload resumes where the person left off. ```tsx } agentMark={} useCases={[ { value: "supper-club", label: "Supper club", description: "Regular dinners for a guest list" }, { value: "pop-up", label: "Pop-ups", description: "One-off nights at a borrowed venue" }, ]} tools={[ { id: "calendar", name: "Google Calendar", brand: "googlecalendar", provider: "Google", description: "Checks your dates and holds the night", scopes: [ { title: "See your events and free time", access: "read" }, { title: "Add and move supper events", access: "write" }, ], }, ]} tones={[{ value: "warm", label: "Warm", sample: "Good question! There’s free parking on Alder Lane after 6." }]} permissions={[ { id: "reminders", title: "Send reminders to confirmed guests", description: "The day before, with the address.", defaultOn: true }, { id: "holds", title: "Hold dates on your calendar", description: "Adds a tentative event.", requires: "calendar" }, ]} firstTask={{ title: "Invite for Saturday supper", detail: "Sat, Nov 15 · 7:30 pm · 12 seats" }} initialStep={saved?.step} initialValues={saved?.values} onChange={(values, step) => saveDraft({ values, step })} onSaveStep={async (step, values) => { const res = await fetch(`/api/onboarding/${step}`, { method: "PUT", body: JSON.stringify(values) }); if (res.status === 409) return "That workspace name is taken. Try another."; if (!res.ok) return "Couldn’t save. Check your connection and press Continue again."; }} onConnect={async (toolId) => { const result = await openOAuthPopup(`/api/connect/${toolId}`); // resolves when the popup closes if (result.closed) return { ok: false, reason: "cancelled" }; return result.ok ? { ok: true, account: result.email } : { ok: false, message: result.error }; }} onDisconnect={(toolId) => fetch(`/api/connect/${toolId}`, { method: "DELETE" })} onFirstTask={async ({ values, write, signal }) => { const res = await fetch("/api/agent/first-invite", { method: "POST", body: JSON.stringify(values), signal }); if (!res.ok) throw new Error("Copper couldn’t reach your workspace."); const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader(); for (let r = await reader.read(); !r.done; r = await reader.read()) write(r.value); }} onComplete={(values, how) => router.replace(how === "skipped" ? "/" : "/drafts")} fullPage /> ``` #### When to use it - The first screen after sign-up in a product with an agent: the person sets it up once, and should see it do something useful before they leave. - Resuming a setup someone left halfway, from what `onChange` saved. #### When not to - Settings people come back to. The agent's defaults live on a settings page afterwards; use [agent settings](../agent-settings/guide.md) there. - A product tour or tooltips over the real UI. This is a setup flow, not a walkthrough. - Managing connections later. Use [connections](../connections/guide.md). #### Steps | Step | What the person does | Can be skipped | |---|---|---| | About you | Workspace name (required), team size (segmented control), what they use it for (cards, required) | No, but "Skip setup" leaves the whole flow | | Tools | Connect each tool: Connect opens a consent showing the agent's mark, the service's mark and each scope (read only or can change), then "Continue to Google" | Yes, "Skip" until one is connected | | The agent | Voice, with a sample reply that changes with it; switches for what it may do without asking. A switch that needs a tool stays off and says "Needs Stripe. Connect Stripe" | Continue with the defaults | | First task | Nothing: the agent starts as the step opens and writes into the card. Then Copy, or "Write another" | Yes, "Skip" until the draft is ready | | Done | Reads the summary; "Open Acme" calls `onComplete` | | Steps behind the current one are buttons in the step track, so the person can go back to any of them. Answers stay as they were. #### States | State | What you see | How | |---|---|---| | Fresh start | Step 1, empty, with "Just me" and the first voice chosen | `initialStep` left out | | Name or use case missing | Continue shows both errors under their fields at once and focuses the first | Checked locally | | Saving a step | Continue keeps its width; after 300ms its label shimmers to "Saving…" | `onSaveStep` pending | | Couldn't save | A danger line at the foot of the step, with your message; the step stays, answers kept | `onSaveStep` resolves a string, or throws | | Consent open | The row opens into the consent; the row's button becomes "Cancel"; focus moves to "Continue to Google" | Connect | | Connecting | Dots travel from the agent toward the service in the accent; the button shimmers "Waiting for Google…" | `onConnect` pending | | Slow connect | After `slowAfter` (8s), the note turns warning: "Still waiting on Google. Finish signing in in the window it opened, or cancel and try again." | | | Connect failed | The node turns to an x, the far side fades, the message in danger; the button becomes "Try again" | `{ ok: false }` or a rejection | | Popup closed | Back to the consent, no message | `{ ok: false, reason: "cancelled" }` | | Connected | The line draws in solid green, the node takes a check, "Connected as mara@…"; the consent folds a second later and focus goes to the row | `{ ok: true, account }` | | Draft writing | "Writing" with a turning glyph, a band running under the card's head, the text arriving with a caret. "Reading your answers…" if the first words take over 300ms | `onFirstTask` pending | | Draft failed | What's written stays; the reason in danger with "Try again" | `onFirstTask` rejects | | Draft ready | "Draft ready" in success; "Saved to Drafts. Nothing has been sent." with Copy and "Write another" | `onFirstTask` resolves | | Skip setup | "Setup skipped", a summary of the defaults, "Back to setup" (to the step it was pressed on) and "Open Acme" | "Skip setup" | | Done | Every segment turns success one after another, a check draws in, the summary rows arrive in reading order | Finish | | Resume | Opens on the step with the answers in place; a saved draft shows as ready, not rewritten | `initialStep` + `initialValues` | | Phone (under 480px wide) | A full-screen sheet: no corners or edge, the footer held at the bottom with the main action across the rest of the width | Container query | Leaving the first-task step, skipping or pressing "Write another" aborts the run through `signal`; a half-written draft is dropped. #### Props | Prop | What it does | |---|---| | `productName`, `agentName` | Required. Used in the copy: "Open Acme", "Copper will be able to" | | `brand` | Logo or wordmark, top left | | `agentMark` | The agent's mark on its accent tile (18px). Without one, its first letter | | `teamSizes` | `{ value, label }[]` for the segmented control. Default Just me, 2–5, 6–20, 21+ | | `useCases` | `{ value, label, description? }[]` as cards. One is required | | `tools` | `{ id, name, brand?, mark?, description, provider?, scopes }[]`. `brand` is a [brand mark](../brand-marks/guide.md) name; `provider` is who they sign in with ("Google" for Gmail) | | `tones` | `{ value, label, sample }[]`. The sample is the agent answering a guest in that voice | | `permissions` | `{ id, title, description, defaultOn?, requires? }[]`. `requires` is a tool id | | `firstTask` | `{ title, detail? }`: what the agent makes on the last step | | `onConnect(toolId)` | Required. `Promise` | | `onDisconnect(toolId)` | Called when the person disconnects a tool on the tools step | | `onFirstTask({ values, write, signal })` | Required. Call `write(chunk)` as text arrives; reject to fail. Pass `signal` on to your fetch | | `onSaveStep(step, values)` | Runs on Continue and Skip. Resolve a message to stay on the step and show it | | `onChange(values, step)` | Every change. Save it to resume | | `onComplete(values, how)` | The last button. `how` is `"finished"` or `"skipped"` | | `initialStep` | `"about"`, `"tools"`, `"agent"`, `"first-task"` or `"done"` | | `initialValues` | `Partial`, usually your last `onChange` | | `slowAfter` | Milliseconds before a connection says it's slow. Default 8000 | | `fullPage` | Fills the viewport's height on the canvas color, with safe-area padding | | `headingLevel` | 1 (default), 2 or 3 | `OnboardingValues` is `{ name, teamSize, useCase, connected, tone, permissions, draft }`; `connected` maps a tool id to the account it connected as. All types are exported. #### Customizing - **Steps.** The four steps are fixed in `onboarding.tsx` (`STEPS`) and drawn in `steps.tsx`. To drop one, take it out of `STEPS` and the step track follows; to add one, add a view in `steps.tsx` and a label. - **Copy.** Headings and lines live in `steps.tsx`. Keep each failure saying what to do next. - **The first task.** Make it the thing your agent is best at, using what the person just told you, and keep it safe: a draft, not a send. - **Width.** `--onboarding-width` on the block (560px by default). The panel's corner follows `--corner`, so footer buttons stay concentric. - **Sheet height.** `--onboarding-sheet-height` (default `100dvh`) when the block is narrow but not the whole screen. #### Layout The panel's padding is `--ui-pad-section`: 24 to 40 as the onboarding widens. On a phone (the onboarding under 480px) it drops to 16, so a 320px screen keeps its width for the step. The step labels wrap between words, never inside one. #### Accessibility - Each step's heading takes focus when the step changes, so a screen reader hears where it is; the step track is a list with `aria-current="step"` and "Step 2 of 4, done:" read before each label. - Steps behind you are buttons; steps ahead aren't focusable. - Errors are tied to their fields (`aria-invalid`, `aria-describedby`), and Continue focuses the first one. - Connect and Cancel are named for the tool ("Connect Gmail") and point at the consent with `aria-expanded`/`aria-controls`. Connecting, connected and failed are announced in a polite live region. When a button goes away, focus moves to the next thing to do, never to the page. - The draft card is `aria-busy` while it writes; "Copied" is announced. - The leaving step is a copy, `inert` and `aria-hidden`, so nothing in it can be reached while it slides out. - Reduced motion: steps fade instead of sliding, the dots and the band breathe instead of travelling, and the check is drawn at once. #### Before / after Before: a wizard of five forms with a "Next" button, a row of OAuth buttons that open popups with no word about what's being granted, a spinner while the popup is open, and a final screen that says "You're all set!" with nothing to show for it. After: four steps whose height glides between them. Each tool says what the agent will read and what it can change before the person leaves for Google, and says so plainly when Google is slow or fails. The agent's voice comes with a sample, its permissions say what they mean, and the last step shows the agent writing a real invite from the person's own answers, saved as a draft and never sent. ### Pricing `pricing` · · v0.2.1 · https://uipolish.com/docs/pricing The public pricing page of an AI SaaS product: a monthly/yearly switch with the saving said quietly, plan cards whose prices roll between the two without moving anything, per-seat and usage-based notes ("then $4 per 1,000 credits"), one featured plan, an Enterprise row, a full comparison table with a sticky plan header and a plan picker on phones, and questions in an accordion. Choosing a plan waits on your async callback and says what went wrong if it fails. The public pricing page of an AI SaaS product. A monthly/yearly switch with the yearly saving said once, quietly, in the switch; a card per plan whose price rolls between the two figures without moving anything around it; what's included, what credits cost past the limit, and what each tier adds to the one before. Under the plans: an optional Enterprise row, a full comparison table with a header that sticks while you read it, and questions in an accordion. Choosing a plan waits on your own async callback. The button pressed shows busy while it runs, and if it fails, says what went wrong next to it. ```tsx startCheckout(planId, interval).then((url) => router.push(url))} /> ``` #### When to use it - The pricing page of a product with a free tier, a paid tier or two and a per-seat team plan, priced per month or per year, with usage on top (credits, messages, minutes). - As the whole page, or cut down: leave out `features`, `faq` or `enterprise` and that part goes. #### When not to - Changing plans once someone is paying. That needs proration, what a downgrade loses and the card it's charged to: use **Billing**. - One price and nothing to compare. A sentence and a button say it better. - More than four plans. Past that nobody compares; split by audience (individuals, teams) on two pages instead. #### States | State | What shows | |---|---| | Monthly | Each card's price per month, "Billed monthly" under it | | Yearly | The price per month when paid yearly, and the year's total: "$192, billed yearly", "$288 per seat, billed yearly". The switch reads "Yearly Save 20%", the largest saving across the plans | | Free plan | "$0 / month", "Free forever" on both periods; the button reads "Start for free" | | Featured | A "Most popular" badge (or `badge`) beside the name, the selected edge and the raised shadow, the primary button and checks in the text color. No glow, gradient or color: in dark, where nothing has a shadow, the card lifts a step (`--ui-fill-solid`) and the edge carries it | | Signed in (`currentPlanId`) | "Current plan" with a check, in place of that plan's button, on the card and in the table header. It holds the button's height, so the rows still line up | | Choosing | The pressed button keeps its label and width and shimmers after 300ms; the other plan buttons wait until it settles | | Choice failed | The rejection's message in red under the card's button, read out at once. From the table's sticky header, it goes to a toast instead | | No `onSelect` | No plan buttons at all, rather than buttons that do nothing: a page that only informs. To link to sign-up, navigate in `onSelect` | | Usage with overage | "**5,000** credits a month / then $4 per 1,000 credits", on a row of the card between two hairlines, not a box inside it | | Usage that stops | "200 credits a month / Pauses at the limit until next month" (`atLimit`) | | Wide (760px and up) | Each card's name, pitch, price, button, usage and features share rows with the other cards (subgrid), so every one lines up across the row. The Enterprise row sits on the same columns: its name, pitch and button under the first plan, its list under the rest | | Narrow (under 760px) | The cards stack in one column capped at 440px, so the full-width button is never wider than a card; the Enterprise row stacks too, its button full width like the plans' | | Phone (under 640px) | The table shows one plan at a time, picked in a segmented control that sticks above it. Values align to the end; nothing scrolls sideways | #### Prices that change in place The figures on a card never animate their width or position. Both the monthly and the yearly figure are rendered, one character per cell, so each cell is as wide as its wider character and the figure holds its box. Only the characters that differ move: going to yearly they roll up, going back they roll down, one after another, on the glide spring (`--ui-dur-glide`, `--ui-ease-glide`), the leaving digit fading on `--ui-dur-instant` before the arriving one fades in. The "billed" line crossfades the same way. Nothing moves on first paint, and under reduced motion the figures only crossfade. Prices use tabular figures, so $16 and $20 are the same width. #### Props | Prop | What it does | |---|---| | `plans` | `{ id, name, description?, price: { month, year }, perSeat?, usage?, includes?, features, featured?, badge?, cta? }[]`, cheapest first. `price.year` is a whole year, paid up front | | `plans[].usage` | `{ included, unit, overage?: { price, per }, atLimit? }`. "10,000 credits a month, then $3 per 1,000 credits" | | `plans[].includes` | Another plan's id: the list opens with "Everything in Pro, plus", so each tier lists only what it adds | | `features` | `{ title, rows: { label, hint?, values }[] }[]`. `values` by plan id: `true` a check, `false` or missing a dash, a string as written. `hint` is one sentence in a tooltip beside the label | | `faq` | `{ question, answer }[]`. `answer` is any node; wrap paragraphs in `

    ` | | `enterprise` | `{ name?, description, features?, cta? }`. Its button links to `contactHref` | | `contactHref` | Where "Talk to us" goes, in the Enterprise row and beside the questions | | `onSelect(planId, interval)` | A plan's button was pressed. Return a promise to show busy; reject with an `Error` whose message says what happened and what to do | | `currentPlanId` | The signed-in workspace's plan | | `defaultInterval` | `"month"` or `"year"`. Default `"month"` | | `interval`, `onIntervalChange` | Control the period yourself, e.g. to keep it in the URL | | `currency`, `locale` | Default `"USD"`, `"en-US"`. Money goes through `Intl.NumberFormat`; whole amounts drop the cents | | `title`, `description`, `headingLevel` | Default "Pricing", none, 2. Plan names and sections are one level down, questions two | #### Customizing - **The sticky header** sits under `--pricing-sticky-top` (default `0px`). Set it to your site header's height if that header is sticky too. - **The phone picker** is opaque so rows pass under it: set `--pricing-backdrop` to the color the section sits on (default `--ui-canvas`). - **Plan copy**: one line of `description` on who it's for, five or six `features`, each a gain over the tier before. Keep the comparison rows parallel ("3 at a time", "10 at a time") so a column reads down. - **The saving** is worked out from `price.month * 12` against `price.year`; the switch shows the largest one, rounded. - **One inset** (`--pricing-inset`, default `--ui-pad-card`: 16 in a narrow section, 24 from 560px) pads the plans, the Enterprise row, the table and the questions alike, so text and rules start the same distance from every card's edge. - **Widths** are container queries on the section: three columns from 760px, the one-plan table under 640px. It lays out right in any frame. #### Accessibility - One heading at `headingLevel`. The plans are a list named by it, each card named by its plan; the comparison and the questions are sections named by their headings. - The period switch is a radio group named "Billing period". The phone picker is a radio group named "Plan to show". - The rolling figure is `aria-hidden`; a screen reader hears the shown price once, in words ("$16 a month", "$24 per seat per month"). The billed line's hidden wording is `aria-hidden` too. - The table is a real `` with a caption, plan columns (`scope="col"`), group rows (`scope="colgroup"`) and feature rows (`scope="row"`). Checks and dashes read "Included" and "Not included". On a phone the header row is visually hidden but stays in the table. - Term hints are buttons named "About Context window", 24px to a mouse and 40px to a finger, and describe themselves with the tooltip. They only repeat: nothing you need to choose lives in a tooltip. - A failed choice is a `role="alert"` linked to its button with `aria-describedby`. Busy buttons keep focus and ignore presses. #### Before / after Before: three boxes of bullets, a price that jumps width when the period changes, and a table that scrolls sideways on a phone. ```tsx
    {plans.map((p) => (

    {p.name}

    ${yearly ? p.year / 12 : p.month}/mo

    Buy
    ))}
    ``` After: one featured plan, prices that roll in place, usage in words, the current plan marked, a comparison that fits any width, and a checkout that says when it didn't open. ```tsx ``` ### Run history `run-history` · · v0.2.3 · https://uipolish.com/docs/run-history The screen where a team inspects past AI agent runs: a filterable, paged table of runs with live ones counting up in place, and a run's trace as a calm waterfall of model and tool calls, with retries and the failing step called out, collapsed long outputs, a token and cost breakdown, the final output, and Re-run, Copy ID and Share link. The screen where a team looks back at what their AI agent did. A table of runs (status, agent, trigger, when, how long, tokens and cost) with search, filters and a date range, and a page for any one run: its trace as a waterfall of model and tool calls, retries and the failing step called out, each call's prompt, arguments and result, a token and cost breakdown, the final output, and **Re-run**, **Copy ID** and **Share link**. Runs that are still going update in place. Their duration counts up in a column that never changes width, the arc in the status turns into a tick that draws on when they finish, and the row tints for a moment. New runs don't push rows down under the pointer: they wait behind **Show 2 new runs**. ```tsx router.push(id ? `/runs/${id}` : "/runs")} detail={trace} // { id, steps, output?, cancelNote? } detailLoading={traceLoading} detailError={traceError} onRetryDetail={refetchTrace} onRerun={(run) => api.post(`/runs/${run.id}/rerun`).then((r) => r.id)} onShare={(run) => api.post(`/runs/${run.id}/share`).then((r) => r.url)} timeZone={viewer.timeZone} emptyAction={} /> ``` #### When to use it - The "Runs" or "Activity" page of a product with an agent in it, for the people who need to know what it did, why it failed, and what it cost. - Support and ops: find the run a customer is asking about by ID or by the words in its error, open it, and share a link to it. - As the place a failed-run alert links to: pass `selectedId` and the page opens on the run, with the failing step open. #### When not to - Watching one run as it works, in the product itself: use Agent task or Tool steps beside the conversation. This screen is for looking back. - Aggregate analytics: cost per week, failure rates, latency percentiles. That's a dashboard; link to it from here. - Raw logs or OpenTelemetry spans with dozens of nested levels. The trace here is one level of steps, in order, which is what an agent run is. #### States | State | What shows | |---|---| | Loading (`loading`) | The title and filters, and "Loading runs…" shimmering in a card the height of ten rows, after 300ms | | Didn't load (`error`) | An empty state in the table's place with your sentence and **Try again** (`onRetry`) | | No runs yet | No filters set and `total` 0: a page empty state, "No runs yet", `emptyDescription` and `emptyAction` | | No results | "No runs match "lamb tagine"" with the range it searched and **Clear filters**, in the table's space | | Refreshing (`refreshing`) | The old rows stay put and dim after 300ms; the search icon breathes | | Live run | The arc turning, the duration counting up in mono, "1 running" with a breathing dot over the table | | A run finishes | The arc gives way to a tick that draws on, or the failed mark; the row tints green or red and fades over a second | | New runs (`newRuns`) | **Show 3 new runs** on the line over the table, which is always there, so nothing moves | | Failed run (row) | The error under the title, in danger text, wrapped, never cut | | Run open | The status, title, ID, agent, trigger and start time; Re-run, Copy ID, Share link; a strip of duration, tokens, cost, steps and models | | Failed run (open) | A red alert, "Failed at step 5 of 5: send_invites", the step's error, **Show step**. The step starts open and tinted | | Retried step | An amber mark and bar, "attempt 2" on the next one, and "This attempt failed, so it was retried" with the reason inside | | Cancelled run | An info alert with `cancelNote`; the steps that ran are listed | | Running run (open) | The scale has headroom, so it changes rarely; the running bar grows and sweeps; its duration counts; Output says it's still working | | Long prompt or result | The first 12 lines (or 1,600 characters of one long line), then **Show all 2,106 lines**. Open, it scrolls in its own box; lines wrap, so nothing runs off the side | | Trace loading or failed | The header shows from the row; the trace's space holds "Loading the trace…" or the error with **Try again** (`onRetryDetail`) | | Run not on the page | From a link: `detail.run` fills the header; before it lands, "Loading run…"; with neither, "No run called run_…" | | Read only (`readOnly`) | No Re-run, and your sentence under the actions: who can | | Re-run, Share pending | The button keeps its width and shimmers "Starting…" or "Making link…" after 300ms | | Re-run, Share failed | The rejection's message under the actions | | Copied | "Copy ID" becomes "Copied", "Share link" becomes "Link copied", in the same width, for a moment. If the clipboard is blocked, the link is shown to select | #### Props | Prop | What it does | |---|---| | `runs` | `RunSummary[]`: `{ id, agent, title, status, trigger, triggeredBy?, startedAt, durationMs?, tokens, cost, error? }`. One page, newest first. `status` is `"running"`, `"succeeded"`, `"failed"` or `"cancelled"`; `trigger` is `"user"`, `"schedule"` or `"webhook"`. Leave out `durationMs` while it runs | | `total`, `page`, `pageSize`, `onPageChange` | Runs across every page, the page (from 1), 25 by default. No `onPageChange`, no pager | | `filters`, `onFiltersChange` | Controlled `{ query, status, agent, trigger, range, from?, to? }`. `range` is `"1h"`, `"24h"`, `"7d"`, `"30d"`, `"all"` or `"custom"` with ISO `from` and `to`. `DEFAULT_FILTERS` is exported | | `agents` | Names for the Agent filter; fewer than two hides it | | `newRuns`, `onShowNewRuns` | How many runs you're holding back, and putting them in | | `loading`, `refreshing`, `error`, `onRetry` | First load, a fetch for new filters or a page, and failure | | `selectedId`, `onSelect` | The open run, or `null` for the list. Put it in the URL | | `detail` | `{ id, run?, steps, output?, cancelNote? }`. Each step: `{ id, kind, name, status, startMs, durationMs?, model?, attempt?, input?, output?, error?, tokens?: { input, output, cached? }, cost? }`. `kind` is `"llm"` or `"tool"`; `status` adds `"retried"`. `startMs` counts from the run's start | | `detailLoading`, `detailError`, `onRetryDetail` | The trace's own loading and failure | | `onRerun(run)` | Resolve with the new run's id and the block opens it | | `onShare(run)` | Return the link, or a promise of it; the block copies it | | `readOnly` | A sentence: why they can't re-run, and who can | | `emptyDescription`, `emptyAction` | For "No runs yet": how runs start in your product, and the button that starts one | | `currency`, `locale`, `timeZone`, `now` | Defaults `"USD"`, `"en-US"`, `"UTC"`, now. Pass the viewer's time zone; "Today, 14:02" follows it | | `title`, `description`, `headingLevel` | Default "Runs", none, 1. Sections are one level down | Every callback can return a promise; the block shows busy until it settles and the rejection's message if it fails. It never fetches: filters, pages and traces come in as props. #### Customizing - **Columns** are in `RunRow` and the `` in `run-history.tsx`. Widths are on the header cells, because the table is fixed-layout: that's what keeps a counting duration from nudging anything. - **Step colors**: `--rh-llm` and `--rh-tool` on `.root`. A model call is accent mixed toward danger, so green and red keep meaning done and failed. - **Fold length**: `FIRST_LINES` and `FIRST_CHARS` in `detail.tsx`. - **Trigger wording**: `triggeredBy` is shown as is ("Mondays at 07:00", "Stripe · checkout completed"). Keep it short; it wraps if it isn't. - **Inset**: every panel is padded `--ui-pad-card` (16 narrow, 24 from 560px), read once as `--rh-inset`. - **Widths**: the Trigger and Tokens columns move under the title below 880px, and every run becomes a card below 600px. The trace puts each bar under its name below 560px. The filter selects sit two to a line, and one to a line below 300px. All container queries, so it fits a narrow pane. #### Accessibility - A real `
    ` named by the title. Each run's title is a button whose hit area covers the row, read as "Draft the menu for Saturday's harvest supper, Running". As cards, the header row is visually hidden but read, and every value is still in the card. - Opening a run moves focus to its title; **Runs** goes back and returns focus to the row it came from. - Status is a glyph and a word, never color alone. Each step reads "Step 5 of 5, Failed: send_invites, took 2.3s" and is a disclosure button with `aria-expanded`; folded content is `inert`. - The failure alert's **Show step** opens the step, scrolls it into view (no smooth scroll with reduced motion) and focuses it. - A polite status region announces what lands: "Copied run_W7kQ2mPx.", "Link copied.", "Started run_…", "3 new runs added at the top." - `/` focuses search. Escape clears it. - Reduced motion: the arc breathes instead of turning, bars land at their length, steps open without a slide. #### Before / after Before: a log table with a JSON blob per row, a status string, and no way to see which call failed without reading all of it. ```tsx
    {runs.map((r) => ( ))}
    {r.id}{r.status}
    {JSON.stringify(r.trace)}
    ``` After: what each run was asked and how it ended, at a glance; one click to its trace, with the failing step open and its error in words; long outputs folded; and the cost of every call. ```tsx ``` ### Settings `settings` · · v0.2.1 · https://uipolish.com/docs/settings The settings screen of an AI product's workspace: name, URL, logo, time zone and deleting the workspace; API keys with scopes and expiry, the secret shown once, revoke and flags for expired or unused keys; the default model, per-feature models, a monthly spend limit with an alert threshold, rate limits and usage against them; and notification switches per channel with a test send. One save bar holds unsaved changes, with inline validation, optimistic saves that roll back, a leave guard, a read-only viewer state and loading skeletons. The settings screen of a workspace in an AI product, in four sections: General (name, URL, logo, time zone, language, deleting the workspace), API keys, Models and limits (default model, a model per feature, a monthly spend limit with an alert, rate limits and what's been spent), and Notifications (a switch per event and channel, and a test send). A column of sections sits beside the panel; on a phone it's a row of tabs above it. Fields in General and Models and limits are a draft. Changing one raises a save bar at the bottom of the screen with Discard and Save, marks the section's tab with a dot, and guards leaving. Logo, keys and notification switches act at once, each in its own place. It takes data and promise-returning callbacks, and fetches nothing. ```tsx api.patch("/workspace", patch)} timezones={timezones} locales={locales} urlPrefix="app.acme.ai/" apiKeys={keys} scopes={scopes} onCreateKey={(input) => api.post("/keys", input)} // resolves { key, secret } onRevokeKey={(id) => api.delete(`/keys/${id}`)} models={models} features={features} usage={{ spent: 1184.6, segments }} plan={{ maxRequestsPerMinute: 1000 }} notifications={{ events, channels, value, onChange, onTest }} canEdit={role !== "viewer"} /> ``` #### When to use it - The workspace or team settings of a SaaS product with AI in it: an assistant, an agent platform, an API product with a dashboard. - As a starting point you cut down: `sections={["general", "api-keys"]}` keeps two. #### When not to - Settings for one agent (its tools, its memory, what it may do without asking). Use Agent settings, where every change saves in its row. - Personal preferences (theme, your own email). They belong in an account menu or a profile page. - Billing and members. Use Billing and Team members beside it. #### Sections | Section | What's in it | Saves | |---|---|---| | General | Logo (upload, replace, remove), workspace name, URL with its full address under it, time zone and language selects, Delete workspace | Logo at once; the rest with the save bar | | API keys | Each key: name, masked secret (`sk-…4f2a`), who made it and when, last used, expiry, scopes as badges, Revoke. Create key. Keys expired or unused for `staleDays` (90) carry a badge and their date in the warning color, and a line above the list counts them | At once | | Models and limits | Default model and one model picker per feature ("Default" follows the default). Monthly limit, an "Email admins at" slider from 50% to 100% in steps of 5, then the month's spend against the limit (the usage meter's breakdown, which follows the draft limit and threshold as you change them). Requests and tokens per minute, with the busiest minute and the plan's ceiling under each | With the save bar | | Notifications | Each channel with where it goes and Send test; then a table of events × channels, a switch in each cell | Each switch at once | **Creating a key** opens a dialog: a name (required, unique), scopes as checkboxes (at least one) and when it expires (30 days, 90 days, a year, never), with the date it lands on under the select. Create key calls `onCreateKey({ name, scopes, expiresInDays })`; while it runs the button shimmers "Creating…". When it resolves with `{ key, secret }` the dialog turns to the secret: a warning "You won't see this key again", the full secret in a copy field and Done. Closing before copying asks "Close without copying the key?" with Go back and Close anyway. Add the key to `apiKeys` before you resolve; it lands at the top of the list behind the dialog. **Revoking** asks first ("Revoke Staging worker? Requests that use sk-…e3a9 start failing straight away…") with Cancel focused and a danger Revoke key. The confirm stays open and busy until `onRevokeKey` resolves; a rejection shows its message in the dialog. Remove the key from `apiKeys`; focus moves to the next key's Revoke, the one before, or Create key. **Deleting the workspace** opens a dialog that asks for the workspace's name typed exactly. A mismatch says so under the field rather than disabling the button. While `onDeleteWorkspace` runs the dialog is locked and the button reads "Deleting…"; a rejection shows its message beside the buttons. **The logo** checks the file before it calls `onLogoUpload(file)`: PNG, JPG, SVG or WebP, under 1 MB, and says what's wrong in the line under "Logo". Without a logo the tile shows the workspace's initials. #### The save bar | State | Bar says | Buttons | |---|---|---| | Nothing changed | hidden (its space is kept, so nothing moves when it shows) | | | Changed | "Unsaved change to the spend limit", or "3 unsaved changes", and the ⌘S / Ctrl+S keys (not on touch) | Discard, Save | | Some field is invalid, after Save | "Fix the workspace name and URL before saving." in the danger color; focus moves to the first bad field, switching section if needed | Discard, Save | | Saving | "Saving 2 changes…" | Discard disabled, Save shimmers "Saving…" | | Failed | The rejection's message, in the danger color | Discard, Try again | | Saved | A check and "Changes saved", for 2 seconds, then it leaves | | **Optimistic.** Save treats the draft as saved at once: the tab dots clear and anything that reads the saved values (the lede, sentences with the workspace's name) moves to the new ones. If `onSave` rejects, the saved values roll back, the draft stays exactly as typed, and the bar shows the message. Reject with `SettingsError` to put messages under fields: ```tsx import { SettingsError } from "./settings"; onSave={async (patch) => { const res = await fetch("/api/workspace", { method: "PATCH", body: JSON.stringify(patch) }); if (res.status === 409) { throw new SettingsError("That URL is taken. Pick another one, then save.", { slug: "app.acme.ai/halden belongs to another workspace. Try halden-hq", }); } if (!res.ok) throw new Error("Acme didn’t answer in time, so nothing was saved."); }} ``` A field error from the server clears as soon as that field changes. The screen switches to the section of the first field with an error and focuses it. `onSave` gets only what changed: `{ name: "Halden Robotics Ltd", requestsPerMinute: 800 }`. `overrides` comes whole when any feature's model changed. Update `settings` when it resolves, or don't: the screen keeps what it saved. New `settings` from outside (another tab, a refetch) replace every value the person hasn't changed and leave their edits alone. #### Validation Fields check themselves on blur, and all of them on Save. An error clears on the keystroke that fixes it. | Field | Rule | Message | |---|---|---| | Workspace name | Required, 48 characters at most | "Give the workspace a name", "Keep it to 48 characters; this is 52" | | URL | 3 to 32 lowercase letters, numbers and single hyphens, starting and ending with a letter or number. Spaces type as hyphens | "Use lowercase letters, numbers and hyphens, starting and ending with a letter or number" | | Monthly limit | 1 to `plan.maxSpendLimit`; the number input pulls typing into range and says so. At or under what's spent, the line warns that runs pause as soon as it's saved | | | Rate limits | 1 to the plan's ceiling, the same way | | #### Leaving with unsaved changes - `beforeunload` asks before a reload or a closed tab while anything is unsaved. - `onDirtyChange(dirty)` tells you when changes appear and go, for your router's own guard. - `ref.current.confirmLeave()` resolves `true` at once when nothing is unsaved. Otherwise it asks "Leave without saving?", naming what changed, with Keep editing (focused) and Discard changes, and resolves with the answer. Call it before you navigate: ```tsx const settings = useRef(null); async function back() { if (await settings.current?.confirmLeave()) router.push("/runs"); } ``` #### Permission and loading - `canEdit={false}`: an info alert above the panel, "You can see these settings, but not change them", with `permissionNote` under it (say who can). Text fields are read-only (their values can still be selected and copied), selects, pickers, numbers, the slider and switches are disabled, and Create key, Revoke, Delete workspace, the logo's buttons, Send test and the save bar aren't rendered. - `loading`: after 300ms a skeleton of the panel stands in, one bone per line it will hold, inside a 480px space so the panel doesn't jump when it lands. The title and the section column stay usable. #### Props | Prop | What it does | |---|---| | `settings` | `WorkspaceSettings`: `{ name, slug, timezone, locale, defaultModel, overrides, spendLimit, alertAt, requestsPerMinute, tokensPerMinute }` | | `onSave` | `(patch) => Promise`. Leave it out and the save bar never shows | | `canEdit`, `permissionNote` | Default `true` | | `loading` | First load | | `product` | Your product's name, used in sentences ("call the Acme API"). Default "Acme" | | `title`, `description` | Default "Settings" and a line naming the workspace | | `urlPrefix` | Shown before the slug: "app.acme.ai/" | | `timezones`, `locales` | `SelectOption[]` | | `logoUrl`, `onLogoUpload`, `onLogoRemove` | Leave the callbacks out to show the logo without buttons | | `onDeleteWorkspace`, `deleteNote` | Shows Delete workspace; the note replaces the line about what goes with it | | `apiKeys`, `scopes` | `ApiKey[]` (`{ id, name, last4, scopes, createdAt, createdBy, lastUsedAt, expiresAt? }`), `{ value, label, description? }[]` | | `onCreateKey`, `onRevokeKey`, `staleDays` | See above. `staleDays` default 90 | | `models`, `features` | `ModelOption[]` as the model picker takes them; `{ id, label, description? }[]` | | `usage` | `{ spent, segments?, period?, peakRequestsPerMinute?, peakTokensPerMinute? }` | | `plan` | `{ maxSpendLimit?, maxRequestsPerMinute?, maxTokensPerMinute? }` | | `currency`, `numberLocale` | Default `"USD"`, `"en-US"` | | `notifications` | `{ events, channels, value, onChange?, onTest? }`. `value` is `{ [event]: { [channel]: boolean } }`. Leave it out to drop the section | | `sections` | Which sections, in order. Default all four | | `section`, `defaultSection`, `onSectionChange` | Control the section, for example from your router | | `onDirtyChange`, `ref` | See "Leaving" | | `now` | The clock for "3 hours ago" and stale keys. Defaults to when the screen mounted | **Notification switches** move the moment they're pressed and call `onChange(event, channel, on)`. While it runs the switch is busy (presses ignored, focus kept). If it rejects, the switch moves back and the message appears under that event, across the table: "Couldn’t turn on Slack for “Key expiring”. It’s still off; try again." or your own. **Send test** calls `onTest(channel)` and shimmers "Sending…"; success is a toast ("Test sent to #ops-alerts"), since what it confirms happens somewhere else; a failure replaces the channel's address with the message. Render `` once in your app. #### Layout | Width of the screen | Sections | Rows | |---|---|---| | 480px and up | A column at least 160px wide, 32px from the panel, sticky at `--settings-top` (24px) | Label and line on the left, control on the right | | Under 480px | A row of tabs above the panel; it scrolls sideways with a faded edge when it overflows | As above while the panel is over 460px | | Panel 460px and under | | The control drops under its text; time zone and language stack; a key's Revoke goes under it | | Screen 560px and under | | The save bar puts its words above the buttons, which share the row in halves | Spacing reads the shared roles: `--ui-gap-block` under the title and between a panel's heading and its groups (16 to 32 as the screen widens), `--ui-gap-group` between fields and `--ui-gap-stack` inside a group. All of these are container queries on the screen, so it lays out the same in a page, a panel or a dialog. Edges: the key list is the one box on a panel, a hairline ring (`inset 0 0 0 var(--ui-hairline) var(--ui-border-strong)`), with rows split by inset lines in `--ui-border`. Rows elsewhere and the notification table use inset single-side lines in `--ui-border`. No borders anywhere. The save bar floats on `--ui-shadow-floating`, which is the edge alone in dark mode. Its corner is its 40px buttons' radius plus its 8px inset. Rows hold one control height each: model pickers 32, numbers 40, the logo's and keys' buttons 32, save bar buttons 40. #### Motion | What | Duration | Easing | |---|---|---| | Save bar arrives: fade and a 4px rise | `--ui-dur-base` | `--ui-ease-enter` | | Save bar leaves | `--ui-dur-fast` | `--ui-ease-exit` | | A tab's unsaved dot appears: fade, scale from 0.5 | `--ui-dur-fast`, `--ui-dur-snap` | `--ui-ease-enter`, `--ui-ease-snap` | | The dot goes | `--ui-dur-instant` | `--ui-ease-exit` | | A line turning warning or danger | `--ui-dur-instant` | `--ui-ease-color` | | A notification failure arrives: fade, 2px rise | `--ui-dur-fast` | `--ui-ease-enter` | | Tabs, dialogs, switches, buttons, the usage breakdown | as each component's guide says | | Under `prefers-reduced-motion: reduce` the bar and the failure only fade. #### Accessibility - One `h1` (the title), an `h2` per section, an `h3` per group. Each panel is named by its heading. The section tabs are Base UI tabs: arrow keys move between them, and a tab with unsaved changes reads ", unsaved changes". - Every control has a name: the model pickers read "Model for Chat: Default", the number fields carry their row's label, each switch reads "Slack: Key expiring", each Revoke "Revoke Staging worker". - ⌘S or Ctrl+S saves from anywhere while something is unsaved. - A polite status region says each save ("Saved 2 changes."), each failure, a discard, a created or revoked key, and each notification change. - Dialogs trap focus and give it back: the safe button is focused in confirms, the name field in Create key; after a revoke focus goes to the next key. - Field errors are linked with `aria-describedby` and set `aria-invalid`. #### Before / after Before: one form per section with its own Save, keys shown in full in a table, and a delete button with a `confirm()`. ```tsx

    {keys.map((k) => )}
    {k.secret}
    ``` After: one draft across sections with one save bar that says what's unsaved and survives failures; keys that are only ever shown masked, with the secret once at creation; a typed confirmation for the one action that can't be undone. ```tsx api.patch("/workspace", patch)} apiKeys={keys} scopes={scopes} onCreateKey={createKey} onRevokeKey={revokeKey} onDeleteWorkspace={deleteWorkspace} models={models} timezones={timezones} locales={locales} /> ``` ### Sign in `sign-in` · · v0.2.5 · https://uipolish.com/docs/sign-in A passwordless sign-in and sign-up screen: work email, then a one-time code with a resend countdown, plus optional OAuth providers and SSO by work domain. Wrong, expired and rate-limited codes, blocked domains and failed providers each say what to do next. The screen a SaaS or AI product puts in front of everyone who isn't signed in yet. Work email first, then a one-time code (with a resend countdown and "Use a different email"), plus any OAuth providers and a "Continue with SSO" path that asks for the work domain. `mode="sign-up"` changes the wording; the flow stays the same, because with codes there's no difference between the two until your server says so. The block holds the flow. Your server holds the decisions: each callback returns a promise of a result, and every failure has its own screen. ```tsx } legal={<>By continuing, you agree to our Terms and Privacy Policy.} mode={mode} onModeChange={(next) => router.push(next === "sign-up" ? "/signup" : "/login")} onSendCode={async (email) => { const res = await fetch("/api/auth/code", { method: "POST", body: JSON.stringify({ email }) }); if (res.status === 403) return { ok: false, reason: "not-allowed", message: (await res.json()).message }; if (res.status === 429) return { ok: false, reason: "rate-limited", retryIn: Number(res.headers.get("Retry-After")) }; if (!res.ok) return { ok: false, reason: "error" }; return { ok: true, expiresIn: 600, resendIn: 30 }; }} onVerify={async (email, code) => { const res = await fetch("/api/auth/verify", { method: "POST", body: JSON.stringify({ email, code }) }); if (res.ok) return { ok: true }; const body = await res.json(); // { reason: "wrong-code", attemptsLeft: 3 } and so on return { ok: false, ...body }; }} providers={[{ id: "google", name: "Google" }, { id: "github", name: "GitHub" }]} onProvider={async (id) => { window.location.assign(`/api/auth/oauth/${id}`); return new Promise(() => {}); // a redirect never comes back: the button stays busy }} onSso={async (domain) => { const res = await fetch(`/api/auth/sso?domain=${encodeURIComponent(domain)}`); if (res.status === 404) return { ok: false, reason: "not-found" }; if (!res.ok) return { ok: false, reason: "error" }; window.location.assign((await res.json()).url); return new Promise(() => {}); }} onSuccess={() => router.replace(next ?? "/")} fullPage autoFocus /> ``` #### When to use it - A product that signs people in with an emailed code, alone or alongside OAuth and SAML/OIDC SSO. - Both the sign-in and sign-up pages, from one component. #### When not to - Password sign-in. Use text fields in a form. The code step here is the whole point. - A second factor after a password. Use [code input](../code-input/guide.md) on its own, in your existing flow. - Re-confirming a signed-in person before a risky action. That's a dialog. #### Results, not exceptions Each callback resolves with `{ ok: true, … }` or `{ ok: false, reason, … }`. A thrown error or rejected promise counts as `reason: "error"`, so a dropped connection still gets a message and a way to retry. Answers that arrive after the person has moved on (pressed "Use a different email", say) are dropped. | Callback | `reason` | What the person sees | |---|---|---| | `onSendCode` | `not-allowed` | Warning under the button: the address can't sign in or up here. Pass `message` to say why ("Personal addresses can't join. Use your work email.") | | | `rate-limited` + `retryIn` | Warning with a live countdown; the button is disabled until it ends, then the warning goes | | | `error` | Error under the button; the email is kept, press again to retry | | `onVerify` | `wrong-code` + `attemptsLeft` | The boxes clear and take focus; "That code didn't match. 2 tries left." `attemptsLeft: 0` asks for a new code | | | `expired` | "This code has expired." The main button becomes "Send a new code" | | | `rate-limited` + `retryIn` | Boxes disabled; the main button counts down to "Send a new code" | | | `error` | The message, or a default; the code is cleared to type again | | `onProvider` | `cancelled` | Nothing: the person closed the popup themselves | | | `not-allowed`, `error` | A message under the provider buttons, next to the one pressed | | `onSso` | `not-found` | Field error: the domain hasn't set up SSO, with a way back | | | `error` | Field error with the message | A code also expires on its own clock: once `expiresIn` passes, the code step switches to "Send a new code" without asking the server. #### States | State | What you see | How | |---|---|---| | Idle | Heading, work email field, "Continue with email", then "or" and the providers | | | Invalid email | Field error on submit, not while typing: "Enter a full email, like name@company.com." | Checked locally | | Sending code | Button keeps its width; after 300ms its label shimmers to "Sending code…" | `onSendCode` pending | | Code sent | "Check your email", the address, six boxes with focus in the first, "Resend in 0:24" counting down | `ok: true` | | Verifying | Fires on the last digit (or the button); "Checking code…"; further typing is ignored | `onVerify` pending | | Wrong code | Danger edges, attempts left, boxes cleared, focus back in the first | `wrong-code` | | Code expired | Error line; main button "Send a new code" | `expired`, or the clock | | Too many tries | Boxes disabled; "Send a new code in 4:47" | `rate-limited` from `onVerify` | | Too many codes | Warning with a countdown; send disabled until it ends | `rate-limited` from `onSendCode` | | Not allowed | Warning naming the address and what to do | `not-allowed` | | Provider busy / failed | "Opening GitHub…" on that button; a failure shows under the providers | `onProvider` | | SSO | Asks for "Work email or domain", prefilled from the email if one was typed; accepts `mara@acme.com`, `acme.com` or a URL | `onSso` | | Success | Check, "You're signed in" (or "Your account is ready"), the address, a shimmering "Opening Acme…". `onSuccess` fires once, after it paints | `ok: true` anywhere | Busy is not disabled: the pressed button keeps focus and ignores more presses, and the rest of the form ignores them too while anything is in flight. #### Props | Prop | What it does | |---|---| | `productName` | Required. Used in the copy: "Sign in to Acme", "Opening Acme…" | | `mode` | `"sign-in"` (default) or `"sign-up"`. Wording only | | `onSendCode(email)` | Required. `Promise`. `ok: true` takes `expiresIn` (default 600s) and `resendIn` (default 30s) | | `onVerify(email, code)` | Required. `Promise`. Resolve `ok: true` once the session exists | | `providers` | `{ id, name, mark? }[]`, shown as "Continue with {name}" in order. Without `mark`, a neutral letter tile. `provider-marks.tsx` has `GoogleMark`, `GitHubMark` and `MicrosoftMark` | | `lastUsed` | `"email"`, `"sso"` or a provider id: a muted line under that option's group says "You last signed in with Google." (or "with email", "with SSO"), and the button points at it with `aria-describedby`. Every button keeps its size and label position. An id that isn't shown is ignored. Read it from your own cookie or local storage; the block never stores anything | | `onProvider(id)` | `Promise`. Needed for providers to show. Leave it pending for a redirect; resolve for a popup | | `onSso(domain)` | `Promise`. Its presence shows "Continue with SSO". Gets a bare lower-case domain | | `onSuccess(success)` | `{ method: "code", email }`, `{ method: "provider", provider, email? }` or `{ method: "sso", domain }`. Navigate here | | `onModeChange(mode)` | Shows "New to Acme? Create an account" (or "Already have an account? Sign in") | | `brand` | Logo or wordmark, centered at the top | | `legal` | Terms and privacy line at the foot. Links inside are styled | | `codeLength` | Digits in the code, default 6 | | `initialState` | Open on a given step: `{ step: "code", email }` after a reload, or any failure for a story or test | | `autoFocus` | Focus the first field on mount. Later steps always move focus | | `fullPage` | Fills the viewport's height on the canvas color, with safe-area padding | | `headingLevel` | 1 (default), 2 or 3 | All result types (`SendCodeResult`, `VerifyResult`, `ProviderResult`, `SsoResult`, `SignInSuccess`, `SignInProvider`, `SignInInitialState`) are exported. #### Customizing - **Copy.** The strings live in `sign-in.tsx`; change them in place. Keep each failure saying what to do next. - **Real provider logos.** Pass `mark` (an `` or ``, drawn at 16px in a 20px tile) once you're allowed to use the brand's mark. The default letter tile needs no license. - **Server messages.** `not-allowed` and `error` take a `message`; it replaces the default body, so your domain policy can speak for itself. - **Width.** `--sign-in-width` on the block (400px by default). - **Phone.** Below a 480px-wide container the card drops its surface and sits on the page, so the screen is the page. - **Magic links.** If your email also carries a link, open the page on `initialState={{ step: "done", success }}` after the link's own check, or just navigate. #### Accessibility - One `
    ` labelled by the step's heading. When the step changes, focus moves into its first field (or to the heading on success), so a screen reader lands where the work is. - The email field is `type="email"`, `autocomplete="email"`; the code boxes carry `autocomplete="one-time-code"`, so phones offer the code from the message. - Field and code errors are tied to their inputs with `aria-describedby` and read out politely. Alerts that arrive answer a press, so they open their height under the button rather than shifting anything above it. - Countdowns tick for the eye only. Screen readers hear the wait once ("about 5 minutes"), not every second; numbers are tabular so nothing shifts. - A status region says "We sent a 6-digit code to …" and "Signed in." - Every action is a real button or form submit; Enter works on each step. #### Before / after Before: an email field and a "Send magic link" button. A second press sends three emails, a typo'd address waits forever, a blocked domain says "Something went wrong", and a wrong code just shakes. After: ```tsx ``` Each of those cases has a screen that names what happened and the next step, with the right countdown when the answer is "wait". ### Team members `team-members` · · v0.2.2 · https://uipolish.com/docs/team-members The members section of a workspace's settings: everyone's role and last activity, invites by pasting a list of addresses with seat limits enforced, pending invites to resend or revoke, and guards so the last owner can't be removed. Optimistic role changes roll back with an inline error. The Members section of a workspace's settings page. Each person gets one row with their avatar, name, email, when they were last active, their role and a way to remove them. Above the list are the seat count and **Invite people**, which opens a dialog where you can paste a whole list of addresses. Pending invites sit under the list, where they can be resent or revoked. Role changes show at once and roll back with an inline error if the server says no. The section won't remove or demote the last owner. People who can't manage the team see the same list without the controls, along with a line saying why. It's a block: a whole section built from Avatar, Badge, Button, Select, SearchField, ChipGroup, Dialog, ConfirmDialog, Alert, EmptyState and Tooltip. It takes data and promise-returning callbacks and never fetches anything itself. ```tsx import { TeamMembers } from "@/components/ui/team-members/team-members"; { const created = await api.invites.create({ emails, role }); setInvites((list) => [...created, ...list]); }} onChangeRole={async (id, role) => { const updated = await api.members.update(id, { role }); setMembers((list) => list.map((m) => (m.id === id ? updated : m))); }} onRemove={async (id) => { await api.members.remove(id); if (id === session.userId) return router.push("/workspaces"); setMembers((list) => list.filter((m) => m.id !== id)); }} onResend={async (id) => { const invite = await api.invites.resend(id); setInvites((list) => list.map((i) => (i.id === id ? invite : i))); }} onRevoke={async (id) => { await api.invites.revoke(id); setInvites((list) => list.filter((i) => i.id !== id)); }} onUpgrade={() => router.push("/settings/billing")} /> ``` Callbacks that reject with an `Error` show its `message` where the action happened. A message that says what to do next is better than a code: "Acme couldn’t remove them just now. They still have access; try again." #### When to use it - The people page of a workspace, team, organization or project: anywhere there are roles, invites and a seat count. - A shared account where some people can manage the team and others can only look. #### When not to - Sharing a single document or page with people. Use a share dialog with an access list; this section manages the whole workspace. - Directories with hundreds of people, SCIM or groups. Use a Data Table with server-side search and pagination. This section shows every member in one list and only filters on the client. - Sign-up or accepting an invite. That's the invitee's screen, not this one. #### States | State | What shows | |---|---| | Loading (`loading`) | The header, then a card that holds the list's space, with one shimmering line: "Loading members…" | | Error (`error`) | The same card with "Couldn’t load members", your message, and **Try again** when `onRetry` is set | | Just you (one member) | Your row, then "It’s just you so far" with Invite people. You can't leave a workspace you're alone in. | | Many members (over `searchThreshold`, 8) | Search by name or email, a role filter with counts, and "3 of 14" | | No matches | "No one matches “zz”" with **Show everyone** | | Read-only (`canManage={false}`) | An info alert: "You can see who’s here, but not change it", naming the owners to ask. Roles show as text, there's no Invite or Remove, and invites have no actions. The person can still leave. | | Role saving | The new role shows straight away. After 300ms, "Saving…" shimmers where "last active" was. | | Role failed | The old role comes back, and the row opens a line: "Couldn’t make Theo a member. Theo is still an admin." with **Try again** and dismiss | | Removing | A confirm names the person and what happens. The button shows busy until the promise settles, and a rejection shows its message in the dialog. Afterwards the row fades and closes, and focus moves to the next person. | | Last owner | The owner role's other options are disabled with "Make someone else an owner first". If you're that owner, **Leave** is disabled and a note under the list says why. | | Above your role | An admin sees owners' roles as text, without Remove, and can't give the owner role ("Only owners can give this role"). | | Sensitive role change | Making someone an owner, or lowering your own role, asks first. | | Seats | "17 of 20 seats used · 3 left". With none left, the count turns warning-colored and **Get more seats** calls `onUpgrade`. | | Invite pending | "Member · Sent 2 days ago by Ines Duarte", with **Resend** and revoke (×) | | Invite expired | An **Expired** badge, "Expired 2 days ago", and **Renew** | | Just resent | **Sent** for `resendCooldown` seconds (60), then Resend again. New invites start cooled down too. | **The invite dialog.** | Input | What happens | |---|---| | Typing, then a comma, space or Enter | The address becomes a chip | | Pasting a list | Split on commas, semicolons, spaces and new lines. "Maya Lind <maya@x.com>" becomes the address alone. | | Duplicate in the list | Dropped, with "Skipped 1 address already in the list." | | Not an address, already a member, already invited | A red chip with a warning mark, and the reasons under the field: "chef@acme isn’t a full address." | | Backspace in an empty field | Pulls the last chip back into the field to edit | | More addresses than seats left | A warning alert, "5 invites, 3 seats left", with **Upgrade plan**. Sending is blocked with a message saying how many to remove. | | Sending | "Send 3 invites" becomes "Sending…" and the dialog can't be dismissed. A rejection keeps every address and shows the message. | | Closing with addresses in it | Asks whether to discard them | #### Props | Prop | Type | Default | | |---|---|---|---| | `members` | `TeamMember[]` | | `{ id, name, email, role, avatarUrl?, lastActiveAt? }`. A `lastActiveAt` of `null` reads "Hasn’t signed in". Rows show in the order you pass them. | | `invites` | `TeamInvite[]` | `[]` | `{ id, email, role, sentAt, expiresAt?, invitedBy? }` | | `currentUserId` | `string` | | Marks "You" and turns Remove into Leave | | `roles` | `TeamRole[]` | Owner, Admin, Member, Viewer | `{ value, label, description? }`, most powerful first | | `ownerRole` | `string` | first role | The role that must always have one person | | `defaultInviteRole` | `string` | `"member"` | | | `seats` | `{ used, total }` | | Leave out for unlimited plans. `used` should count pending invites. | | `canManage` | `boolean` | | | | `workspaceName` | `string` | | Used in sentences | | `title`, `description` | | "Members", "People who can plan suppers in …" | Change the description for your product | | `loading`, `error`, `onRetry` | | | First-load states | | `onInvite` | `(emails, role) => Promise` | | Leave out to hide Invite | | `onChangeRole` | `(id, role) => Promise` | | Leave out for read-only roles | | `onRemove` | `(id) => Promise` | | Also used when the current user leaves | | `onResend`, `onRevoke` | `(id) => Promise` | | | | `onUpgrade` | `() => void` | | Shown when seats run out | | `upgradeLabel` | `string` | "Upgrade plan" | | | `searchThreshold` | `number` | `8` | | | `resendCooldown` | `number` | `60` | Seconds | The section never changes your data. It shows optimistic roles while a change is saving, but `members` and `invites` must be updated by you. #### Customizing - **Roles.** Pass your own `roles`, most powerful first. Someone can change rows and hand out roles at their own rank or lower, which is how an admin can't touch owners. If your rules are different, such as admins promoting to owner, merge those roles or check on the server and reject with a message. - **Copy.** The description defaults to supper-planning words from the demo, so pass `description` with a line about your product. The confirm text says what removal does in Acme; check that it's true for yours ("Suppers and notes they made stay"). - **Seats.** If pending invites don't use a seat in your billing, don't count them in `used`. Then change the "Pending invites count as seats" line in `invite-dialog.tsx`. - **Invite expiry.** The dialog says invites last 7 days. Edit that sentence, and set `expiresAt` to match. - **Columns.** The row puts everything on one line once the section is 600px wide, measured on the section with a container query. Below that, the role and last active move under the name. Your own row's **Leave** is a word, not an icon, so on a phone it takes its own line under your role and last active, and your name and "You" keep the whole top line. `--team-role` and `--team-active` set the column widths. - **Edges.** The toolbar's search field and role filter are full-size fields, and the list's card takes the same outline (`--ui-edge`, the field edge color), so the controls and the box under them read as one set. Rows are split by the lighter divider (`--ui-border`); the role selects inside rows are quiet `sm` pills with no edge. Keep that if you restyle: one outline color for the boxes, one lighter color for the lines inside them. - **Sorting.** Rows show in the order you pass. Owners first, then by name, reads best. #### Layout The title sits `--ui-gap-block` above the list and the invites a full `--ui-gap-section` below it; rows are padded `--ui-pad-card` at the sides (16 narrow, 24 from 560px), so every edge moves together. An email address too long for a phone row breaks after its @, so the name and the domain each stay whole where they fit. #### Accessibility - The section is a `
    ` named by its heading. Each list is named by its heading. - Avatars are decorative, because the name is written beside them. "You" is real text in the row. - Each role select is named "Role for Maya Lindqvist". Disabled options keep their reason as their description, so it's read with them. - Icon buttons are named "Remove Theo Marchetti" and "Revoke invite to felix@…". A tooltip shows the same words on hover. - **Leave**, when blocked, uses `aria-disabled` and stays focusable. It's described by the note under the list that explains why. - A role-change, resend or revoke failure uses `role="alert"` in its row, with Try again next to it. Successes are announced once through a polite status region: "Maya Lindqvist is now an admin.", "Olu Bankole removed.", "2 invites sent as members." - Confirm dialogs start on Cancel. After a removal, focus moves to the next person's role or button, not the page. - The invite field is a real labelled input, described by the line under it, which lists the problems. Each red chip's accessible name includes its problem ("chef@acme, isn’t a full address"). Chip removal is announced and focus moves to a neighbour. - Search and the role filter update the "3 of 14" count, which is a polite live region. - Rows open and close by height. Under reduced motion they only fade. #### Before / after Before: a table with a role dropdown that saves silently, a delete button that just deletes, and an invite field that takes one address at a time. ```tsx {members.map((m) => ( {m.name} ))} ``` After: you can't remove the last owner, a failed change says so and puts the old role back, removal asks first and names the person, a pasted list of 20 addresses is checked before sending, and seats are counted before the server has to refuse. ```tsx ``` ### Voice session `voice-session` · · v0.1.6 · https://uipolish.com/docs/voice-session A live voice call with an AI agent: a calm visual of who's speaking, a state label that morphs in place, a transcript where the agent's words appear as they're said and lookups show inline, mute, device and captions controls, and a summary of what was decided when you hang up. Covers a blocked or missing mic, slow connections, network drops and agent errors. The screen for a live voice call with an agent. In the middle, a disc with five bars shows who has the floor: the text color for the agent, the accent for you. A voice lifts the bars smoothly; silence leaves five still dots. Under it, the agent's state in words (Connecting…, Listening, Thinking, Speaking, Muted, Reconnecting…) morphs in place without moving anything, with one line of what that means and, when there's one, a single action: Interrupt while it talks, Try again when it fails, Cancel when connecting drags. Beside it, the transcript: your words in a quiet tone while the recognizer may still change them, settling when they're final; the agent's words appearing as the voice reaches them; and its lookups inline ("Checking the guest list", then "Checked the guest list · 14 going"). At the foot: the microphone, Mute (M), Captions (C) and End call. Hanging up shows a summary: how long, what was decided, what happens next, and the transcript folded underneath. ```tsx ({ user: micMeter.level(), agent: voiceMeter.level() })} muted={muted} onMutedChange={(m) => { setMuted(m); micTrack.enabled = !m; }} captions={captions} onCaptionsChange={setCaptions} devices={mics} // from navigator.mediaDevices.enumerateDevices() deviceId={micId} onDeviceChange={(id) => call.switchMic(id)} transcript={call.transcript} startedAt={call.startedAt} endedAt={call.endedAt} error={call.error?.message} onInterrupt={() => call.cancelResponse()} onEnd={() => call.hangUp()} onRetry={() => (call.status === "error" ? call.retry() : call.requestMic())} summary={call.summary} summaryError={call.summaryError} onRetrySummary={() => call.summarize()} onCallAgain={() => call.start()} /> ``` It's controlled and fetches nothing: your realtime client (WebRTC, a WebSocket to a speech model, a telephony SDK) reports where the call is and what's been said, and the screen draws it. It fills its container's height; give it one. #### When to use it - A spoken conversation with an agent, in the browser: planning, support, intake, tutoring, a phone-style assistant. - When the person needs to see what was heard, what the agent looked up, and to cut in, mute or switch microphones mid-call. #### When not to - One spoken message, sent and then answered in text: that's `voice-input` in a composer. - A text chat that can also speak: use the Chat app template and add a voice button. - Calls between people. The turn-taking, interruption and lookups here are about an agent. #### States | `status` | Stage | Transcript | Controls | |---|---|---|---| | `requesting` | Still dots, "Allow microphone", "Your browser is asking to use the microphone. Choose Allow." | "The transcript starts when the call connects." | Mic, Mute, Captions, End call | | `connecting` | One slow wave crossing the dots, "Connecting…". After `slowAfter` (6s): "This is taking longer than usual…" and Cancel, which calls `onEnd` | As above | As above | | `live`, listening | Dots at rest; your voice lifts them in the accent and "You" shows above "Listening" | Your words, quiet until `final` | | | `live`, thinking | The wave in the text color, "Thinking"; the line under it names the lookup that's running | The lookup, with a turning arc in its icon slot | | | `live`, speaking | Bars in the text color following the agent's voice, "Speaking", "Start talking to interrupt.", Interrupt | Words appear as `spoken` reaches them | | | `live`, muted | "Muted", "Copper can’t hear you. Unmute to talk." Your voice no longer moves the bars | | Mute turns red and reads Unmute | | Barge-in | The moment your voice rises over the agent's, the disc turns to you; when you mark its line `interrupted`, the line ends with an "Interrupted" tag and the stage says "Copper stopped so you can talk." for a few seconds | | | | `reconnecting` | The wave in amber, "Reconnecting…", "Your connection dropped. The call picks up where it left off." The dot by the timer breathes amber; the timer keeps counting | Holds | All stay; Mute still works | | Back to `live` | "Reconnected. Nothing was lost." in green for a few seconds | | | | `error` | Still red dots, "Not responding", `error`, Try again (`onRetry`) | Holds | All stay | | `denied` | Replaces stage and transcript: "Microphone blocked", steps for Chrome, Safari, Firefox or Edge (the one in use is picked; the others a tap away), where the OS setting lives, Try again and Not now | | Hidden | | `no-mic` | "No microphone found", what to check, Try again and Not now | | Hidden | | `ended` | The summary: "Call ended", the duration, Decided, Follow-ups with whose they are, Call again and Copy summary, and the transcript folded underneath. Without `summary`: "Writing up the call…" shimmering (after 300ms) in space held for it. With `summaryError`: the error and Try again | | Hidden; focus moves to "Call ended" | Nothing on the stage moves between states: the disc, the speaker's name, the label (every label held in one cell), two lines for the hint and the action's 40px slot are always there. The timer sits over "88:88" in mono, so the seconds never nudge the header. #### Props | Prop | Type | Default | | |---|---|---|---| | `agentName` | `string` | `"Assistant"` | Names the call, the speaker and the copy. | | `agentImage` | `string` | | Its picture; else its initial. | | `topic` | `string` | | Under the name, and in the summary. Wraps. | | `status` | `VoiceSessionStatus` | | See States. | | `activity` | `"listening" \| "thinking" \| "speaking"` | `"listening"` | While `live`. | | `levels` | `VoiceLevels \| () => VoiceLevels` | | `{ user, agent }`, 0 to 1. A function is read once a frame without re-rendering. | | `muted`, `onMutedChange` | `boolean`, `(muted) => void` | `false` | Mute shows when `onMutedChange` is passed. M toggles it. | | `captions`, `onCaptionsChange` | `boolean`, `(on) => void` | `true` | Shows or folds the transcript. C toggles it. | | `devices` | `VoiceDevice[]` | `[]` | `{ id, label, detail? }`. The picker shows with at least one. | | `deviceId`, `onDeviceChange` | `string`, `(id) => void` | first device | | | `transcript` | `VoiceTranscriptItem[]` | | See Transcript. | | `startedAt`, `endedAt` | `number` | | `Date.now()` at connect and hang-up. Drive the timer and the summary's duration. | | `error` | `string` | | `error`: what happened and what it means for the call. | | `slowAfter` | `number` | `6000` | Ms of `connecting` before it says so. | | `browser` | `"chrome" \| "safari" \| "firefox" \| "edge"` | detected | Which steps show first when blocked. | | `onInterrupt` | `() => void` | | Stop the agent's reply. | | `onEnd` | `() => void` | | End call; also Cancel while connecting and Not now. | | `onRetry` | `() => void` | | Ask for the mic again, or retry after `error`. | | `summary` | `VoiceSummary` | | `{ decisions: string[], followUps: { id, text, owner?: "agent" \| "user", due? }[] }`. | | `summaryError`, `onRetrySummary` | `string`, `() => void` | | | | `onCallAgain` | `() => void` | | | | `className` | `string` | | | `formatCallTime(ms)` is exported too: 42000 gives "0:42". #### Transcript | `role` | Fields | Shows | |---|---|---| | `user` | `text`, `final?`, `at?` | Your words. `final: false` sets them in the subtle tone while the recognizer may revise them ("make the gallery"); `final: true` settles them into the text color as they're corrected ("make the galette"). | | `agent` | `text`, `spoken?`, `interrupted?`, `at?` | Pass the whole reply and how many characters the voice has reached (from your TTS's word or character timings). Each word fades in as it's said. `interrupted` ends the line with an "Interrupted" tag; what was never said stays unsaid. | | `tool` | `title`, `status`, `detail?`, `at?` | A lookup inline, in a quiet row: a turning arc while `running`, a check or a red "!" after. The title morphs from "Checking…" to "Checked…". | A name heads each change of speaker, with `at` (ms from the start) as "0:42". The panel follows the newest line until the reader scrolls up, then offers "Latest". #### Levels The visual never invents movement. It eases toward whatever `levels` says, rising over `--ui-dur-instant` and falling over `--ui-dur-panel`, so it can't jitter, and each bar sways on its own slow sine so a steady voice still looks spoken. Silence is still. Feed it RMS from an `AnalyserNode` on each side: ```ts function meter(stream: MediaStream) { const ctx = new AudioContext(); const analyser = ctx.createAnalyser(); ctx.createMediaStreamSource(stream).connect(analyser); const buf = new Float32Array(analyser.fftSize); return () => { analyser.getFloatTimeDomainData(buf); const rms = Math.sqrt(buf.reduce((s, v) => s + v * v, 0) / buf.length); return Math.min(1, Math.max(0, (20 * Math.log10(rms || 1e-8) + 55) / 45)); // -55…-10 dBFS }; } ``` Who the disc shows: you, while your level is up (held 400ms across gaps between words, and ignored while muted); else the agent, while `activity` is `speaking`. You win a tie, so talking over the agent turns the disc to you at once. Stopping the agent is yours to do (`onInterrupt` or your client's own voice detection); mark its line `interrupted` and set `activity` back to `listening`. #### Customizing - **The agent's color** is `--vs-agent` (and `--vs-agent-text`) on the root, the text color by default. Set it to your brand's hue; yours is `--vs-user`, the accent. - **The transcript's width** is `--vs-side` (380px). Under 760px wide the transcript goes under the stage; under 560px Mute and Captions fold to icon capsules and the microphone takes its own row. - **Copy.** The labels are short on purpose; the hint line under them is where your product's voice goes. The denied steps name each browser's real menus; check them against the browsers you support. - **The summary** is whatever your agent writes after the call. Keep decisions to a line each and give follow-ups an owner, so the person can see what's theirs. #### Accessibility - The block is a `section` named "Call with Copper". A polite `role="status"` region speaks milestones only: connecting, connected, muted and unmuted, captions on and off, the agent stopping, the line dropping and coming back, an error, and the call's length when it ends. Never the levels or each word: the person can hear the call. - The transcript is a `role="log"` with `aria-live="off"`, so it doesn't talk over the agent; it's focusable and scrolls from the keyboard. Each line has its speaker in words, interim lines say "(still listening)", a speaking line is `aria-busy`, and lookups say "In progress", "Done" or "Failed". - M and C work anywhere on the page except in a field, menu or picker; both buttons carry `aria-keyshortcuts` and show the key in their tooltip. Mute names its action ("Mute", "Unmute"); Captions is a toggle with `aria-pressed`. - When the action under the label goes (Interrupt after you interrupt), focus that was on it moves to Mute. After hanging up, focus moves to "Call ended". - Who's speaking and every state are in words; color only repeats them. - With reduced motion the bars don't move: they stay dots and brighten with the voice; the waiting wave becomes the dots breathing together; the disc doesn't swell; lines fade in without rising. #### Before / after Before: a pulsing circle and a hang-up button. Nobody can tell whether the agent heard, what it's doing, or why it went quiet. ```tsx
    ``` After: who's talking, what the agent is doing in words, what was heard and said, what it looked up, every failure with a way out, and what the call came to. ```tsx ``` ### Agent settings `agent-settings` · agent kit · v0.1.4 · https://uipolish.com/docs/agent-settings A whole settings screen for an AI agent, built from the kit: model, MCP connections, what it may do without asking, a daily spending limit and memory. Every change saves in its row, and a sticky section nav follows you down the page. A whole settings screen for an AI agent inside a product, built from the kit: which model it uses, the MCP servers it's connected to, what it may do without asking, how much it may spend in a day and what it remembers. Each section is a heading and a few rows, split by hairlines: a label and one line on the left, the control on the right, stacked on a phone. On a wide screen a column of section links stays in view and marks where you are. Every change saves on its own, and the row it was made in says "Saved" for a moment. There's no Save button and no bar at the bottom. Revoking a tool leaves "Revoked · Undo" in its place for a few seconds; clearing memory asks first, in place. ```tsx saveSettings(patch)} models={models} connections={{ servers, onToggle, onReconnect, onRetry, onDisconnect }} allowed={alwaysAllowed} onRevoke={(tool) => revokeTool(tool.id)} onRestore={(tool) => allowTool(tool.id)} spending={{ used: 3.4, segments }} memories={notes} onClearMemory={() => clearMemory()} /> ``` #### When to use it - The settings page of a product with an agent in it: a chat app, an assistant inside a booking or billing tool, an agent that acts for a team. - As a starting point you cut down. Drop the sections your agent doesn't have and keep the rest as they are. #### When not to - Settings for one conversation (its model, its tools this time). Those belong in the composer, as a model picker or a menu. - A first-run setup that walks someone through connecting servers and setting a budget in order. Use steps, with a Continue button. - Settings that only apply together, like a billing address. Use a form with one Save button. #### Sections | Section | Rows | Built from | |---|---|---| | Model | "Model": for new conversations; ones already started keep theirs | Model picker | | Connections | The MCP servers, as they are | Connections | | Permissions | "Always allowed": the tools the person chose Always allow for, grouped by server, each with Revoke. "Everything else": Reads, Changes, Sends, Deletes and Spends money, each "Ask every time" or "Allow" | Badge, button, segmented control | | Usage & budget | What was spent today against the limit, then "Daily limit" with what happens at it: "Copper stops and asks when it reaches $10.00 today." | Usage meter's `UsageBreakdown`, number input | | Memory | "Remember between conversations", then the notes and Clear memory | Switch, button | **Sends, deletes and spends always ask.** Their "Allow" can't be picked, and the line under each says why in words: "Money moves the moment a payment or refund runs, so Copper always asks." A message can't be unsent, a deleted menu doesn't come back and a refund can't be reversed, so a standing yes for any of them is one bad call away from harm. The type says the same: `policy` only has `read` and `change`. This matches tool approval, which only offers "this conversation" for those effects. **The daily limit** is a currency field without steppers, from 1 to `maxDailyLimit` (default 500). Its line always says what the limit does: "Copper stops and asks when it reaches $10.00 today.", and once it's reached, "Copper reached $10.00 today and is waiting for you. Raise the limit to let it carry on." The breakdown above it takes the new limit at once. **Memory** lists the notes as they are, newest first. Clear memory turns into "Clear everything Copper remembers? Copper forgets these 5 notes for good. Your conversations stay." with Cancel and a danger Clear memory. With remembering off, the notes are kept and the line says so. #### Props Everything is controlled. `settings` holds the values the person sets; lists the person acts on (tools, notes) come in their own props with their own callbacks. | Prop | What it does | |---|---| | `agent` | The agent's name, used in sentences: "Copper asks every time" | | `settings` | `{ model?, policy?, dailyLimit?, remember? }`. `policy` is `{ read, change }`, each `"ask"` or `"allow"` | | `onChange` | Called with the one value that changed: `{ model: "deep" }`, `{ policy: { read: "allow", change: "ask" } }` (the whole policy), `{ dailyLimit: 25 }`, `{ remember: false }`. Merge it into `settings` | | `models` | `ModelOption[]`, as the model picker takes them | | `connections` | The Connections props, passed on as they are: `servers`, `add`, `onAdd`, `onToggle`, `onReconnect`, `onRetry`, `onDisconnect`. `agent` comes from here | | `allowed` | `{ id, server, tool, effect, description? }[]`: the tools set to Always allow. `server` is the name the rows are grouped under | | `onRevoke`, `onRestore` | Revoke: take the tool out of `allowed`. Restore is Undo: put it back where it was. Leave `onRestore` out to offer no Undo | | `effectNotes` | Your own line under Reads, Changes, Sends, Deletes and Spends money | | `spending` | `{ used, segments? }` for today, in currency units. Segments as in the usage meter | | `maxDailyLimit`, `currency`, `locale` | Default 500, `"USD"`, `"en-US"` | | `memories`, `onClearMemory` | `{ id, text }[]`, newest first. Clear memory clears them all; then pass `[]`. Leave `onClearMemory` out to hide the button | | `sections` | Which sections, in what order. Default all five | | `title`, `description` | Default "Copper settings", from `agent`; a line under it | | `headingLevel` | The title's level, default 1. Sections take the next level, the Permissions parts the one after | | `idPrefix` | Starts each section's id: `#agent-settings-permissions`. Default `"agent-settings"` | **Saving.** Each callback can return a promise. While it's pending past 300ms the row says "Saving…" with a light band crossing it; when it resolves, "Saved" with a check, for 2.4 seconds; when it rejects, the rejection's message in the danger color, until the row changes again. Put the old value back in `settings` yourself when a save fails: the row shows what `settings` says. ```tsx async function onChange(patch: Partial) { const before = settings; setSettings((s) => ({ ...s, ...patch })); // the control moves at once try { await api.patch("/agent/settings", patch); } catch (error) { setSettings(before); throw error; // its message shows in the row: "Ledger caps Copper at $200.00 a day." } } ``` A save that rejects says the same in a polite announcement. Only the latest change to a row speaks for it, so a slow first save can't mark a later one as failed. **Revoking.** Revoke calls `onRevoke(tool)` and the row turns to "Revoked · Undo" at once, with the tool name and badge dimmed and "Copper asks before using it again." under it. Remove the tool from `allowed` then; the row stays on screen by itself. Six seconds after the pointer and focus have left it, the row fades and closes, and a server's group closes with its last tool. Undo calls `onRestore(tool)`. If `onRevoke` rejects, the row comes back with the message under it. **Dropping sections.** A section shows when its values are there, so leaving props out drops it: | Section | Shows with | |---|---| | Model | `settings.model` and `models` | | Connections | `connections` | | Permissions | `settings.policy` or `allowed`; each part shows on its own | | Usage & budget | `settings.dailyLimit` or `spending`; the breakdown needs both | | Memory | `settings.remember` or `memories`; each row shows on its own | Or name them: `sections={["model", "permissions", "usage"]}` shows those three in that order. With one section left, the nav goes too. #### Layout | Width of the screen | Nav | Rows | |---|---|---| | 640px and up | A 144px column beside the sections, sticky 32px from the top, marking the last section whose top is within 96px of where the nav sticks (the last one at the end of the page) | Label and line on the left, control on the right, 24px apart | | Under 640px | Plain links in a wrapping row above the sections; none is marked | As above while the sections' column is over 440px | | Sections' column 440px and under | | The control drops under its text, 12px below | The breakpoints are container queries on the screen, not the window, so it lays out the same in a page, a panel or a dialog. Set `--agent-settings-top` on the screen to your sticky header's height plus 24px: the nav sticks there and a jumped-to section lands there. Title (display size), 32px to the sections; 64px between sections; heading and its line 4px apart, 24px above the first row; 16px above and below each hairline. The Connections list keeps its own card, the one surface on the screen. #### Motion | What | Duration | Easing | |---|---|---| | "Saved", "Saving…" and a failure fade in beside the label | `--ui-dur-fast` (160ms); "Saving…" waits 300ms first | `--ui-ease-enter` | | They fade out | `--ui-dur-fast` | `--ui-ease-exit` | | Light band across "Saving…" | `--ui-dur-sweep` per pass | `linear` | | "Revoked · Undo" arrives; name and badge dim | `--ui-dur-fast`; `--ui-dur-instant` | `--ui-ease-enter`; `--ui-ease-color` | | A revoked row, or a server's group, fades, then its space closes | `--ui-dur-instant` fade, then `--ui-dur-base` (200ms) height | `--ui-ease-exit`, `--ui-ease-collapse` | | The clear-memory confirm arrives: fade and 4px rise; the box eases to its new height | `--ui-dur-fast`, animated size | `--ui-ease-enter` | | Current section link tints | `--ui-dur-instant` | `--ui-ease-color` | | Model picker, segmented control, switch, breakdown, Connections | as each component's guide says | | Under `prefers-reduced-motion: reduce` the light band stops, a revoked row only fades and is gone without closing, and the confirm fades in without rising. The embedded components follow their own reduced-motion rules. #### Accessibility - One heading at `headingLevel` (default `h1`), a heading one level down for each section and one more for "Always allowed" and "Everything else". - Each section is a `
    ` named by its heading, so it's a region landmark. The nav is a `