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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/safety-controls.jsonPrompt
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
onSaveis 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.
Batch Jobs
A batch inference jobs table — middle-truncated copyable job ids, a stacked done/failed/pending bar per job, spend so far, age and ETA derived from an injected clock, an expandable failed-request preview, a one-shot cancel, partial-result downloads, and four data states.
Memory List
The things an assistant remembers about someone, as a list they can audit — inline editing behind a one-editor lock, a two-step bin that fires exactly once, an add box that refuses duplicates, per-memory and master switches, the "used in this chat" highlight, and four data states.