Refusal Negotiation

Guardrail

A refusal that negotiates instead of stonewalling: the offending span of a blocked request is echoed back struck through, with narrowing-lever toggle chips that rewrite it and unlock resend the instant a recheck passes.

Install
npx shadcn add https://design.helpmarq.com/r/refusal-negotiation.json
Source
registry/core/refusal-negotiation/component.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// RedlineParley — the counter-offer after a guardrail closes, not the gate
// itself (that's approval-inline-diff's job, for a different decision entirely: a
// human approving an agent's already-permitted tool call). Here the request
// was already refused. The offending span is echoed back struck through —
// never a full-red block, just a --muted strikethrough bar that draws
// left-to-right once on mount — with the reason beneath it and a small
// group of narrowing levers (switches) that each rewrite the span. Flipping
// any lever re-runs its rewrite against the *original* span, cross-fades the
// old phrasing into the new one without changing the line's height, and
// recomputes whether the rewritten request now clears `recheck`. Passing
// retracts the strike (the same bar, animating the other direction) and is
// the only moment --accent appears anywhere in this component: the resend
// button's border. A visible status line always states "Blocked"/"Allowed"
// in plain words too, so the outcome never rides on noticing a line move.
//
// Gate-selector note: the verifier's generic press pass clicks the first
// interactive control before checking a declared `gate`. The first control
// here is remedies[0]'s switch. Point any `gate.openBy` at remedies[1] (or
// later) — `passed` is an OR over every active remedy by default, so a
// press-pass toggle of remedies[0] and a gate click on remedies[1] both
// leave at least one lever on and the gate still resolves.
// ---------------------------------------------------------------------------

export interface RedlineSpan {
  /** text before the flagged span, echoed verbatim */
  before: string;
  /** the substring that tripped the guardrail */
  flagged: string;
  /** text after the flagged span, echoed verbatim */
  after: string;
}

export interface RedlineRemedy {
  id: string;
  /** short label on the toggle chip, e.g. "anonymize names" */
  label: string;
  /** rewrites the span — called against the *original* span each time the
   * lever is (re)armed, so levers don't compound off each other's output */
  rewrite: (span: RedlineSpan) => RedlineSpan;
}

export interface RedlineResolution {
  /** before + flagged + after, as finally rewritten */
  request: string;
  /** ids of the remedies that were active at send time */
  activeRemedies: string[];
}

export interface RedlineParleyProps {
  span: RedlineSpan;
  /** one-sentence explanation of what tripped, shown under the echoed prompt */
  reason: string;
  remedies: RedlineRemedy[];
  /** decides whether the rewritten span now clears the guardrail. Default:
   * passes once at least one remedy is active — a real integration should
   * pass its own check against the actual guardrail instead. */
  recheck?: (span: RedlineSpan, activeRemedyIds: string[]) => boolean;
  onResend?: (resolution: RedlineResolution) => void;
  className?: string;
}

const FADE_MS = 220;
const STRIKE_MS = 200;
const SENT_HOLD_MS = 1400;

function defaultRecheck(_span: RedlineSpan, activeRemedyIds: string[]) {
  return activeRemedyIds.length > 0;
}

