Skip to main content

ns-ui

Refusal Negotiation

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.

Usage

registry/core/refusal-negotiation/demo.tsx
"use client";

import { RedlineParley, type RedlineSpan } from "./component";

function dropPII(span: RedlineSpan): RedlineSpan {
  return {
    ...span,
    flagged: "the account under review, referenced by ticket ID only",
  };
}

function publicOnly(span: RedlineSpan): RedlineSpan {
  return {
    ...span,
    flagged: "only the details already published in the public case summary",
  };
}

function summaryOnly(span: RedlineSpan): RedlineSpan {
  return {
    ...span,
    flagged: "a one-paragraph summary of the investigation's findings",
  };
}

function anonymizeTeam(span: RedlineSpan): RedlineSpan {
  return {
    ...span,
    flagged: "the affected staff, referenced by role and shift only",
  };
}

function scheduleOnly(span: RedlineSpan): RedlineSpan {
  return {
    ...span,
    flagged: "the shift schedule, without contact details",
  };
}

export default function RedlineParleyDemo() {
  return (
    <main className="flex min-h-screen justify-center bg-background px-6 py-16">
      <div className="w-full max-w-lg space-y-6">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / refusal-negotiation — negotiated refusal
        </p>

        <section>
          <h2 className="text-sm font-medium text-foreground">
            Board note — refused
          </h2>
          <p className="mt-1 text-sm leading-relaxed text-ns-muted">
            The draft tripped a data-handling guardrail. Flip a lever to see
            the flagged clause rewrite itself and the send button unlock.
          </p>
          <div className="mt-4">
            <RedlineParley
              span={{
                before: "Could you draft a note to the board that includes ",
                flagged:
                  "the customer's full name, account number, and home address from the complaint",
                after: " so they have full context before Friday's meeting?",
              }}
              reason="Contains a customer's personally identifying details, which can't leave this workspace as free text."
              remedies={[
                { id: "anonymize", label: "anonymize names", rewrite: dropPII },
                {
                  id: "public-only",
                  label: "restrict to public data",
                  rewrite: publicOnly,
                },
                {
                  id: "summary",
                  label: "summary instead of full text",
                  rewrite: summaryOnly,
                },
              ]}
              onResend={(resolution) =>
                console.log("refusal-negotiation resend", resolution)
              }
            />
          </div>
        </section>

        <section>
          <h2 className="text-sm font-medium text-foreground">
            Roster export — refused
          </h2>
          <p className="mt-1 text-sm leading-relaxed text-ns-muted">
            A second, independent negotiation over a different clause and a
            smaller lever set.
          </p>
          <div className="mt-4">
            <RedlineParley
              span={{
                before: "Export tonight's roster with ",
                flagged: "each nurse's home phone number and address",
                after: " attached for the shift supervisor.",
              }}
              reason="Home contact details for staff can't be included in an export outside the scheduling system."
              remedies={[
                {
                  id: "anonymize-staff",
                  label: "anonymize names",
                  rewrite: anonymizeTeam,
                },
                {
                  id: "schedule-only",
                  label: "summary instead of full text",
                  rewrite: scheduleOnly,
                },
              ]}
              onResend={(resolution) =>
                console.log("refusal-negotiation resend", resolution)
              }
            />
          </div>
        </section>
      </div>
    </main>
  );
}

Component

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 --ns-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 --ns-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 {
  /** the flagged prompt span, echoed back for context */
  span: RedlineSpan;
  /** one-sentence explanation of what tripped, shown under the echoed prompt */
  reason: string;
  /** available fixes the user can toggle on to clear the guardrail */
  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;
  /** called when the user resends the rewritten span, with the final resolution */
  onResend?: (resolution: RedlineResolution) => void;
  /** extra classes merged onto the rendered root element */
  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-ns-muted"
                style={{ transform: `translateY(-50%) scaleX(${barScale})` }}
              />
            </span>
          </span>
          {currentSpan.after}
        </p>

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

        <p
          data-redline-status={passed ? "allowed" : "blocked"}
          className="mt-4 font-mono text-[11px] uppercase tracking-wide text-ns-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-ns-muted hover:border-foreground/40 hover:text-foreground")
                }
              >
                <span
                  aria-hidden
                  className={
                    "h-1.5 w-1.5 rounded-full " +
                    (active ? "bg-background" : "bg-ns-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-ns-accent " +
              (passed ? "border-ns-accent" : "border-border")
            }
          >
            {justSent ? "Sent" : "Resend request"}
          </button>
        </div>
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/refusal-negotiation.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/refusal-negotiation.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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 --ns-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 --ns-accent — the single, sole use of --ns-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.

Props

PropTypeDefaultDescription
spanRedlineSpan—the flagged prompt span, echoed back for context
reasonstring—one-sentence explanation of what tripped, shown under the echoed prompt
remediesRedlineRemedy[]—available fixes the user can toggle on to clear the guardrail
recheck?(span: RedlineSpan, activeRemedyIds: string[]) => booleandefaultRecheckdecides 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.
onResend?(resolution: RedlineResolution) => void—called when the user resends the rewritten span, with the final resolution
className?string—extra classes merged onto the rendered root element