{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "refusal-negotiation",
  "title": "Refusal Negotiation",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/refusal-negotiation/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RedlineParley — the counter-offer after a guardrail closes, not the gate\n// itself (that's approval-inline-diff's job, for a different decision entirely: a\n// human approving an agent's already-permitted tool call). Here the request\n// was already refused. The offending span is echoed back struck through —\n// never a full-red block, just a --ns-muted strikethrough bar that draws\n// left-to-right once on mount — with the reason beneath it and a small\n// group of narrowing levers (switches) that each rewrite the span. Flipping\n// any lever re-runs its rewrite against the *original* span, cross-fades the\n// old phrasing into the new one without changing the line's height, and\n// recomputes whether the rewritten request now clears `recheck`. Passing\n// retracts the strike (the same bar, animating the other direction) and is\n// the only moment --ns-accent appears anywhere in this component: the resend\n// button's border. A visible status line always states \"Blocked\"/\"Allowed\"\n// in plain words too, so the outcome never rides on noticing a line move.\n//\n// Gate-selector note: the verifier's generic press pass clicks the first\n// interactive control before checking a declared `gate`. The first control\n// here is remedies[0]'s switch. Point any `gate.openBy` at remedies[1] (or\n// later) — `passed` is an OR over every active remedy by default, so a\n// press-pass toggle of remedies[0] and a gate click on remedies[1] both\n// leave at least one lever on and the gate still resolves.\n// ---------------------------------------------------------------------------\n\nexport interface RedlineSpan {\n  /** text before the flagged span, echoed verbatim */\n  before: string;\n  /** the substring that tripped the guardrail */\n  flagged: string;\n  /** text after the flagged span, echoed verbatim */\n  after: string;\n}\n\nexport interface RedlineRemedy {\n  id: string;\n  /** short label on the toggle chip, e.g. \"anonymize names\" */\n  label: string;\n  /** rewrites the span — called against the *original* span each time the\n   * lever is (re)armed, so levers don't compound off each other's output */\n  rewrite: (span: RedlineSpan) => RedlineSpan;\n}\n\nexport interface RedlineResolution {\n  /** before + flagged + after, as finally rewritten */\n  request: string;\n  /** ids of the remedies that were active at send time */\n  activeRemedies: string[];\n}\n\nexport interface RedlineParleyProps {\n  /** the flagged prompt span, echoed back for context */\n  span: RedlineSpan;\n  /** one-sentence explanation of what tripped, shown under the echoed prompt */\n  reason: string;\n  /** available fixes the user can toggle on to clear the guardrail */\n  remedies: RedlineRemedy[];\n  /** decides whether the rewritten span now clears the guardrail. Default:\n   * passes once at least one remedy is active — a real integration should\n   * pass its own check against the actual guardrail instead. */\n  recheck?: (span: RedlineSpan, activeRemedyIds: string[]) => boolean;\n  /** called when the user resends the rewritten span, with the final resolution */\n  onResend?: (resolution: RedlineResolution) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst FADE_MS = 220;\nconst STRIKE_MS = 200;\nconst SENT_HOLD_MS = 1400;\n\nfunction defaultRecheck(_span: RedlineSpan, activeRemedyIds: string[]) {\n  return activeRemedyIds.length > 0;\n}\n\nexport function RedlineParley({\n  span,\n  reason,\n  remedies,\n  recheck = defaultRecheck,\n  onResend,\n  className = \"\",\n}: RedlineParleyProps) {\n  const original = useRef(span).current;\n  const uid = useId();\n\n  const [activeIds, setActiveIds] = useState<string[]>([]);\n  const [justSent, setJustSent] = useState(false);\n  const sentTimeout = useRef<number | undefined>(undefined);\n\n  // Each active remedy's rewrite is folded over the *original* span in\n  // remedy-array order — a lever always starts from what the guardrail\n  // actually flagged, never from another lever's output, so toggling one\n  // off doesn't leave a stray partial rewrite behind from another.\n  const currentSpan = useMemo(\n    () =>\n      remedies.reduce(\n        (acc, r) => (activeIds.includes(r.id) ? r.rewrite(acc) : acc),\n        original\n      ),\n    [remedies, activeIds, original]\n  );\n\n  const passed = recheck(currentSpan, activeIds);\n\n  // Cross-fade the flagged text old -> new without moving the line: the two\n  // copies stack in the same CSS grid cell (grid sizes the cell to the\n  // larger of the two, so nothing else on the line reflows mid-fade) while\n  // opacity swaps between them.\n  const lastFlaggedRef = useRef(original.flagged);\n  const [displayFlagged, setDisplayFlagged] = useState(original.flagged);\n  const [ghost, setGhost] = useState<string | null>(null);\n  const [revealed, setRevealed] = useState(true);\n  const ghostTimeout = useRef<number | undefined>(undefined);\n\n  useEffect(() => {\n    if (currentSpan.flagged === lastFlaggedRef.current) return;\n    const previous = lastFlaggedRef.current;\n    lastFlaggedRef.current = currentSpan.flagged;\n    setGhost(previous);\n    setDisplayFlagged(currentSpan.flagged);\n    setRevealed(false);\n    const raf = requestAnimationFrame(() => setRevealed(true));\n    window.clearTimeout(ghostTimeout.current);\n    ghostTimeout.current = window.setTimeout(() => setGhost(null), FADE_MS);\n    return () => cancelAnimationFrame(raf);\n  }, [currentSpan.flagged]);\n\n  useEffect(() => () => window.clearTimeout(ghostTimeout.current), []);\n  useEffect(() => () => window.clearTimeout(sentTimeout.current), []);\n\n  // The strike bar's scale tracks `!passed`, deferred one frame so the very\n  // first paint (undrawn) and the draw-in (drawn) are two distinct commits —\n  // without the frame gap the mount would paint straight at its final value\n  // and no transition would ever be observed.\n  const [barScale, setBarScale] = useState(0);\n  useEffect(() => {\n    const raf = requestAnimationFrame(() => setBarScale(passed ? 0 : 1));\n    return () => cancelAnimationFrame(raf);\n  }, [passed]);\n\n  // sr-only announcement, one per lever flip — skipped on mount so a screen\n  // reader doesn't hear a phantom \"rewritten\" before anything happened.\n  const mountedRef = useRef(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  useEffect(() => {\n    if (!mountedRef.current) {\n      mountedRef.current = true;\n      return;\n    }\n    setAnnouncement(\n      `rewritten: ${currentSpan.flagged}, request now ${passed ? \"allowed\" : \"blocked\"}`\n    );\n  }, [currentSpan.flagged, passed]);\n\n  function toggle(id: string) {\n    setActiveIds((prev) =>\n      prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]\n    );\n  }\n\n  function handleSend() {\n    if (!passed) return;\n    onResend?.({\n      request: currentSpan.before + currentSpan.flagged + currentSpan.after,\n      activeRemedies: activeIds,\n    });\n    setJustSent(true);\n    window.clearTimeout(sentTimeout.current);\n    sentTimeout.current = window.setTimeout(() => setJustSent(false), SENT_HOLD_MS);\n  }\n\n  return (\n    <div className={className}>\n      <style>{`\n.ns-rp-strike { transition: transform ${STRIKE_MS}ms cubic-bezier(0.16,1,0.3,1); }\n.ns-rp-fade { transition: opacity ${FADE_MS}ms cubic-bezier(0.16,1,0.3,1); }\n.ns-rp-chip { transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease; }\n.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); }\n@media (prefers-reduced-motion: reduce) {\n  .ns-rp-strike, .ns-rp-fade, .ns-rp-chip, .ns-rp-send { transition: none !important; }\n}\n`}</style>\n\n      <div className=\"rounded-md border border-border bg-surface p-4\">\n        <p className=\"text-sm leading-relaxed text-foreground\">\n          {currentSpan.before}\n          <span\n            role=\"mark\"\n            tabIndex={0}\n            aria-label={`flagged: ${reason}`}\n            className=\"relative inline-grid align-baseline rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60\"\n          >\n            {ghost !== null ? (\n              <span\n                aria-hidden\n                style={{ gridArea: \"1 / 1\" }}\n                className={\n                  \"ns-rp-fade pointer-events-none \" +\n                  (revealed ? \"opacity-0\" : \"opacity-100\")\n                }\n              >\n                {ghost}\n              </span>\n            ) : null}\n            <span\n              style={{ gridArea: \"1 / 1\" }}\n              className={\n                \"ns-rp-fade relative \" +\n                (ghost !== null ? (revealed ? \"opacity-100\" : \"opacity-0\") : \"opacity-100\")\n              }\n            >\n              {displayFlagged}\n              <span\n                aria-hidden\n                className=\"ns-rp-strike pointer-events-none absolute inset-x-0 top-1/2 h-[1.5px] bg-ns-muted\"\n                style={{ transform: `translateY(-50%) scaleX(${barScale})` }}\n              />\n            </span>\n          </span>\n          {currentSpan.after}\n        </p>\n\n        <p className=\"mt-2 text-sm text-ns-muted\">{reason}</p>\n\n        <p\n          data-redline-status={passed ? \"allowed\" : \"blocked\"}\n          className=\"mt-4 font-mono text-[11px] uppercase tracking-wide text-ns-muted\"\n        >\n          {passed ? \"Allowed — ready to resend\" : \"Blocked\"}\n        </p>\n\n        <div\n          role=\"group\"\n          aria-label=\"Narrowing levers\"\n          className=\"mt-2 flex flex-wrap gap-2\"\n        >\n          {remedies.map((r, i) => {\n            const active = activeIds.includes(r.id);\n            return (\n              <button\n                key={r.id}\n                type=\"button\"\n                role=\"switch\"\n                aria-checked={active}\n                data-redline-remedy={r.id}\n                data-redline-index={i}\n                onClick={() => toggle(r.id)}\n                className={\n                  \"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 \" +\n                  (active\n                    ? \"border-foreground bg-foreground text-background\"\n                    : \"border-border text-ns-muted hover:border-foreground/40 hover:text-foreground\")\n                }\n              >\n                <span\n                  aria-hidden\n                  className={\n                    \"h-1.5 w-1.5 rounded-full \" +\n                    (active ? \"bg-background\" : \"bg-ns-muted\")\n                  }\n                />\n                {r.label}\n              </button>\n            );\n          })}\n        </div>\n\n        <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </p>\n\n        <div className=\"mt-4 flex items-center gap-3\">\n          <button\n            type=\"button\"\n            id={`${uid}-send`}\n            disabled={!passed}\n            onClick={handleSend}\n            className={\n              \"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 \" +\n              (passed ? \"border-ns-accent\" : \"border-border\")\n            }\n          >\n            {justSent ? \"Sent\" : \"Resend request\"}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/refusal-negotiation.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "guardrail",
      "refusal",
      "negotiation",
      "toggle",
      "diff",
      "accessibility"
    ],
    "instruction": "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.",
    "rank": 288
  },
  "type": "registry:ui"
}