AI

Safety Controls

Per-category content thresholds on a four-rung scale — a plain-language line for what each rung actually does, strictness presets that batch-set every row, policy floors and locked rows, and an unsaved-changes bar that names the filters you are about to loosen.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  CircleAlert,
  LoaderCircle,
  Lock,
  RefreshCcw,
  RotateCcw,
  ShieldAlert,
  ShieldCheck,
  ShieldOff,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/safety-controls.json

Prompt

Build a React + TypeScript + Tailwind "SafetyControls" component with zod and
lucide-react. No other runtime dependency: the scale, the presets and the
unsaved-changes bar are hand-rolled from semantic tokens.

Contract
- A zod schema in a sibling contract file is the single source of truth. The
  scale is a FOUR-RUNG ORDERED enum — ["none","few","some","most"] — exported
  together with `safetyLevelIndex(level)` and `clampSafetyLevel(level, min)`.
  The order is load-bearing: it is what makes "this change loosens the filter"
  computable. Do not model it as an unordered set of options, and do not model it
  as a 0-100 slider — classifiers emit severity bands, not percentages.
- One category: { id, label, description?, level, defaultLevel?, minLevel?,
  minReason?, locked?, lockedReason?, effects?, updatedBy?, updatedAt? }.
  `level` is the SAVED value and the baseline every draft is diffed against; the
  component never mutates it. `effects` is an object with four optional strings —
  bespoke wording per rung for this category, falling back to a generic sentence.
  `updatedAt` is a PRE-FORMATTED string: the panel owns no clock, so the server,
  the browser and a screenshot fixture always agree.
- The draft is a SPARSE OVERLAY: Record<categoryId, level> holding a key only
  while it differs from that category's saved level. It is already the PATCH
  body. Moving a row back onto its saved level deletes the key; an empty object
  means "nothing to save".
- A preset is data too: { id, label, description?, level, overrides? } — `level`
  applies to every category, `overrides` names the exceptions. Ship relaxed /
  standard / strict as a default export and let the caller replace them.
- Envelope status — "loading" | "empty" | "error" | "ready" — is the panel's own
  render state, independent of any category's level.
- Props: status; categories; value? (controlled sparse overlay);
  onValueChange?(next, changes); onSave?(next, changes); onDiscard?; saving?;
  disabled?; presets? (default: the three above, [] hides the row);
  defaultPresetId? ("standard"); levelMeta? (re-word any rung); heading?;
  description?; showEffects? (true); emptyState?; errorMessage?; onRetry?;
  label?; saveLabel?; className plus the rest of the element props, ref forwarded
  to the <section>.

Behavior
- Each row is a REAL radiogroup: role="radiogroup" + role="radio" with
  aria-checked, one roving tab stop, Arrow keys wrapping in both directions,
  Home/End, and selection following focus. Four exclusive ordinal choices are a
  radio group, not a toggle bar — a toggle bar would announce four independent
  pressed states for one value.
- Each rung's accessible name carries its OUTCOME, not just its label ("Block
  severe only. Only the most severe content is stopped; borderline material still
  gets through."), so arrowing through the group tells a screen-reader user what
  each choice would do — which is exactly what the sighted user reads off the
  effect line under the scale.
- POLICY FLOORS ARE A PROPERTY OF THE CONTROL, not a validation message. Rungs
  below `minLevel` are struck through, aria-disabled, ignored by the click
  handler AND removed from arrow-key traversal. Presets and reset clamp up to the
  floor instead of failing. If the SAVED level is below a floor added later, the
  row arrives already staged for the raise and the unsaved bar opens with a
  change nobody made — hiding that conflict would leave the account out of policy
  indefinitely.
- `locked` rows are not editable by ANY path: not by clicking, not by a preset,
  not by reset, not by discard. A preset is not a master key. The lock always
  renders its reason; a lock without a reason reads as a bug.
- Presets use aria-pressed, not aria-checked, and "pressed" is DERIVED — a chip
  reads pressed when applying it would produce exactly the current draft, so
  nudging one row makes every chip let go by itself and the panel falls back to
  "Custom". No preset state is stored anywhere.
- Every dirty row shows its direction as a SHAPE (arrow up / arrow down) plus the
  previous rung, and an undo button that returns it to the level it arrived at
  (which is the floor, not the illegal saved value, on a floored row). The save
  bar repeats the count and names the loosenings separately: "3 categories
  changed, 1 loosens filtering". Tightening a safety filter is routine; loosening
  one is the move that deserves a second look.
