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.
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.
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.
Usage
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>import { Button, Input, Modal } from '@halo-compliance/ui'
import { Trash2 } from 'lucide-react'
const match = typed.trim() === rulebook.name // exact match unlocks
<Modal
isOpen={open}
onOpenChange={setOpen}
role="alertdialog"
size="sm"
title={`Delete the ${rulebook.name} rulebook?`}
footer={
<>
<Button variant="ghost" tone="secondary" onClick={close}>Cancel</Button>
<Button variant="critical" icon={<Trash2 />} disabled={!match}
onClick={deleteRulebook}>
Delete rulebook
</Button>
</>
}
>
{/* name the cost, then the labelled type-to-confirm field */}
<Input value={typed} onChange={(e) => setTyped(e.target.value)}
valid={match} autoComplete="off" spellCheck={false} />
</Modal>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.
Related
- /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.