Approval card
Human in the loop: a proposed action with its fields, Approve, Edit and Skip.
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { AssistantMessage } from "@oration/canon/components/ai/message";import { toast } from "@oration/canon/components/toast";import { Building2Icon } from "lucide-react";export function Hero() { return ( <div className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border"> <AssistantMessage> <div className="flex flex-col gap-3"> <p> Aisha asked on today's call to send Northwind Freight's remittances to their new AP inbox, with a CSV alongside the PDF. Here's the change: </p> <ApprovalCard icon={<Building2Icon />} title="Update remittance details for Northwind Freight" description="From Nora's call with Aisha Bello at 9:12 AM today." fields={[ { label: "Remittance email", before: "ap@northwindfreight.example", after: "remit@northwindfreight.example", }, { label: "Remittance format", before: "PDF", after: "PDF and CSV", }, ]} footnote="Updates 1 supplier record" approveLabel="Update supplier" onApprove={() => toast.add({ type: "success", title: "Northwind Freight updated", description: "The next remittance goes to remit@northwindfreight.example.", }) } onEdit={() => toast.add({ title: "Draft moved to the composer", description: "Describe the change you want instead.", }) } onSkip={() => undefined} onUndo={() => toast.add({ title: "Change undone" })} /> </div> </AssistantMessage> </div> );}Usage#
Approval card is how Copilot and agents act in Oration: every change they propose (update a supplier record, send a remittance email, open a ticket) arrives as a card with what will change, any question it needs answered, and Approve, Edit and Skip. Nothing happens until a person approves, and once they do the card collapses to a one-line receipt with Undo. In a stack of proposals only the focused one carries the filled button; the rest are emphasis="quiet". The common mistake is describing the change in prose instead of showing the fields, before and after.
When to use
- Whenever Copilot or an agent wants to change data or contact someone: update a record, send an email, reassign a ticket, create an agent.
- When the change has fields worth checking before it runs, such as a supplier's remittance email changing from one inbox to another.
- When the action needs one or two choices from a short list first, such as the ticket's priority and assignee.
- On the Home approvals column and inside Copilot answers, one card per proposed action.
When not to use
- For a suggestion the person may want to explore but that changes nothing by itself. Use Recommendation card
- For a destructive action a person starts themselves, such as deleting a supplier. Use Confirm dialog
- For showing a change that already happened. Use Diff
- For a multi-step plan the agent will carry out after one approval. Show the plan as a task list under the card. Use Task list
- For a form with more than a couple of questions or any free text. Use Form dialog
The One Filled Button Rule
emphasis="quiet" and its Approve is outline. When the focused card resolves, the next pending card takes the fill.The Tint Well Rule
Every mutation can be undone
The Label-Beside-Color Rule
Anatomy#
Send remittance to Halcyon
Aisha asked for the remittance on PMT-58190 again.
- To
- EmptyAfter: ap@halcyon.example
- Payment
- Before: Not sentAfter: PMT-58190, $18,240.00
- Icon tile. Optional 28px Well Gray tile with a 16px Slate Meta icon for the kind of effect: mail, ticket, record. Never indigo.
- Title. An
h3at 14px, weight 600: the action, verb first. - Description. Optional 13px Slate Meta: why the change is proposed.
- Field well. Each field's label in 12px Slate Meta, then the old value struck through, an arrow and the new value. No old value shows a dash read as Empty.
- Footnote. Optional 12px Slate Meta at the start of the footer: the scope, such as Updates 1 supplier record.
- Skip. Ghost, small. Shown when
onSkipis passed. - Edit. Outline, small. Shown when
onEditis passed. - Approve. Small and last. Filled at
emphasis="primary", outline atquiet. Its label isapproveLabel. - Receipt. After a decision: a 44px row with a status icon, Approved or Skipped, the title truncated, and a ghost Undo.
Examples#
Fields, before and after
Each field shows its old value struck through and the new one beside it. A field with no old value reads as empty.
Update payment terms for Halcyon
Halcyon's signed amendment from September 21 adds an early-payment discount.
- Payment terms
- Before: Net 30After: 2/10 net 30
- Early-payment discount
- EmptyAfter: 2% within 10 days
- Owner
- Before: Jordan LeeAfter: Priya Raman
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { Building2Icon } from "lucide-react";export function Fields() { return ( <div className="w-full max-w-md text-left"> <ApprovalCard icon={<Building2Icon />} title="Update payment terms for Halcyon" description="Halcyon's signed amendment from September 21 adds an early-payment discount." fields={[ { label: "Payment terms", before: "Net 30", after: "2/10 net 30", }, { label: "Early-payment discount", after: "2% within 10 days", }, { label: "Owner", before: "Jordan Lee", after: "Priya Raman", }, ]} footnote="Updates 1 supplier record" approveLabel="Update supplier" onApprove={() => toast.add({ type: "success", title: "Halcyon's terms updated", }) } onEdit={() => toast.add({ title: "Opened the supplier record to edit" }) } onSkip={() => undefined} /> </div> );}Questions
Single-choice questions must be answered before Approve. Try approving without picking an owner: the card asks, and focuses the question.
Open a ticket for Halcyon's short payment
INV-20877 was paid $760.00 short. Aisha Bello has called twice.
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { TicketPlusIcon } from "lucide-react";export function Questions() { return ( <div className="w-full max-w-md text-left"> <ApprovalCard icon={<TicketPlusIcon />} title="Open a ticket for Halcyon's short payment" description="INV-20877 was paid $760.00 short. Aisha Bello has called twice." questions={[ { id: "priority", label: "Priority", options: ["Low", "Normal", "High"], defaultValue: "Normal", }, { id: "assignee", label: "Who should own it?", options: [ "Priya Raman", "Tomás Ferreira", "Jordan Lee", ], }, ]} approveLabel="Create ticket" onApprove={(answers) => toast.add({ type: "success", title: "Ticket created", description: `${answers.priority} priority, assigned to ${answers.assignee}.`, }) } onSkip={() => undefined} /> </div> );}Custom content
Children render after the fields, for previews such as an email draft. Keep them in a tint well.
Email Orchard Street about the missing W-9
OS-4502 is held until a W-9 is on file. It's in Friday's payment run.
Hi Tomás, we're ready to pay OS-4502 on Friday, October 2, but we don't have a current W-9 for Orchard Street. Could you upload one at the link below? The payment goes out as soon as it's on file.
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { MailIcon } from "lucide-react";export function CustomContent() { return ( <div className="w-full max-w-md text-left"> <ApprovalCard icon={<MailIcon />} title="Email Orchard Street about the missing W-9" description="OS-4502 is held until a W-9 is on file. It's in Friday's payment run." footnote="Emails ap@orchardstreet.example" approveLabel="Send email" onApprove={() => toast.add({ type: "success", title: "Email sent to Orchard Street", }) } onEdit={() => toast.add({ title: "Draft opened in the email editor" }) } onSkip={() => undefined} > <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <div className="flex gap-2"> <span className="w-14 shrink-0 text-muted-foreground"> Subject </span> <span className="text-foreground"> W-9 needed before Friday's payment </span> </div> <p className="text-pretty text-foreground"> Hi Tomás, we're ready to pay OS-4502 on Friday, October 2, but we don't have a current W-9 for Orchard Street. Could you upload one at the link below? The payment goes out as soon as it's on file. </p> </div> </ApprovalCard> </div> );}A stack of proposals
Only the first pending proposal is primary. Approve or skip it and the fill moves to the next one; Undo brings it back.
Resend remittance for PMT-58213 to Northwind Freight
The first one went to the old AP inbox.
Add a note to Orchard Street's supplier record
So Nora explains the W-9 hold on the next call.
Open a ticket for Halcyon's short payment
Assigned to Priya Raman, normal priority.
import { ApprovalCard, type ApprovalStatus } from "@oration/canon/components/ai/approval-card";import { MailIcon, StickyNoteIcon, TicketPlusIcon } from "lucide-react";import * as React from "react";export function ProposalStack() { const proposals = [ { id: "remittance", icon: <MailIcon />, title: "Resend remittance for PMT-58213 to Northwind Freight", description: "The first one went to the old AP inbox.", footnote: "Sends 1 email", approveLabel: "Send", }, { id: "note", icon: <StickyNoteIcon />, title: "Add a note to Orchard Street's supplier record", description: "So Nora explains the W-9 hold on the next call.", footnote: "Updates 1 supplier record", approveLabel: "Add note", }, { id: "ticket", icon: <TicketPlusIcon />, title: "Open a ticket for Halcyon's short payment", description: "Assigned to Priya Raman, normal priority.", footnote: "Creates 1 ticket", approveLabel: "Create ticket", }, ]; const [statuses, setStatuses] = React.useState< Record<string, ApprovalStatus> >({}); const statusOf = (id: string) => statuses[id] ?? "pending"; const focused = proposals.find((p) => statusOf(p.id) === "pending")?.id; const set = (id: string, status: ApprovalStatus) => setStatuses((map) => ({ ...map, [id]: status })); return ( <div className="flex w-full max-w-lg flex-col gap-2 text-left"> {proposals.map((proposal) => ( <ApprovalCard key={proposal.id} icon={proposal.icon} title={proposal.title} description={proposal.description} footnote={proposal.footnote} approveLabel={proposal.approveLabel} emphasis={proposal.id === focused ? "primary" : "quiet"} status={statusOf(proposal.id)} onApprove={() => set(proposal.id, "approved")} onSkip={() => set(proposal.id, "skipped")} onUndo={() => set(proposal.id, "pending")} /> ))} </div> );}States#
Add a note to Orchard Street's record
So Nora explains the W-9 hold on the next call.
Add a note to Orchard Street's record
So Nora explains the W-9 hold on the next call.
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";import { toast } from "@oration/canon/components/toast";import { StickyNoteIcon } from "lucide-react";export function StatesMatrix() { const states = [ { label: "Pending, primary", emphasis: "primary" as const, status: "pending" as const, }, { label: "Pending, quiet", emphasis: "quiet" as const, status: "pending" as const, }, { label: "Approved", emphasis: "primary" as const, status: "approved" as const, }, { label: "Skipped", emphasis: "primary" as const, status: "skipped" as const, }, ]; return ( <div className="grid w-full gap-x-6 gap-y-5 text-left md:grid-cols-2"> {states.map((state) => ( <div key={state.label} className="flex min-w-0 flex-col gap-2"> <span className="text-xs text-muted-foreground"> {state.label} </span> <ApprovalCard icon={<StickyNoteIcon />} title="Add a note to Orchard Street's record" description="So Nora explains the W-9 hold on the next call." emphasis={state.emphasis} defaultStatus={state.status} approveLabel="Add note" onApprove={() => toast.add({ title: "Note added" })} onEdit={() => toast.add({ title: "Draft moved to the composer" }) } onSkip={() => undefined} /> </div> ))} </div> );}| State | Treatment |
|---|---|
| Pending, primary | The full proposal with a filled Approve. |
| Pending, quiet | The same proposal with an outline Approve, for every card but the focused one. |
| Option selected | A chosen question option takes a 6% indigo tint with a 22% indigo inset ring. Others fill Well Gray on hover. |
| Invalid | Approve with an unanswered question shows Choose an option to continue. in red under it and focuses its first option. Nothing is approved. |
| Approved | Collapses to a receipt with a Ledger Green check and Approved. |
| Skipped | Collapses to a receipt with a Faint Slate slashed circle and Skipped. |
| Undone | Undo returns the card to pending, with the answers kept, and focus returns to Approve. |
Behavior#
- Approve validates questions first. If one has no answer, it marks every unanswered one invalid and focuses the first; otherwise it calls
onApprove(answers)with{ [questionId]: option }and moves to approved. - Skip calls
onSkipand moves to skipped. Undo callsonUndoand moves back to pending. - Edit only calls
onEdit; the card stays pending. Copilot uses it to drop a draft into the composer so the person can describe the change they want. - Status is uncontrolled by default (
defaultStatus). Passstatusto control it, which Copilot does so a receipt survives re-renders. A controlled card only moves when you updatestatusin the callbacks. - The card springs between heights on
spring.moderate(160ms, no bounce) while the proposal and the receipt cross-fade, the receipt leaving in 120ms. - If focus was inside the card when it resolved, focus moves to Undo; after Undo, to Approve. Focus elsewhere on the page is left alone.
- A visually hidden
role="status"announces Approved: Update remittance email for Northwind Freight or Skipped: …. - Question defaults come from
defaultValueand are read once, on mount.
Do and don't#
Resend remittance to Northwind Freight
Add a note to Orchard Street's record
Resend remittance to Northwind Freight
Add a note to Orchard Street's record
Send remittance for PMT-58190 to Halcyon
Are you sure?
Copilot wants to take an action.
Content#
- Title: the action, verb first, naming the record: Update remittance email for Northwind Freight, Open a ticket for Halcyon's short payment.
- Description: why, from the evidence: Aisha asked on today's call to send remittances to the new AP inbox.
- Field labels match the record's attribute names: Remittance email, Payment terms, Assignee.
- Question labels are short questions or nouns: Priority, Who should own it? Options are one or two words each.
approveLabel: the effect, verb first, such as Update supplier, Send email, Create ticket. Default Approve.- Footnote: the scope in plain words, Updates 1 supplier record, Emails ap@halcyon.example.
Accessibility#
- The title is an
h3, so the card shows up in a screen reader's headings list inside a Copilot answer. - Each question is a
fieldsetwith alegend. Options are native radio inputs (visually hidden inside their labels), so arrow keys move between them and the group is announced as one. - An invalid question links its error with
aria-describedby, and Approve moves focus to the question's first option. - After a decision, focus moves to Undo when it was inside the card, and a hidden status line announces the outcome.
- Under reduced motion the app's motion config drops transforms, but the height spring and the cross-fade still run (see known gaps).
| Keys | Action |
|---|---|
| Tab | Moves through options, Skip, Edit and Approve. |
| ←→ | Moves between a question's options and selects. |
| Space | Selects the focused option, or presses the focused button. |
| Enter | Presses the focused button. |
Design tokens#
| Token | Used for |
|---|---|
--card | Card surface |
shadow-border | Hairline lift |
--muted | Icon tile, field well at 70%, option hover |
--muted-foreground | Icon, description, field labels, old values, footnote |
--primary | Filled Approve; selected option tint at 6% and ring at 22% |
--input | Option chip stroke |
--success | Approved check |
--subtle-foreground | Skipped icon, the before-to-after arrow |
--destructive | Question error text |
--radius-xl | 12px card corners |
spring.moderate | Height change and cross-fade |
API reference#
ApprovalCard
One proposed action. Also exported: the ApprovalStatus, ApprovalField and ApprovalQuestion types.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
titleRequired | string | No default | The action, verb first. Also shown in the receipt. |
description | React.ReactNode | No default | Why it's proposed. |
fields | { label: string; before?: React.ReactNode; after: React.ReactNode }[] | No default | Rendered as a stacked diff in a tint well. |
questions | { id: string; label: string; options: string[]; defaultValue?: string }[] | No default | Single-choice questions that must be answered before Approve. |
children | React.ReactNode | No default | Custom content after the fields, e.g. an email preview. |
icon | React.ReactNode | No default | Shown in a 28px tile. |
footnote | React.ReactNode | No default | Quiet scope note in the footer. |
onApprove | (answers: Record<string, string>) => void | No default | Called after questions validate. |
onEdit | () => void | No default | Shows Edit. Doesn't change status. |
onSkip | () => void | No default | Shows Skip. |
onUndo | () => void | No default | Called by the receipt's Undo. |
status | "pending" | "approved" | "skipped" | No default | Controlled status. |
defaultStatus | "pending" | "approved" | "skipped" | "pending" | Initial status when uncontrolled. |
approveLabel | string | "Approve" | Name the effect. |
emphasis | "primary" | "quiet" | "primary" | quiet makes Approve outline, for every card but the focused one in a stack. |
className | string | No default | Merged onto the card. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Under reduced motion the height spring and the cross-fade still run; DESIGN.md says disclosures snap. The app's MotionConfig only drops transforms.
There is no failed or running status. If the approved action fails (the ERP rejects the update), the card still reads Approved; Copilot shows progress in a task list below instead.
The receipt always shows Undo, even without onUndo. A controlled card without onUndo shows an Undo that does nothing.
Edit has no editing mode of its own. Every caller has to provide one; Copilot moves a sentence into the composer.
Questions only take a single choice from chips; there's no free-text answer.
The title is always an h3, whatever the surrounding heading levels.