- SAVE IS ONE-SHOT. The guard is a ref read and written synchronously inside the
  click handler, because several clicks dispatched in one task all observe the
  same stale state and a state-only guard would POST the change set twice. The
  lock is keyed to a generation that ticks when the baseline moves (the write
  landed) and when an in-flight save ends (`saving` falling back to false, i.e. a
  rejection) — so it expires by itself instead of leaving a dead button.
- Buttons use aria-disabled, never the native `disabled` attribute: `disabled`
  blurs the control the instant it flips, dropping focus to <body> mid-action,
  and removes it from the tab order while it is still worth reading.
- Baseline changes reset the draft by adjusting state during render (no effect),
  so there is never a frame in which the previous account's overlay is applied to
  freshly loaded categories. The baseline identity includes the floors and the
  locks, because a policy tightened server-side is a new baseline.
- Unknown ids in the incoming value, entries equal to the baseline and entries on
  locked rows are all dropped before the payload leaves the component.
- The panel starts no timers, no observers and no listeners, so there is nothing
  to clean up: `saving` is a prop, not an internal countdown.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary + text-primary-foreground for the selected rung and the pressed
  preset, bg-primary/10 for the filled trail behind the selection, bg-muted/40
  for the scale track and the save bar, text-destructive for the "blocks nothing"
  warning and for every loosening. No hard-coded colours anywhere.
- The rungs BEFORE the selected one are filled, which is what makes four buttons
  read as one scale instead of four unrelated options.
- Colour never carries meaning alone: direction is an arrow, "blocks nothing" is
  a triangle icon, an unreachable rung is struck through, a locked row is a lock
  icon plus the word.
- One persistent sr-only role="status" line lives in the whole ready branch and
  states the dirty summary — a bar that appears at the same instant as its own
  live region is usually not announced at all.
- Long category names and descriptions use `wrap-anywhere` plus `min-w-0`, not
  `break-words`: only the former lowers the element's min-content width, which is
  what stops a 90-character custom classifier from making the panel wider than
  its container.
- Every transition and the save bar's entrance are motion-reduce guarded; the
  skeleton stops pulsing under prefers-reduced-motion and still reads as loading
  through aria-busy.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Wording: `levelMeta` re-words any rung (short label / full label / fallback
  effect) — swap "Nothing, Severe, Moderate, Most" for provider vocabulary such
  as BLOCK_NONE … BLOCK_LOW_AND_ABOVE without touching the logic. Per-row
  `effects` overrides the sentence for one category only.
- Policy: `presets` replaces the three profiles wholesale (add "compliance", drop
  "relaxed", or pass [] to hide the row); `defaultPresetId` decides what "Reset
  to defaults" means for categories with no `defaultLevel` of their own.
- Density: `showEffects={false}` drops the plain-language line, `heading={null}`
  drops the whole header block, `presets={[]}` drops the chip row — a compact
  read-only summary is three props away from the full editor.
- Save model: omit `onSave` and no Save button is rendered (the bar still counts
  and still discards), which is the right shape for a wizard that saves at the
  end. Drive `saving` from your mutation to lock the bar and to re-arm the gate
  on rejection.
- Control: pass `value` + `onValueChange` to lift the draft into a form library,
  or leave both out and let the panel hold it. `disabled` turns the whole thing
  into a readable, keyboard-reachable, non-editable view for members without the
  permission.

Concepts

  • Sparse draft overlay — the panel stores only the categories that differ from what the server has, so "changed" is derived instead of tracked, the object handed to onSave is already the PATCH body, and dragging a row back to where it started deletes the key rather than resending the current value as if it were an edit.
  • An ordered scale, so changes have a direction — four rungs in severity order make "you are loosening this filter" computable. Every dirty row shows an arrow, and the save bar counts the loosenings separately from the tightenings, because those two are not the same review.
  • Policy floor as a property of the control — an unreachable rung is unreachable by pointer and by keyboard, presets clamp up to it rather than failing, and a saved value sitting below a floor added later arrives pre-staged for the raise. A floor enforced only as an error after Save leaves the account out of policy in the meantime.
  • A preset is an action with a derived pressed state — clicking one batch-sets every unlocked row; whether it reads pressed is recomputed from the draft, so a single nudged row drops the panel back to "Custom", with no preset state stored anywhere and no chip left claiming a configuration that is no longer true.
  • Effect wording is the control's accessible name — each rung's name carries what it would actually do, not just its label, so a screen-reader user arrowing through the group learns the same thing a sighted user reads off the line under the scale.
  • One-shot save gate that expires by itself — the lock is a ref read synchronously inside the handler (five clicks in one task all observe the same stale state), keyed to a generation that ticks when the baseline moves or when an in-flight save ends, so a rejected write re-arms the button instead of stranding it.

On This Page