Approval Request with Blast Radius
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.
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.
components/confidence-recommendation.tsxnpx hoverlab add confidence-recommendation
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.
Suggested action
Demand is tracking 22% above last October and your supplier holds a volume break at 1,200 units. Ordering today lands stock four days before the forecast stockout.
Based on 3 seasons of sales history. The forecast has not seen a promotion like the one running this month, which is why this is not higher.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <ConfidenceRecommendation> — a suggestion the agent is offering, with an
* honest confidence reading and the alternatives it rejected.
*
* Confidence meters are usually theatre. This one is built so it can carry
* real information:
*
* - The number is expressed as a `<meter>`, the element that exists for
* "a measurement within a known range". It exposes value, min and max to
* assistive tech natively, and `low`/`high`/`optimum` let the browser
* class the reading — which is genuinely the semantic here, unlike
* `<progress>`, which means "how far through a task".
* - The percentage is never the only signal. A band label ("moderate") and
* a plain-English caveat sit beside it, because 71% means nothing without
* knowing 71% of what.
* - The rejected alternatives are shown, not hidden behind a toggle. What
* the agent *didn't* pick, and why, is the fastest way for a human to
* catch a bad recommendation, and burying it is how automation bias sets
* in.
* - Accept is not styled as the only path. Dismiss and Edit are the same
* visual weight, so the card reads as a proposal rather than a prompt to
* click the blue thing.
*/
'use client'
import * as React from 'react'
import { Check, ChevronRight, Info, Lightbulb, PenLine, X } from 'lucide-react'
export interface RecommendationAlternative {
id: string
label: string
why: string
}
export interface ConfidenceRecommendationProps {
eyebrow?: string
headline?: string
body?: string
/** 0–100. */
confidence?: number
/** What the score is based on, in plain language. */
basis?: string
alternatives?: RecommendationAlternative[]
className?: string
}
const DEFAULT_ALTERNATIVES: RecommendationAlternative[] = [
{
id: 'wait',
label: 'Wait for the October close',
why: 'Safer, but the reorder lead time is 3 weeks — you would stock out first',
},
{
id: 'half',
label: 'Order half now, half in November',
why: 'Loses the volume break; unit cost rises about 9%',
},
]
/**
* `<meter>` restyled without losing its semantics.
*
* Left alone a meter renders in the platform's own olive-and-yellow, which
* is jarring in any themed UI and is why most teams give up and use a div
* with no semantics at all. Keeping the element is worth this much CSS: the
* browser still classifies the value against `low`/`high`/`optimum` and
* decides *which* pseudo-element applies, so the colour goes on meaning what
* it meant. Only the paint changes.
*
* Why a `<style>` block rather than Tailwind utilities — this cost an hour,
* so it is written down:
*
* - Chromium gates `::-webkit-meter-*` on the *prefixed*
* `-webkit-appearance: none`. Tailwind's `appearance-none` emits only the
* unprefixed property, so every rule is dropped silently.
* - Worse, an arbitrary variant like `[&::-webkit-meter-bar]:bg-muted`
* cannot work at all. Tailwind nests it as `&::-webkit-meter-bar`, which
* desugars to `:is(.cls)::-webkit-meter-bar`, and Chromium rejects a UA
* shadow pseudo-element sitting behind `:is()`. The selector has to be
* flat, which no utility class can produce.
*
* Firefox exposes one `::-moz-meter-bar` and colours it by the same
* classification, so it needs no per-band rule.
*/
const METER_CSS = `
.hl-meter { -webkit-appearance: none; appearance: none; background: none; }
.hl-meter::-webkit-meter-bar {
background: var(--muted); border: none; border-radius: 9999px;
}
.hl-meter::-webkit-meter-optimum-value {
background: var(--color-emerald-500, #10b981); border-radius: 9999px;
}
.hl-meter::-webkit-meter-suboptimum-value {
background: var(--color-amber-500, #f59e0b); border-radius: 9999px;
}
.hl-meter::-webkit-meter-even-less-good-value {
background: var(--color-rose-500, #f43f5e); border-radius: 9999px;
}
.hl-meter::-moz-meter-bar { border-radius: 9999px; }
`
/** Bands, so the number is never the only thing said about it. */
function band(value: number) {
if (value >= 85) return { label: 'High confidence', tone: 'text-emerald-600 dark:text-emerald-400' }
if (value >= 60) return { label: 'Moderate confidence', tone: 'text-amber-600 dark:text-amber-400' }
return { label: 'Low confidence', tone: 'text-rose-600 dark:text-rose-400' }
}
export function ConfidenceRecommendation({
eyebrow = 'Suggested action',
headline = 'Reorder 1,400 units of the winter blend now',
body = 'Demand is tracking 22% above last October and your supplier holds a volume break at 1,200 units. Ordering today lands stock four days before the forecast stockout.',
confidence = 71,
basis = 'Based on 3 seasons of sales history. The forecast has not seen a promotion like the one running this month, which is why this is not higher.',
alternatives = DEFAULT_ALTERNATIVES,
className = '',
}: ConfidenceRecommendationProps) {
const [decision, setDecision] = React.useState<'accepted' | 'dismissed' | null>(null)
const { label, tone } = band(confidence)
return (
<section
className={`mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-border/60 bg-card ${className}`}
>
<style>{METER_CSS}</style>
<div className="space-y-4 px-5 py-4">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Lightbulb aria-hidden className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{eyebrow}
</p>
{headline ? (
<h3 data-stress-ignore className="mt-0.5 text-base font-semibold leading-snug">{headline}</h3>
) : null}
</div>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{body}</p>
{/* -- Confidence ------------------------------------------------ */}
<div className="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<span className={`text-sm font-semibold ${tone}`}>{label}</span>
<span className="font-mono text-sm tabular-nums text-muted-foreground">
{confidence}%
</span>
</div>
{/*
`<meter>`, not a styled div. The value, bounds and the fact that
this is a measurement are all exposed without a line of ARIA —
and the bands below let a browser render it as good/average/poor
on its own.
*/}
<meter
value={confidence}
min={0}
max={100}
low={60}
high={85}
optimum={100}
aria-label="Model confidence"
className="hl-meter mt-2 h-1.5 w-full"
>
{confidence}%
</meter>
<p className="mt-2.5 flex gap-2 text-xs leading-relaxed text-muted-foreground">
<Info aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{basis}
</p>
</div>
{/* -- What it rejected ----------------------------------------- */}
{alternatives.length > 0 ? (
<div>
<h4
data-stress-ignore
className="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground"
>
Considered and rejected
</h4>
<ul className="space-y-1.5">
{alternatives.map((alt) => (
<li key={alt.id} className="flex gap-2 text-xs leading-relaxed">
<ChevronRight
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180"
/>
<span>
<span className="font-medium">{alt.label}</span>
<span className="text-muted-foreground"> — {alt.why}</span>
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
{/* -- Actions, all three at equal weight ------------------------- */}
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 bg-muted/30 px-5 py-3">
{decision ? (
<p role="status" className="flex items-center gap-2 text-sm">
{decision === 'accepted' ? (
<>
<Check aria-hidden className="h-4 w-4 text-emerald-500" />
Order queued for your approval in Purchasing.
</>
) : (
<>
<X aria-hidden className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Dismissed. I will not raise this again this week.
</span>
</>
)}
</p>
) : (
<>
<button
type="button"
onClick={() => setDecision('dismissed')}
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" />
Dismiss
</button>
<button
type="button"
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"
>
<PenLine aria-hidden className="h-4 w-4" />
Edit quantity
</button>
<button
type="button"
onClick={() => setDecision('accepted')}
className="ms-auto inline-flex items-center gap-1.5 rounded-xl border border-primary/40 bg-primary/10 px-3.5 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check aria-hidden className="h-4 w-4" />
Accept
</button>
</>
)}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/confidence-recommendation.tsx and import it where you need the section:
import { ConfidenceRecommendation } from '@/components/confidence-recommendation'3 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 |
|---|---|---|
eyebrow | string | 'Suggested action' |
headline | string | 'Reorder 1,400 units of the winter blend now' |
body | string | — |
confidence0–100. | number | 71 |
basisWhat the score is based on, in plain language. | string | — |
alternatives | RecommendationAlternative[] | DEFAULT_ALTERNATIVES |
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.
<!--
Recommendation with Confidence Meter — 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 class="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-border/60 bg-card ">
<style>.hl-meter { -webkit-appearance: none; appearance: none; background: none; }
.hl-meter::-webkit-meter-bar {
background: var(--muted); border: none; border-radius: 9999px;
}
.hl-meter::-webkit-meter-optimum-value {
background: var(--color-emerald-500, #10b981); border-radius: 9999px;
}
.hl-meter::-webkit-meter-suboptimum-value {
background: var(--color-amber-500, #f59e0b); border-radius: 9999px;
}
.hl-meter::-webkit-meter-even-less-good-value {
background: var(--color-rose-500, #f43f5e); border-radius: 9999px;
}
.hl-meter::-moz-meter-bar { border-radius: 9999px; }</style>
<div class="space-y-4 px-5 py-4">
<div class="flex items-start gap-3">
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<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-lightbulb h-4 w-4" aria-hidden="true">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
<path d="M9 18h6"></path>
<path d="M10 22h4"></path>
</svg>
</span>
<div class="min-w-0">
<p class="text-xs font-bold uppercase tracking-wider text-muted-foreground">Suggested action</p>
<h3 data-stress-ignore="true" class="mt-0.5 text-base font-semibold leading-snug">Reorder 1,400 units of the winter blend now</h3>
</div>
</div>
<p class="text-sm leading-relaxed text-muted-foreground">Demand is tracking 22% above last October and your supplier holds a volume break at 1,200 units. Ordering today lands stock four days before the forecast stockout.</p>
<div class="rounded-xl border border-border/60 bg-muted/40 px-4 py-3">
<div class="flex items-baseline justify-between gap-3">
<span class="text-sm font-semibold text-amber-600 dark:text-amber-400">Moderate confidence</span>
<span class="font-mono text-sm tabular-nums text-muted-foreground">71%</span>
</div>
<meter value="71" min="0" max="100" low="60" high="85" optimum="100" aria-label="Model confidence" class="hl-meter mt-2 h-1.5 w-full">71%</meter>
<p class="mt-2.5 flex gap-2 text-xs leading-relaxed text-muted-foreground"><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-info mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>Based on 3 seasons of sales history. The forecast has not seen a promotion like the one running this month, which is why this is not higher.</p>
</div>
<div>
<h4 data-stress-ignore="true" class="mb-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">Considered and rejected</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-chevron-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<span>
<span class="font-medium">Wait for the October close</span>
<span class="text-muted-foreground">— Safer, but the reorder lead time is 3 weeks — you would stock out first</span>
</span>
</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-chevron-right mt-0.5 h-3 w-3 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<span>
<span class="font-medium">Order half now, half in November</span>
<span class="text-muted-foreground">— Loses the volume break; unit cost rises about 9%</span>
</span>
</li>
</ul>
</div>
</div>
<div class="flex flex-wrap 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>Dismiss</button>
<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-pen-line h-4 w-4" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"></path></svg>Edit quantity</button>
<button type="button" class="ms-auto inline-flex items-center gap-1.5 rounded-xl border border-primary/40 bg-primary/10 px-3.5 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/15 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-check h-4 w-4" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>Accept</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.
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.
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.
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.