Skip to main content

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

Loading example

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 becomes labels.confirm, "Confirm"); the second approves. Escape, blur or five seconds disarm it.
  • type: a field asks for confirmationPhrase ("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, for holdMs (1.2s); a progress bar shows the hold.
Loading example

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.

Loading example

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

PropTypeDefaultDescription
titlerequiredReactNode
descriptionReactNode
previewReactNodeThe 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'
confirmationPhrasestring'APPROVE'The phrase for confirm="type"
holdMsnumber1200Milliseconds for confirm="hold"
onApproverequired() => void | Promise<unknown>A promise keeps the card busy until it settles
onRejectrequired() => void | Promise<unknown>Same
onEdit() => voidAdds an Edit button
busybooleanfalse
decision'undecided' | 'approved' | 'rejected' | 'expired''undecided'The outcome: badge + locked buttons
expiresAtDate | number | stringPast it the card shows as expired
labelsPartial<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
classNamestring