ApprovalCard
Writes always need a human. ApprovalCard is the moment of consent: what
the assistant wants to do, how risky it is, a preview of the change, and
two buttons. The card is a focusable role="group" labelled by its title,
so a keyboard user can Tab to it and press Enter to approve or Escape to
reject.
import ApprovalCard from '@zuilib/ai/approval-card'
Basic
risk="high" gives the approve button the danger tone and asks for a second
activation (see Confirmation). The badge, the border tint
and the button colours all come from the success / warning /
danger tokens.
Confirmation
confirm sets what it takes to approve. high risk defaults to
double; low and medium default to none. A single Enter never
approves a high-risk card.
double: the first activation arms the button (its label becomeslabels.confirm, "Confirm"); the second approves. Escape, blur or five seconds disarm it.type: a field asks forconfirmationPhrase("APPROVE"); Approve is disabled until the field matches, and Enter in the field approves.hold: Approve fires only after the button was held, with the pointer or Space, forholdMs(1.2s); a progress bar shows the hold.
Busy
While the approved action runs, pass busy: the approve button spins,
the others lock, and the keys are ignored. onApprove (and onReject)
may also return a promise; the card then holds itself busy until it
settles, whether it resolves or rejects, and labels.busy is what the
spinner announces.
Decided and expired
Pass decision="approved" | "rejected" once the human has answered: the
card adds an outcome badge next to the risk badge, locks its buttons and
the keys, and carries data-decision. expiresAt (a Date, epoch ms or
ISO string) marks when the proposal stops being actionable; once it is
past the card shows as expired the same way, so a stale proposal from a
model cannot be approved an hour later. The labels keys approved,
rejected and expired translate the three outcome labels.
Keyboard
With focus anywhere inside the card, Enter activates approve (through the
confirm step) and Escape calls onReject, with two exceptions: a text
field (input, textarea, select, contenteditable) and a nested popup
(dialog, menu, listbox, combobox) keep both keys; a button keeps
Enter for itself, and Escape still rejects. A rejecting Escape is
preventDefaulted and stopped, so an enclosing dialog does not close on
it. The card is described by its description and by a visually hidden
hint that states the keys for the current confirm mode.
Below sm (640px) the buttons stack full width, and on a coarse pointer
each has a 44px target.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | |
description | ReactNode | — | |
preview | ReactNode | — | The proposal: a draft, a diff, a table |
risk | 'low' | 'medium' | 'high' | 'low' | high: danger-tone approve button, confirm defaults to double |
confirm | 'none' | 'double' | 'type' | 'hold' | 'double' for high, else 'none' | |
confirmationPhrase | string | 'APPROVE' | The phrase for confirm="type" |
holdMs | number | 1200 | Milliseconds for confirm="hold" |
onApproverequired | () => void | Promise<unknown> | — | A promise keeps the card busy until it settles |
onRejectrequired | () => void | Promise<unknown> | — | Same |
onEdit | () => void | — | Adds an Edit button |
busy | boolean | false | |
decision | 'undecided' | 'approved' | 'rejected' | 'expired' | 'undecided' | The outcome: badge + locked buttons |
expiresAt | Date | number | string | — | Past it the card shows as expired |
labels | Partial<ApprovalCardLabels> | — | Every text on the card, one key at a time: approve, reject, edit, confirm (the armed double-confirm button), busy (announced with the spinner), riskLow/riskMedium/riskHigh, approved/rejected/expired |
className | string | — |