Patterns

Destructive action

The playbook for anything a user can't take back. Delete, revoke, purge — dangerous controls get the solid critical color, an icon right next to it, and friction that matches what it destroys — a quick confirm for one record, a typed name for a whole scope, and an Undo toast instead of a dialog when nothing is really lost.

BetaDestructive action is Beta — the escalation ladder is settled (ConfirmDialog · TypeToConfirmDialog · undo toast); TypeToConfirmDialog is new and its prop surface may still move.

Principles

  • Critical color, never alone. A destructive control takes the solid critical fill — the metal name (rust) belongs to the proposed system — and per ADR-0017 the color always travels with an icon (octagon-alert, trash) and an explicit verb, so severity survives grayscale.
  • Never one-click. An irreversible action always confirms through a ConfirmDialog (an alertdialog). The one exception: skip the dialog when an undo window genuinely restores everything.
  • The cost is named. The confirm body says what is destroyed and what survives — never a bare "Are you sure?".
  • No danger zone. The record doctrine subsumes it: delete is a critical intent in the ActionBar beside the other intents, gated by its dialog — not a separate dread section at the bottom of the page.

The standard gate

One record, no take-backs: the critical intent in the bar pops a ConfirmDialog that tells you exactly what it costs. Cancel is the easy out; the confirm carries the solid critical fill (the rust metal is the proposed system's treatment).

Type to confirm

When it's bigger than one record — a bulk delete, a whole rulebook — the gate steps up: the confirm stays locked until you type the name of the thing you're deleting. Reading the name is the point; the button only unlocks on an exact match. This tier is TypeToConfirmDialog, ConfirmDialog's bigger sibling.

The undo exception

Archive, suppress, unassign — when the operation is fully recoverable, a dialog is the wrong friction. Act immediately and offer Undo in the toast; the window is the safeguard. Never offer undo as cover for actual data loss.

The escalation ladder

Friction is proportional to blast radius. Each tier adds to the one below; pick the lowest tier that honestly covers the loss.

tier 0 — staged
The commit doctrine itself: nothing autosaves, so most mistakes die at Discard before they ever need a gate.
tier 1 — confirm
Irreversible, one record: a critical ConfirmDialog that names the cost. The floor for any real deletion.
tier 2 — type to confirm
Irreversible at scope: bulk deletes, a whole rulebook, anything that fans out. The exact name is typed before the confirm unlocks.
the exception — undo
Recoverable operations skip the dialog: act now, toast with Undo. The contract is a real restore, not a fig leaf.

Placement

Where the destructive control sits is part of the safety.

in a record
Delete is a critical intent in the ActionBar, beside the other intents and gated by its dialog. There is no Danger Zone section; the commit doctrine subsumed it.
in a toolbar
Destructive bulk actions sit rightmost, separated from constructive controls by whitespace — and escalate to tier 2 when the selection fans out.
in a menu
Destructive items go last, below a divider, in the critical register with their glyph.

Accessibility

  • An alertdialog, by role. Both gates render role=alertdialog — focus is trapped, Escape backs out safely, and focus returns to the trigger (React Aria's wiring, never hand-rolled).
  • Severity is never colour alone. The critical color always pairs with an icon and an explicit verb (ADR-0017) — grayscale and color-blind users read the same danger.
  • Backing out is the easy path. Cancel is a plain button, Escape works, the scrim clicks away; only the confirm carries the critical weight.
  • Type-to-confirm stays honest. The field is a real labelled Input; paste is allowed deliberately (the friction is reading the name, not typing it), and the confirm is a real disabled→enabled button, never a hidden one. TypeToConfirmDialog wires all of this by construction.

API

The pattern's components: a critical Button intent in the ActionBar, ConfirmDialog as the tier-1 gate, TypeToConfirmDialog as the tier-2 gate, and the toast undo window. TypeToConfirmDialog is the one unit this pattern ships itself — its props:

isOpen · onOpenChange
Controlled open state — the same contract as Modal and ConfirmDialog.
title
The question — it names what's being destroyed.
children
The scope body: what is destroyed and what survives — never a bare "Are you sure?".
name
The exact text that unlocks the confirm — the name of the thing being destroyed. The match trims surrounding whitespace and is otherwise exact, and the typed value resets every time the dialog opens.
confirmLabel · cancelLabel
The footer buttons. Cancel stays the plain path out; only the confirm carries the critical register.
onConfirm
Fires only while the gate is unlocked. Closing afterwards is the caller's job.
isPending
Locks the field and both buttons while the operation runs.
confirmIcon
Overrides the trash glyph on the confirm button.

The instruction line ("Type … to confirm.") and the irreversibility statement ship inside the component as chrome strings (EN and ES) — the safety copy can't be forgotten or paraphrased away.

Usage

Tier 1 — a critical intent and its gatetsx
import { ActionBar, ConfirmDialog } from '@halo-compliance/ui'
import { Check, Trash2 } from 'lucide-react'

<ActionBar
  dirty={dirty}
  intents={[
    { id: 'discard', label: 'Discard', tone: 'ghost',
      disabled: !dirty, onPress: form.reset },
    { id: 'delete', label: 'Delete report', tone: 'critical',
      icon: <Trash2 />, onPress: () => setConfirmOpen(true) },
    { id: 'save', label: 'Save changes', tone: 'primary',
      icon: <Check />, disabled: !dirty, onPress: form.handleSubmit },
  ]}
/>

<ConfirmDialog
  isOpen={confirmOpen}
  onOpenChange={setConfirmOpen}
  tone="critical"
  title="Delete this report?"
  confirmLabel="Delete report"
  cancelLabel="Cancel"
  onConfirm={deleteReport}
>
  This permanently removes the report and its findings. It can't be undone.
</ConfirmDialog>
Tier 2 — TypeToConfirmDialogtsx
import { TypeToConfirmDialog } from '@halo-compliance/ui'

<TypeToConfirmDialog
  isOpen={open}
  onOpenChange={setOpen}
  title={`Delete the ${rulebook.name} rulebook?`}
  name={rulebook.name} // the exact match that unlocks the confirm
  confirmLabel="Delete rulebook"
  cancelLabel="Cancel"
  isPending={deletion.isPending}
  onConfirm={deleteRulebook}
>
  This deletes the rulebook and its 38 rules from every channel it
  governs. Evaluations already recorded keep their history.
</TypeToConfirmDialog>

Anti-patterns

  • One-click destruction. A bare critical button that fires the API is never acceptable — even with the staged doctrine, irreversible confirms.
  • "Are you sure?" A confirm that names no cost teaches users to click through. Say what is destroyed.
  • Friction inflation. Type-to-confirm on an everyday single-record delete trains contempt for the gate; save it for scope.
  • Undo as a fig leaf. Offering Undo on a hard delete the backend can't restore is dishonest; the toast contract is a real restore.
  • /action-bar — the commit bar a critical intent lives in.
  • /modal — Modal and the ConfirmDialog convenience, the gates themselves.
  • /button — the solid critical fill the control wears.
  • /toast — the undo window's vehicle.