AI

Moderation Notice

Content-policy interruption states for chat — blocked input, withheld response and softened answer, with category chips that cross-highlight the flagged passage, a one-shot appeal action, and role=alert only when something was actually stopped.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ExternalLink, EyeOff, Flag, LoaderCircle, PenLine, Scissors, ShieldAlert, X } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"

/* -------------------------------------------------------------------------- *
 * Moderation Notice
 *
 * A content policy becomes visible at exactly three moments, and they are three
 * different truths — which is why they are three variants of ONE component and

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/moderation-notice.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ModerationNotice" component: the panel a
chat shows when a content policy interrupted something. Dependencies:
lucide-react, plus shadcn/ui Badge and Button.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus "title" (the prop is a ReactNode headline, not a tooltip).
- variant?: "blocked-input" | "blocked-output" | "softened-output"
  (default "blocked-output"). The three are ONE component because the DOM
  skeleton, the slots and the recovery model are identical; only the role, the
  chrome and the default copy differ.
- title?: ReactNode, reason?: ReactNode — undefined means "use the variant
  default", null means "render nothing".
- categories?: readonly { id: string; label: string; score?: number }[]
- showScores?: boolean (default false).
- excerpt?: string — the flagged text quoted back.
- spans?: readonly { start: number; end: number; categoryId?: string }[] —
  ranges inside excerpt, half-open [start, end).
- excerptLabel?, suggestionLabel? (default "Try instead"), useSuggestionLabel?
  (default "Use this instead"), editLabel? (default "Edit message"),
  appealLabel? / appealPendingLabel? / appealSentLabel?, policyLabel?
  (default "Content policy"), caseIdLabel? (default "Reference"),
  dismissLabel? — every visible string is a prop; nothing is hardcoded English
  inside the tree.
- suggestion?: ReactNode, onUseSuggestion?: () => void, onEdit?: () => void,
  onAppeal?: () => void, actions?: ReactNode (extra buttons in the same row).
- appealStatus?: "idle" | "pending" | "sent" — controlled; omitted means the
  component owns it.
- policyHref?: string, caseId?: string, announce?: boolean (default true),
  autoFocusAction?: boolean (default false), icon?: ReactNode (null = none).
- dismissible? / dismissed? / defaultDismissed? / onDismiss? — the usual
  controlled + uncontrolled pair.
- children?: ReactNode — the content the notice governs, rendered BELOW the
  notice box (the partially filtered answer under a softened-output note).

Behavior — which moment is this
- blocked-input: the message never reached the model. Copy must say so
  explicitly ("Nothing left this device"), because the user's first fear is
  that it was sent AND logged. Useful content: the flagged span plus a rewrite.
- blocked-output: an answer existed and was withheld. The user did nothing
  obviously wrong, so this variant owes them categories, an appeal route and a
  reference id.
- softened-output: an answer is on screen with parts removed. It is an
  annotation, not an event.

Behavior — announcement
- Root has no role. The notice BOX carries role="alert" for the two blocking
  variants (when announce is true) and role="note" otherwise. Assertive
  interruption is earned by "the thing you asked for did not happen"; a
  softened answer is already being read, so shouting over it is the second
  harm.
- announce={false} downgrades a blocking notice to role="note" — use it when
  the surrounding message list already owns a live region, so the same words
  are not spoken twice.
- Edge case to document for consumers: a role="alert" element that is present
  in the very first paint is often NOT announced. Mount the notice in response
  to the send/generate result, not with the page.
- role="alert" implies aria-atomic, so any later change inside re-announces the
  whole notice. The appeal confirmation therefore lives in its own
  <p role="status">, mounted from the FIRST render and simply empty until the
  appeal lands (sr-only while empty, so it contributes no flex gap). Two
  reasons: a live region inserted together with its text is routinely missed,
  and the innermost live region governs the update — the user hears "Review
  requested" alone instead of the entire block again.

Behavior — spans and cross-highlight
- Normalise spans before rendering: truncate to integers, clamp both ends into
  [0, excerpt.length], drop anything where end <= start (which also removes NaN
  offsets for free, since every comparison with NaN is false), sort by start
  then end, and resolve overlaps first-writer-wins by tracking a cursor and
  letting each span claim only the part beyond it. The output is a flat list of
  segments; the excerpt always renders in full, whatever the detector sent.
- A category chip is a <button aria-pressed> only when at least one surviving
  span carries its id; otherwise it renders as an inert Badge. A control that
  looks pressable and does nothing is worse than a label.
- Pressing a chip sets the active category: its passages keep the destructive
  tint, every other flagged passage falls back to muted. Pressing again clears
  it. Derive "active" from state ∩ available ids rather than storing it, so a
  category list that changes underneath cannot leave every passage dimmed.
- Add one sr-only line under the excerpt ("2 highlighted passages"): <mark> is
  announced inconsistently and the count is the part that matters.

Behavior — recovery and appeal
- onEdit and onUseSuggestion are slots, not behaviour: the component never
  touches the composer. Wire onEdit to focus the input and select the flagged
  range (you already have the offsets), and onUseSuggestion to replace the
  draft with the suggestion.
- The suggestion block renders whenever `suggestion` is set; the accept button
  only renders when onUseSuggestion is also set, so read-only guidance stays
  read-only.
- Appeal is a state machine: idle -> (click) -> sent when uncontrolled, or
  idle -> pending -> sent when the consumer owns appealStatus. `pending`
  disables the button and shows a spinner; `sent` replaces the button entirely
  with a check + confirmation line.
- One-shot lock: a ref guards onAppeal so a double-click, or an impatient click
  while a controlled consumer is still writing to the network, cannot file two
  reports. An effect clears the lock when the status returns to idle (new
  message, reset), which is the only way back.
- The dismiss button uses the same one-shot pattern for onDismiss: an async
  persistence hook must not be written twice.
- autoFocusAction moves focus to the first action button once per mount, guarded
  by a ref so re-renders never yank focus back. Off by default — it is only
  right immediately after the user pressed Send.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, bg-muted/50, border,
  border-destructive/30, text-foreground, text-muted-foreground,
  text-destructive, bg-destructive/10 on the flagged <mark>, text-primary on
  the policy link, ring for focus. No hex, no rgb(), no oklch().
- Tone is the product decision: blocking variants are a normal card with a
  tinted BORDER and a destructive icon — no red fill, no exclamation wall.
  softened-output is quieter still (bg-muted/40, muted icon, smaller title).
  Copy states what the system did and what to do next; it never characterises
  the user ("you violated" is banned).
- Scores render as a three-step bar (>=0.8 high, >=0.5 medium, else low) with
  the word in sr-only text. Never print a raw classifier percentage: four
  digits read as a verdict the number cannot support.
- Entrance is one hoisted @keyframes (React 19 <style href precedence>), a 4px
  rise over 160ms, disabled with motion-reduce:[animation:none]. Nothing about
  the notice depends on it.
- Accessibility: icons aria-hidden; the dismiss button carries aria-label; the
  policy link gets target=_blank + rel="noopener noreferrer" only for http(s)
  hrefs; every interactive element keeps a focus-visible ring; chips are
  reachable and toggled from the keyboard because they are real buttons.
- Layout: min-w-0 everywhere down the flex chain and wrap-anywhere on the
  excerpt, so an unbroken 200-character prompt cannot widen the thread.

Customization levers
- Tone dial: swap border-destructive/30 for a plain border and the destructive
  icon for a muted one to get a fully neutral treatment; or push the other way
  with bg-destructive/5 on the notice box if your product wants more weight.
- Slots you can drop entirely: categories, excerpt+spans, suggestion, appeal,
  policy footer, caseId. The minimum useful notice is title + reason.
- Excerpt length: pass the flagged sentence, not the transcript. If you must
  show a long draft, add line-clamp to the excerpt paragraph plus a
  show-more toggle rather than a fixed height that silently cuts.
- Density: the variant maps (noticeClass / iconClass / titleClass) are three
  one-line lookups — change padding, radius and type scale there instead of
  spraying overrides through the tree.
- Copy: every string is a prop; localise by passing them, or change the
  defaults in one COPY record keyed by variant.
- Score bands: move the 0.8 / 0.5 thresholds, or render the number if your
  audience is a trust-and-safety reviewer rather than an end user.
- Cross-highlight: if a chip should filter a LIST of flagged messages instead
  of spans inside one excerpt, keep the pressed-chip state and swap what reads
  it — the chip contract does not change.
- announce={false} plus a sibling live region is the right combination inside a
  virtualised message list.

Concepts

  • Interception vs annotation — the variant is chosen by what actually happened to the content, not by how severe it felt: two variants report that something did not arrive (assertive, role="alert"), the third annotates something that did (role="note"), and that single distinction decides whether a screen reader is interrupted.
  • Span normalisation — detector offsets arrive reversed, out of bounds, duplicated or NaN; they are truncated, clamped, dropped when empty and resolved first-writer-wins against a moving cursor, so the quoted text always renders in full no matter what the classifier sent.
  • Chip ↔ span cross-highlight — a category only becomes a pressable filter when a surviving span carries its id; pressing it keeps that category's passages tinted and dims the others, which turns a list of policy labels into a way of reading the flagged sentence.
  • One-shot appeal lock — a ref swallows every emission after the first, so a double-click (or an impatient click while a controlled consumer is still writing to the network) cannot file two reports; the lock reopens only when the status returns to idle.
  • Inner live regionrole="alert" re-announces its whole subtree on any change, so the appeal confirmation sits in its own role="status" element that is mounted empty from the first render (a region inserted together with its text is routinely missed); the innermost live region governs the update, so the user hears "Review requested" instead of the entire notice again.
  • Recovery over verdict — the component's centre of gravity is the way out (a concrete rewrite, an edit that selects the flagged range, an appeal, a policy link), which is why the destructive token is limited to a border, an icon and the flagged passage.

On This Page