Skip to content

Approval card

Human in the loop: a proposed action with its fields, Approve, Edit and Skip.

Status
Beta
Category
AI
Adoption
Not used yet
import { ApprovalCard } from "@oration/canon/components/ai/approval-card";
packages/canon/src/components/ai/approval-card.tsx

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:

Update remittance details for Northwind Freight

From Nora's call with Aisha Bello at 9:12 AM today.

Remittance email
Before: ap@northwindfreight.exampleAfter: remit@northwindfreight.example
Remittance format
Before: PDFAfter: PDF and CSV
Updates 1 supplier record
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

In a stack of proposals, only the focused one shows a filled Approve. Every other card passes emphasis="quiet" and its Approve is outline. When the focused card resolves, the next pending card takes the fill.

The Tint Well Rule

Fields sit in a 70% Well Gray well with 10px corners inside the card, not in a nested bordered card. Custom content (an email preview, a plan) follows the same rule.

Every mutation can be undone

Approve and Skip both collapse to a receipt with Undo, and focus moves to Undo so keyboard users can reverse straight away.

The Label-Beside-Color Rule

Receipts say Approved or Skipped in words beside the green check or the slashed circle.

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
Sends 1 email
ApprovedSend remittance to Halcyon
Approved: Send remittance to Halcyon
  1. Icon tile. Optional 28px Well Gray tile with a 16px Slate Meta icon for the kind of effect: mail, ticket, record. Never indigo.
  2. Title. An h3 at 14px, weight 600: the action, verb first.
  3. Description. Optional 13px Slate Meta: why the change is proposed.
  4. 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.
  5. Footnote. Optional 12px Slate Meta at the start of the footer: the scope, such as Updates 1 supplier record.
  6. Skip. Ghost, small. Shown when onSkip is passed.
  7. Edit. Outline, small. Shown when onEdit is passed.
  8. Approve. Small and last. Filled at emphasis="primary", outline at quiet. Its label is approveLabel.
  9. 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
Updates 1 supplier record
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.

Priority
Who should own it?
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.

SubjectW-9 needed before Friday's payment

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.

Emails ap@orchardstreet.example
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.

Sends 1 email

Add a note to Orchard Street's supplier record

So Nora explains the W-9 hold on the next call.

Updates 1 supplier record

Open a ticket for Halcyon's short payment

Assigned to Priya Raman, normal priority.

Creates 1 ticket
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#

Pending, primary

Add a note to Orchard Street's record

So Nora explains the W-9 hold on the next call.

Pending, quiet

Add a note to Orchard Street's record

So Nora explains the W-9 hold on the next call.

Approved
ApprovedAdd a note to Orchard Street's record
Approved: Add a note to Orchard Street's record
Skipped
SkippedAdd a note to Orchard Street's record
Skipped: Add a note to Orchard Street's record
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>    );}
States
StateTreatment
Pending, primaryThe full proposal with a filled Approve.
Pending, quietThe same proposal with an outline Approve, for every card but the focused one.
Option selectedA chosen question option takes a 6% indigo tint with a 22% indigo inset ring. Others fill Well Gray on hover.
InvalidApprove with an unanswered question shows Choose an option to continue. in red under it and focuses its first option. Nothing is approved.
ApprovedCollapses to a receipt with a Ledger Green check and Approved.
SkippedCollapses to a receipt with a Faint Slate slashed circle and Skipped.
UndoneUndo 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 onSkip and moves to skipped. Undo calls onUndo and 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). Pass status to control it, which Copilot does so a receipt survives re-renders. A controlled card only moves when you update status in 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 defaultValue and are read once, on mount.

Do and don't#

Resend remittance to Northwind Freight

Add a note to Orchard Street's record

Do. In a stack, fill only the focused proposal's Approve and keep the rest quiet.

Resend remittance to Northwind Freight

Add a note to Orchard Street's record

Don't. Give every card in the stack a filled Approve. The eye can't find the next decision.

Send remittance for PMT-58190 to Halcyon

Emails ap@halcyon.example
Do. Title the card with the action and label Approve with the effect: Send remittance email, Create ticket.

Are you sure?

Copilot wants to take an action.

Don't. Ask Are you sure? and confirm with OK. People approve what they can read, and the label is the last thing they read.
Do. Show the fields that change, old value struck through and new value beside it.
Don't. Describe the change in a paragraph. A changed bank account or email hides in prose.

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 fieldset with a legend. 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).
Keyboard interactions
KeysAction
TabMoves through options, Skip, Edit and Approve.
←→Moves between a question's options and selects.
SpaceSelects the focused option, or presses the focused button.
EnterPresses the focused button.

Design tokens#

Design tokens
TokenUsed for
--cardCard surface
shadow-borderHairline lift
--mutedIcon tile, field well at 70%, option hover
--muted-foregroundIcon, description, field labels, old values, footnote
--primaryFilled Approve; selected option tint at 6% and ring at 22%
--inputOption chip stroke
--successApproved check
--subtle-foregroundSkipped icon, the before-to-after arrow
--destructiveQuestion error text
--radius-xl12px card corners
spring.moderateHeight 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..

Props of ApprovalCard
PropTypeDefaultDescription
titleRequiredstringNo defaultThe action, verb first. Also shown in the receipt.
descriptionReact.ReactNodeNo defaultWhy it's proposed.
fields{ label: string; before?: React.ReactNode; after: React.ReactNode }[]No defaultRendered as a stacked diff in a tint well.
questions{ id: string; label: string; options: string[]; defaultValue?: string }[]No defaultSingle-choice questions that must be answered before Approve.
childrenReact.ReactNodeNo defaultCustom content after the fields, e.g. an email preview.
iconReact.ReactNodeNo defaultShown in a 28px tile.
footnoteReact.ReactNodeNo defaultQuiet scope note in the footer.
onApprove(answers: Record<string, string>) => voidNo defaultCalled after questions validate.
onEdit() => voidNo defaultShows Edit. Doesn't change status.
onSkip() => voidNo defaultShows Skip.
onUndo() => voidNo defaultCalled by the receipt's Undo.
status"pending" | "approved" | "skipped"No defaultControlled status.
defaultStatus"pending" | "approved" | "skipped""pending"Initial status when uncontrolled.
approveLabelstring"Approve"Name the effect.
emphasis"primary" | "quiet""primary"quiet makes Approve outline, for every card but the focused one in a stack.
classNamestringNo defaultMerged 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.