Proposed Edits Diff Table
Machine-proposed row edits accepted one at a time, with before and after in real del/ins elements, a tri-state select-all that actually sets indeterminate, and a running count of what will be written.
The agent stops and asks: the effect stated before the verb, choices as radios in a fieldset so picking is separate from committing, no pre-focused Approve, and the decision left on screen afterwards.
components/approval-request-card.tsxnpx hoverlab add approval-request-card
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Waiting on you
I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.
This sends real email from a production account.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <ApprovalRequestCard> — the agent stops and asks before it acts.
*
* This is the block that decides whether an agent product is trustworthy,
* and almost every version of it is too vague to approve safely. The rules
* it follows:
*
* - State the *effect*, not the intention. "Send 214 emails" is approvable;
* "proceed with outreach" is not. The consequence line is required, not
* decorative, and it names what changes and where.
* - Approve is never the pre-focused default. A card that appears under the
* user's hands with the destructive option focused will be approved by an
* Enter keypress meant for something else, so focus lands on the card's
* heading and the user must travel to a choice deliberately.
* - The options are radios in a `<fieldset>`, not buttons. Arrow keys move
* between them, the legend names the question for a screen reader, and —
* crucially — choosing is separate from committing, so a mis-click is not
* an irreversible action.
* - The decision is announced through `role="status"`, and the card keeps
* its resolved state on screen. An approval that vanishes leaves the user
* unable to answer "what did I just agree to".
*
* Real dialogs get `role="alertdialog"` and a focus trap. This one renders
* inline in the thread, which is the shape most agents actually use, so it
* is a `<section>` with a labelled heading instead.
*/
'use client'
import * as React from 'react'
import { AlertTriangle, ArrowRight, Check, ShieldAlert, X } from 'lucide-react'
export interface ApprovalOption {
id: string
label: string
hint?: string
/** Marks the choice that does the most damage if chosen carelessly. */
destructive?: boolean
}
export interface ApprovalRequestCardProps {
question?: string
rationale?: string
/** What changes if this is approved. Shown as a consequence list. */
effects?: string[]
options?: ApprovalOption[]
/** Shown when the request touches something irreversible. */
warning?: string
className?: string
}
const DEFAULT_OPTIONS: ApprovalOption[] = [
{
id: 'core',
label: 'Email the 38 accounts flagged high-risk',
hint: 'Smallest blast radius — the cohort the model is most confident about',
},
{
id: 'full',
label: 'Email all 214 accounts in the at-risk segment',
hint: 'Includes medium-confidence accounts; ~40 are likely false positives',
destructive: true,
},
{ id: 'none', label: 'Draft them for me to review, send nothing', hint: 'No mail leaves today' },
]
const DEFAULT_EFFECTS = [
'Sends from retention@acme.com via the production Postmark key',
'Writes a "save offer sent" activity to each CRM account',
'Cannot be recalled once dispatched',
]
export function ApprovalRequestCard({
question = 'Who should receive the retention offer?',
rationale = 'I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.',
effects = DEFAULT_EFFECTS,
options = DEFAULT_OPTIONS,
warning = 'This sends real email from a production account.',
className = '',
}: ApprovalRequestCardProps) {
const [choice, setChoice] = React.useState(options[0]?.id ?? '')
const [decision, setDecision] = React.useState<'approved' | 'declined' | null>(null)
const selected = options.find((o) => o.id === choice)
const headingId = React.useId()
return (
<section
aria-labelledby={headingId}
className={`mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-amber-500/30 bg-card ${className}`}
>
{/* -- Header ----------------------------------------------------- */}
<div className="flex items-start gap-3 border-b border-border/60 bg-amber-500/5 px-5 py-4">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400">
<ShieldAlert aria-hidden className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">
Waiting on you
</p>
{question ? (
<h3 id={headingId} data-stress-ignore className="mt-0.5 text-base font-semibold leading-snug">
{question}
</h3>
) : null}
</div>
</div>
<div className="space-y-5 px-5 py-4">
<p className="text-sm leading-relaxed text-muted-foreground">{rationale}</p>
{/* -- What actually happens ----------------------------------- */}
{effects.length > 0 ? (
<div className="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
<h4
data-stress-ignore
className="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground"
>
If you approve
</h4>
<ul className="space-y-1.5">
{effects.map((effect) => (
<li key={effect} className="flex gap-2 text-xs leading-relaxed">
<ArrowRight
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180"
/>
{effect}
</li>
))}
</ul>
</div>
) : null}
{/* -- Choices --------------------------------------------------- */}
<fieldset disabled={decision !== null} className="disabled:opacity-60">
<legend className="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">
Choose one
</legend>
<div className="space-y-2">
{options.map((option) => {
const active = option.id === choice
return (
<label
key={option.id}
className={`flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring ${
active ? 'border-primary bg-primary/5' : 'border-border/60 hover:bg-muted/50'
}`}
>
<input
type="radio"
name={`${headingId}-choice`}
value={option.id}
checked={active}
onChange={() => setChoice(option.id)}
className="mt-0.5 h-4 w-4 shrink-0 accent-primary"
/>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{option.label}
{option.destructive ? (
<span className="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-rose-600 dark:text-rose-400">
<AlertTriangle aria-hidden className="h-2.5 w-2.5" />
Widest reach
</span>
) : null}
</span>
{option.hint ? (
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">
{option.hint}
</span>
) : null}
</span>
</label>
)
})}
</div>
</fieldset>
{warning ? (
<p className="flex gap-2 text-xs leading-relaxed text-amber-600 dark:text-amber-400">
<AlertTriangle aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{warning}
</p>
) : null}
</div>
{/* -- Decision --------------------------------------------------- */}
<div className="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-3">
{decision === null ? (
<>
<button
type="button"
onClick={() => setDecision('declined')}
className="inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3.5 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-4 w-4" />
Decline
</button>
<button
type="button"
onClick={() => setDecision('approved')}
className="ms-auto inline-flex items-center gap-1.5 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Check aria-hidden className="h-4 w-4" />
Approve
</button>
</>
) : (
<>
{/* The card resolves in place. A decision that erases what was
decided is not an audit trail. */}
<p role="status" className="flex items-center gap-2 text-sm">
{decision === 'approved' ? (
<>
<Check aria-hidden className="h-4 w-4 text-emerald-500" />
<span>
Approved — <span className="font-medium">{selected?.label}</span>
</span>
</>
) : (
<>
<X aria-hidden className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Declined. Nothing was sent.</span>
</>
)}
</p>
<button
type="button"
onClick={() => setDecision(null)}
className="ms-auto rounded-lg px-2.5 py-1.5 text-xs font-medium text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Undo
</button>
</>
)}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/approval-request-card.tsx and import it where you need the section:
import { ApprovalRequestCard } from '@/components/approval-request-card'2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
question | string | 'Who should receive the retention offer?' |
rationale | string | — |
effectsWhat changes if this is approved. Shown as a consequence list. | string[] | DEFAULT_EFFECTS |
options | ApprovalOption[] | DEFAULT_OPTIONS |
warningShown when the request touches something irreversible. | string | 'This sends real email from a production acco… |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Approval Request with Blast Radius — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section aria-labelledby="_R_0_" class="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-amber-500/30 bg-card ">
<div class="flex items-start gap-3 border-b border-border/60 bg-amber-500/5 px-5 py-4">
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-alert h-4 w-4" aria-hidden="true">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
<path d="M12 8v4"></path>
<path d="M12 16h.01"></path>
</svg>
</span>
<div class="min-w-0">
<p class="text-xs font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400">Waiting on you</p>
<h3 id="_R_0_" data-stress-ignore="true" class="mt-0.5 text-base font-semibold leading-snug">Who should receive the retention offer?</h3>
</div>
</div>
<div class="space-y-5 px-5 py-4">
<p class="text-sm leading-relaxed text-muted-foreground">I scored 214 accounts as at-risk for Q4. 38 of them cleared the high-confidence threshold. Sending to the full segment reaches more real churn but also more accounts that were never going to leave.</p>
<div class="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
<h4 data-stress-ignore="true" class="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">If you approve</h4>
<ul class="space-y-1.5">
<li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Sends from retention@acme.com via the production Postmark key</li>
<li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Writes a "save offer sent" activity to each CRM account</li>
<li class="flex gap-2 text-xs leading-relaxed"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>Cannot be recalled once dispatched</li>
</ul>
</div>
<fieldset class="disabled:opacity-60">
<legend class="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">Choose one</legend>
<div class="space-y-2">
<label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-primary bg-primary/5">
<input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" checked="" value="core" />
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">Email the 38 accounts flagged high-risk</span>
<span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">Smallest blast radius — the cohort the model is most confident about</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-border/60 hover:bg-muted/50">
<input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" value="full" />
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">Email all 214 accounts in the at-risk segment<span class="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-2.5 w-2.5" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>Widest reach</span></span>
<span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">Includes medium-confidence accounts; ~40 are likely false positives</span>
</span>
</label>
<label class="flex cursor-pointer gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring border-border/60 hover:bg-muted/50">
<input type="radio" class="mt-0.5 h-4 w-4 shrink-0 accent-primary" name="_R_0_-choice" value="none" />
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">Draft them for me to review, send nothing</span>
<span class="mt-0.5 block text-xs leading-relaxed text-muted-foreground">No mail leaves today</span>
</span>
</label>
</div>
</fieldset>
<p class="flex gap-2 text-xs leading-relaxed text-amber-600 dark:text-amber-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>This sends real email from a production account.</p>
</div>
<div class="flex items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-3">
<button type="button" class="inline-flex items-center gap-1.5 rounded-xl border border-border/60 px-3.5 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-4 w-4" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>Decline</button>
<button type="button" class="ms-auto inline-flex items-center gap-1.5 rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-4 w-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Approve</button>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Human in the LoopCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Machine-proposed row edits accepted one at a time, with before and after in real del/ins elements, a tri-state select-all that actually sets indeterminate, and a running count of what will be written.
A suggestion scored with a native meter rather than a styled bar, banded in words as well as numbers, and showing what the model rejected — the fastest way a human catches a bad recommendation.
Read and write scopes separated rather than bundled, writes off by default, a required expiry, and switches that are real checkboxes with role="switch" — so the grant is toggleable by keyboard and announced as on or off.
Forty decisions and an honest way to clear them: bulk approve that cannot reach the high-impact rows, a select-all that says how many it takes, and the auto-approve deadline on every line.
Which agent actions run unattended and which need a signature, stated as a policy the operator can read before anything happens.
What is waiting on a person, ordered by how long it has been waiting rather than when it arrived.
Where a person sits in an automated run, written as four guarantees rather than as a promise that a human is involved.