{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "guardrail-feed",
  "title": "Guardrail Feed",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A feed of what an agent's guardrails did: each check with its rule, action (blocked, redacted, flagged or allowed) and target, counts that double as filters, and rows that open to show why and the text that matched.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/guardrail-feed.tsx",
      "content": "// Ballmac UI: Guardrail Feed. https://ui.ballmac.com/components/guardrail-feed\n\"use client\"\n\nimport * as React from \"react\"\nimport { Ban, Check, ChevronDown, EyeOff, Flag, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype GuardAction = \"allowed\" | \"blocked\" | \"redacted\" | \"flagged\"\n\ntype GuardEvent = {\n  id: string\n  /** When it happened, already formatted, such as \"2:14:08 pm\". */\n  time: string\n  /** The rule that fired, for example \"No customer emails in replies\". */\n  rule: string\n  action: GuardAction\n  /** What was checked, for example \"Draft reply to ticket 4821\". */\n  target: string\n  /** Why the rule fired, in a sentence. */\n  reason?: string\n  /** The part that matched, shown in the opened row. */\n  match?: string\n}\n\nconst ACTIONS: Record<GuardAction, { icon: LucideIcon; fallback: string; tone: string }> = {\n  blocked: { icon: Ban, fallback: \"Blocked\", tone: \"border-destructive/50 text-destructive\" },\n  redacted: { icon: EyeOff, fallback: \"Redacted\", tone: \"border-chart-3/60 text-foreground\" },\n  flagged: { icon: Flag, fallback: \"Flagged\", tone: \"border-chart-5/60 text-foreground\" },\n  allowed: { icon: Check, fallback: \"Allowed\", tone: \"text-muted-foreground\" },\n}\nconst ORDER: GuardAction[] = [\"blocked\", \"redacted\", \"flagged\", \"allowed\"]\n\ntype GuardrailFeedProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Checks in the order to show them, newest first. */\n  events: GuardEvent[]\n  /** Heading. */\n  title?: string\n  /** The id of the row that is open at first. */\n  defaultOpenId?: string\n}\n\n/**\n * A feed of what an agent's guardrails did: every check with the rule, the action and what was checked, counts by action that double as filters, and\n * rows that open to show why and the part that matched. Actions are words and icons, never colour alone.\n */\nfunction GuardrailFeed({ events, title, defaultOpenId, className, ...props }: GuardrailFeedProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  title ??= msg(\"guardrail-feed.title\", \"Guardrail activity\")\n  const [filter, setFilter] = React.useState<GuardAction | \"all\">(\"all\")\n  const [open, setOpen] = React.useState<string | null>(defaultOpenId ?? null)\n  const count = (a: GuardAction) => events.filter((e) => e.action === a).length\n  const shown = filter === \"all\" ? events : events.filter((e) => e.action === filter)\n\n  return (\n    <section data-slot=\"guardrail-feed\" aria-labelledby={`${uid}-t`} className={cn(\"grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <h3 id={`${uid}-t`} className=\"text-sm font-medium text-foreground\">{title}</h3>\n        <div role=\"group\" aria-label={msg(\"guardrail-feed.filter\", \"Filter by action\")} className=\"flex max-w-full flex-wrap gap-1.5\">\n          {([\"all\", ...ORDER] as const).map((k) => {\n            const n = k === \"all\" ? events.length : count(k)\n            return (\n              <button\n                key={k}\n                type=\"button\"\n                aria-pressed={filter === k}\n                disabled={k !== \"all\" && n === 0}\n                onClick={() => setFilter(k)}\n                className={cn(\"h-8 rounded-full border px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none\", filter === k ? \"border-foreground bg-foreground text-background\" : \"hover:bg-accent\")}\n              >\n                {k === \"all\" ? msg(\"guardrail-feed.all\", \"All\") : msg(`guardrail-feed.${k}`, ACTIONS[k].fallback)} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{n}</span>\n              </button>\n            )\n          })}\n        </div>\n      </div>\n\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground tabular-nums\">\n        {msg(\"guardrail-feed.count\", \"{count} checks shown\", { count: shown.length })}\n      </p>\n\n      {shown.length === 0 ? (\n        <p className=\"rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground\">{msg(\"guardrail-feed.empty\", \"Nothing here yet.\")}</p>\n      ) : (\n        <ul className=\"divide-y overflow-hidden rounded-lg border\">\n          {shown.map((e) => {\n            const a = ACTIONS[e.action]\n            const Icon = a.icon\n            const isOpen = open === e.id\n            const hasMore = Boolean(e.reason || e.match)\n            return (\n              <li key={e.id} data-action={e.action}>\n                <button\n                  type=\"button\"\n                  disabled={!hasMore}\n                  aria-expanded={hasMore ? isOpen : undefined}\n                  aria-controls={hasMore ? `${uid}-${e.id}` : undefined}\n                  onClick={() => setOpen(isOpen ? null : e.id)}\n                  className=\"flex w-full items-center gap-3 px-3 py-2.5 text-start text-[13px] outline-none transition-colors hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset disabled:cursor-default disabled:hover:bg-transparent motion-reduce:transition-none\"\n                >\n                  <span className={cn(\"inline-flex w-[5.5rem] shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium\", a.tone)}>\n                    <Icon aria-hidden=\"true\" className=\"size-3\" />\n                    {msg(`guardrail-feed.${e.action}`, a.fallback)}\n                  </span>\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block font-medium text-foreground sm:truncate\">{e.rule}</span>\n                    <span className=\"block text-xs text-muted-foreground sm:truncate\">{e.target}</span>\n                    <time className=\"mt-0.5 block font-mono text-[11px] text-muted-foreground tabular-nums sm:hidden\" dir=\"ltr\">{e.time}</time>\n                  </span>\n                  <time className=\"hidden shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums sm:block\" dir=\"ltr\">{e.time}</time>\n                  {hasMore && <ChevronDown aria-hidden=\"true\" className={cn(\"size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none\", isOpen && \"rotate-180\")} />}\n                </button>\n                {hasMore && isOpen && (\n                  <div id={`${uid}-${e.id}`} className=\"grid min-w-0 gap-2 border-t bg-muted/30 px-3 py-3 text-[13px]\">\n                    {e.reason && <p className=\"leading-relaxed text-foreground\">{e.reason}</p>}\n                    {e.match && (\n                      <p className=\"rounded-md border bg-background px-3 py-2 font-mono text-xs [overflow-wrap:anywhere] text-muted-foreground\" dir=\"ltr\">\n                        {e.match}\n                      </p>\n                    )}\n                  </div>\n                )}\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </section>\n  )\n}\n\nexport { GuardrailFeed, type GuardAction, type GuardEvent, type GuardrailFeedProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/guardrail-feed.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "guardrails",
      "safety",
      "policy",
      "audit",
      "moderation",
      "feed"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "events is [{ id, time, rule, action: allowed | blocked | redacted | flagged, target, reason?, match? }] newest first. Counts by action are filter buttons; a row with reason or match opens to show them. defaultOpenId opens one row at first.",
      "whenToUse": [
        "Showing people what an agent's safety rules caught",
        "An audit view of policy checks on an assistant's output or tool use"
      ],
      "whenNotToUse": [
        "A full audit log of a whole product; use the audit block",
        "The live stream of an agent's steps; use trace or log-stream"
      ],
      "composesWith": [
        "permission-grid",
        "approval-card",
        "agent-lanes",
        "log-stream"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Filters are toggle buttons; a row's button opens and closes its detail (aria-expanded)"
        },
        {
          "keys": "Screen readers",
          "action": "Every action is written as a word with an icon; the count of shown checks is a polite live region"
        },
        {
          "keys": "Disabled filters",
          "action": "An action with no events is disabled instead of hidden, so the set of filters never moves"
        }
      ],
      "customization": [
        "events and their wording",
        "title",
        "defaultOpenId"
      ]
    },
    "statesNone": "A presentational feed: the events are passed in, so it has nothing of its own to load, and it shows its own empty message.",
    "examples": [
      "guardrail-feed-demo",
      "guardrail-feed-quiet"
    ],
    "url": "https://ui.ballmac.com/components/guardrail-feed"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}