export function RedlineParley({
  span,
  reason,
  remedies,
  recheck = defaultRecheck,
  onResend,
  className = "",
}: RedlineParleyProps) {
  const original = useRef(span).current;
  const uid = useId();

  const [activeIds, setActiveIds] = useState<string[]>([]);
  const [justSent, setJustSent] = useState(false);
  const sentTimeout = useRef<number | undefined>(undefined);

  // Each active remedy's rewrite is folded over the *original* span in
  // remedy-array order — a lever always starts from what the guardrail
  // actually flagged, never from another lever's output, so toggling one
  // off doesn't leave a stray partial rewrite behind from another.
  const currentSpan = useMemo(
    () =>
      remedies.reduce(
        (acc, r) => (activeIds.includes(r.id) ? r.rewrite(acc) : acc),
        original
      ),
    [remedies, activeIds, original]
  );

  const passed = recheck(currentSpan, activeIds);

  // Cross-fade the flagged text old -> new without moving the line: the two
  // copies stack in the same CSS grid cell (grid sizes the cell to the
  // larger of the two, so nothing else on the line reflows mid-fade) while
  // opacity swaps between them.
  const lastFlaggedRef = useRef(original.flagged);
  const [displayFlagged, setDisplayFlagged] = useState(original.flagged);
  const [ghost, setGhost] = useState<string | null>(null);
  const [revealed, setRevealed] = useState(true);
  const ghostTimeout = useRef<number | undefined>(undefined);

  useEffect(() => {
    if (currentSpan.flagged === lastFlaggedRef.current) return;
    const previous = lastFlaggedRef.current;
    lastFlaggedRef.current = currentSpan.flagged;
    setGhost(previous);
    setDisplayFlagged(currentSpan.flagged);
    setRevealed(false);
    const raf = requestAnimationFrame(() => setRevealed(true));
    window.clearTimeout(ghostTimeout.current);
    ghostTimeout.current = window.setTimeout(() => setGhost(null), FADE_MS);
    return () => cancelAnimationFrame(raf);
  }, [currentSpan.flagged]);

  useEffect(() => () => window.clearTimeout(ghostTimeout.current), []);
  useEffect(() => () => window.clearTimeout(sentTimeout.current), []);

  // The strike bar's scale tracks `!passed`, deferred one frame so the very
  // first paint (undrawn) and the draw-in (drawn) are two distinct commits —
  // without the frame gap the mount would paint straight at its final value
  // and no transition would ever be observed.
  const [barScale, setBarScale] = useState(0);
  useEffect(() => {
    const raf = requestAnimationFrame(() => setBarScale(passed ? 0 : 1));
    return () => cancelAnimationFrame(raf);
  }, [passed]);

  // sr-only announcement, one per lever flip — skipped on mount so a screen
  // reader doesn't hear a phantom "rewritten" before anything happened.
  const mountedRef = useRef(false);
  const [announcement, setAnnouncement] = useState("");
  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      return;
    }
    setAnnouncement(
      `rewritten: ${currentSpan.flagged}, request now ${passed ? "allowed" : "blocked"}`
    );
  }, [currentSpan.flagged, passed]);

  function toggle(id: string) {
    setActiveIds((prev) =>
      prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
    );
  }

  function handleSend() {
    if (!passed) return;
    onResend?.({
      request: currentSpan.before + currentSpan.flagged + currentSpan.after,
      activeRemedies: activeIds,
    });
    setJustSent(true);
    window.clearTimeout(sentTimeout.current);
    sentTimeout.current = window.setTimeout(() => setJustSent(false), SENT_HOLD_MS);
  }

  return (
    <div className={className}>
      <style>{`
.ns-rp-strike { transition: transform ${STRIKE_MS}ms cubic-bezier(0.16,1,0.3,1); }
.ns-rp-fade { transition: opacity ${FADE_MS}ms cubic-bezier(0.16,1,0.3,1); }
.ns-rp-chip { transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease; }
.ns-rp-send { transition: border-color 220ms cubic-bezier(0.16,1,0.3,1), box-shadow 220ms cubic-bezier(0.16,1,0.3,1); }
@media (prefers-reduced-motion: reduce) {
  .ns-rp-strike, .ns-rp-fade, .ns-rp-chip, .ns-rp-send { transition: none !important; }
}
`}</style>

      <div className="rounded-md border border-border bg-surface p-4">
        <p className="text-sm leading-relaxed text-foreground">
          {currentSpan.before}
          <span
            role="mark"
            tabIndex={0}
            aria-label={`flagged: ${reason}`}
            className="relative inline-grid align-baseline rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60"
          >
            {ghost !== null ? (
              <span
                aria-hidden
                style={{ gridArea: "1 / 1" }}
                className={
                  "ns-rp-fade pointer-events-none " +
                  (revealed ? "opacity-0" : "opacity-100")
                }
              >
                {ghost}
              </span>
            ) : null}
            <span
              style={{ gridArea: "1 / 1" }}
              className={
                "ns-rp-fade relative " +
                (ghost !== null ? (revealed ? "opacity-100" : "opacity-0") : "opacity-100")
              }
            >
              {displayFlagged}
              <span
                aria-hidden
                className="ns-rp-strike pointer-events-none absolute inset-x-0 top-1/2 h-[1.5px] bg-muted"
                style={{ transform: `translateY(-50%) scaleX(${barScale})` }}
              />
            </span>
          </span>
          {currentSpan.after}
        </p>

        <p className="mt-2 text-sm text-muted">{reason}</p>

        <p
          data-redline-status={passed ? "allowed" : "blocked"}
          className="mt-4 font-mono text-[11px] uppercase tracking-wide text-muted"
        >
          {passed ? "Allowed — ready to resend" : "Blocked"}
        </p>

        <div
          role="group"
          aria-label="Narrowing levers"
          className="mt-2 flex flex-wrap gap-2"
        >
          {remedies.map((r, i) => {
            const active = activeIds.includes(r.id);
            return (
              <button
                key={r.id}
                type="button"
                role="switch"
                aria-checked={active}
                data-redline-remedy={r.id}
                data-redline-index={i}
                onClick={() => toggle(r.id)}
                className={
                  "ns-rp-chip inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60 " +
                  (active
                    ? "border-foreground bg-foreground text-background"
                    : "border-border text-muted hover:border-foreground/40 hover:text-foreground")
                }
              >
                <span
                  aria-hidden
                  className={
                    "h-1.5 w-1.5 rounded-full " +
                    (active ? "bg-background" : "bg-muted")
                  }
                />
                {r.label}
              </button>
            );
          })}
        </div>

        <p role="status" aria-live="polite" className="sr-only">
          {announcement}
        </p>

        <div className="mt-4 flex items-center gap-3">
          <button
            type="button"
            id={`${uid}-send`}
            disabled={!passed}
            onClick={handleSend}
            className={
              "ns-rp-send rounded-sm border bg-background px-3 py-1.5 text-sm font-medium text-foreground disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent " +
              (passed ? "border-accent" : "border-border")
            }
          >
            {justSent ? "Sent" : "Resend request"}
          </button>
        </div>
      </div>
    </div>
  );
}
Use when

The counter-offer that appears *after* a guardrail already blocked a request: the flagged span struck through inline, a one-sentence reason, and toggle-chip levers that rewrite the span and unlock resend once a recheck passes — an editable negotiation surface for turning a refusal into a one-flip repair. Reach for approval-inline-diff instead when the request hasn't been decided yet and a human needs to approve or deny an agent's proposed, already-permitted tool-call arguments with an audit receipt — approval-inline-diff is the pre-decision approval row; refusal-negotiation is what a human (or the requester) sees once the gate has already closed.

Build spec

Renders one guardrail refusal as an editable negotiation, not a dead end. Props are `span` ({before, flagged, after} — the user's original request pre-split around the offending substring), `reason` (one sentence, why it tripped), and `remedies` (2-3 `{id, label, rewrite}` narrowing levers, each `rewrite` a pure function from the original span to a candidate span). The component echoes `before` + the flagged span + `after` as one line of body text; the flagged span alone renders inside a `role=mark` element with an accessible name of `flagged: {reason}` — so a screen reader gets the 'what and why' as a single unit without depending on visually finding the struck text. A muted 1.5px bar underlines exactly the flagged span's width and draws left-to-right once over 200ms on mount (never a red flash, never the whole line changing color) — this is the strikethrough, drawn as an animatable bar rather than text-decoration so its direction is controllable both ways. Beneath the echoed line, `reason` renders in --muted, then a always-visible status line reading literally 'Blocked' or 'Allowed — ready to resend' in Geist Mono uppercase — this exists specifically so the outcome never rides on the strikethrough alone; a user who can't see the strike animate still reads the word. Below that, the remedies render as a labeled switch group (`role=group aria-label="Narrowing levers"`, each lever a `role=switch aria-checked` chip told apart from its OFF state by fill and weight, never colour — filled foreground-on-background when ON, outlined muted when OFF). Flipping a lever folds every currently-active remedy's `rewrite` over the *original* span in remedies-array order (never chaining off another lever's already-rewritten output, so toggling one off cleanly restores exactly what the others produced) and the flagged span's old text cross-fades into the new one in the same CSS grid cell — both copies stacked in one grid area so the cell sizes to whichever is wider/taller and nothing else on the line reflows mid-fade. An optional `recheck(span, activeRemedyIds)` prop decides whether the rewritten span now clears the guardrail; the default recheck passes once at least one remedy is active (a real integration should pass its own check against the actual guardrail instead — the default only proves the wiring). The instant `recheck` passes, the status line flips to 'Allowed', the strike bar retracts the opposite direction (same 200ms bar, animating scale back toward zero, transform-origin held at the left edge throughout so 'draw' and 'retract' are the same transition read in reverse), and the Resend button's border transitions --border to --accent — the single, sole use of --accent anywhere in this component, reserved for exactly this one interaction cue and nothing decorative. The Resend button stays disabled (native `disabled`, so it's correctly out of tab order and exempt from the accessible-name-on-enabled-controls audit) until `recheck` passes; clicking it while enabled calls `onResend({request, activeRemedies})` with the fields reassembled from the current span plus which remedy ids were active, and briefly swaps its own label to 'Sent' for 1.4s as a lightweight, non-terminal acknowledgement — unlike approval-inline-diff's one-shot collapse, a lever can still be flipped back afterward; this is a negotiation surface, not an audit receipt. Every lever flip also pushes one sr-only `aria-live=polite` announcement reading 'rewritten: {new flagged text}, request now allowed/blocked', so the outcome of a flip is heard immediately even before Tab reaches the visible status line. Full keyboard path: Tab reaches the flagged mark first (focusable, `tabIndex=0`, its own visible focus ring), then each lever switch in order (Space flips, native button semantics), then Resend once it's enabled (Enter/Space sends, again native). Under `prefers-reduced-motion: reduce` every transition (strike draw/retract, cross-fade, button border) is removed via a scoped media query while the underlying state — which text renders, which levers are on, whether Blocked or Allowed shows — is unaffected, so the component is fully legible and operable with zero motion. Zero dependencies, no canvas — DOM, SVG-free markup and CSS transitions only.

Tags
guardrailrefusalnegotiationtogglediffaccessibility