Persona Editor
An authoring form for one assistant persona: a seed-derived face with a deterministic shuffle, trait chips that refuse rather than truncate, a tone select, budgeted instructions, a preview bubble recomposed on every keystroke, and a save bar that names which fields moved.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/persona-editor.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PersonaEditor" component: one form that
authors ONE assistant persona — name, face, behaviour chips, tone and the
instructions it carries into every turn — with an unsaved bar that owns Save and
Cancel. Uses lucide-react for icons and the `radix-ui` package for the tone
Select; everything else is hand-rolled.
Contract
- forwardRef<HTMLFormElement>, extends React.FormHTMLAttributes<HTMLFormElement>
minus onSubmit / onChange / defaultValue / children / title.
- PersonaValue = { name: string; avatarSeed: string; traits: string[];
tone: string; systemPrompt: string }. Export it — the caller stores this shape.
- PersonaTone = { id, label, description?, voice }. `voice` is the FIRST-PERSON
sentence the preview speaks in that tone; `description` is the muted line in
the menu. Ship five defaults (neutral / concise / warm / socratic / rigorous)
and export the array.
- value?: PersonaValue — the SAVED baseline, the thing your backend holds.
defaultValue?: PersonaValue for the uncontrolled path; defaultDraft?: restore
an in-progress draft.
- status?: "idle" | "saving" | "error" (default "idle"), errorMessage?: string.
- onDraftChange(draft), onSave(next), onCancel(discarded), onSeedShuffle(seed).
- tones?, traitSuggestions?, maxTraits = 8, maxTraitLength = 28,
nameMaxLength = 40, tokenBudget?, countTokens = estimatePersonaTokens,
minRows = 4, maxRows = 14, showPreview = true, title, description,
disabled = false, readOnly = false.
- avatarSrc?: (seed: string) => string | undefined — upgrade the built-in
identicon to a real image (DiceBear, your CDN).
- generateSeed?: (current: string) => string, composeGreeting?: (draft, tone) =>
string, fallbackName = "your new assistant".
Behavior — the face is a seed
- Store a SEED, never a URL: it survives a JSON round trip, costs zero network
requests, renders identically on the server and in a screenshot, and can be
re-rendered by any avatar service later.
- Default rendering is a local identicon: FNV-1a hash the seed, use fifteen of
its bits to fill the left half of a 5x5 grid, mirror it, and pick the tint
from bits the grid did not use so two seeds with the same shape still differ.
- Shuffle is a DETERMINISTIC walk — nextSeed = hash(seed + salt).toString(36) —
not Math.random(): a random seed differs between the server render and the
first client render (hydration mismatch) and makes every screenshot unique.
Pressing shuffle three times from the same start must always land on the same
third face.
- Show the seed under the avatar in a mono caption, so a shuffle is legible as a
value change and not just a picture change.
- Never fall back to initials when the name is filled in: a monogram would make
the shuffle button look broken the moment the persona has a name. A broken
`avatarSrc` image falls back to the identicon, not to a torn icon.
Behavior — trait chips
- Chips are the value; the text input is only a staging area. Enter or a comma
commits, a paste of "a, b, c" becomes three chips, Backspace on an empty box
removes the last chip, Escape clears the staging text (stopPropagation so a
surrounding dialog does not close), blur commits — a typed-but-not-entered
trait is work and must not be lost to a click elsewhere.
- Enter MUST preventDefault: letting it bubble submits the form and saves a
persona the user was still writing.
- Refuse, never truncate. A duplicate (case-insensitive) flags the chip that
already exists; an over-long entry and an over-full list bounce back INTO the
input with a reason, so the text can be edited instead of retyped. Every
refusal clears itself after ~3s and is repeated in the live region.
- Whoever pressed decides who owns the staging text: a suggestion button must
never clear a half-typed trait, because the browser may not have blurred the
input first. Pass a `fromInput` flag through the commit path.
- At the ceiling the input goes readOnly, NOT disabled: it stays focusable and
announced, so the limit is discoverable instead of a control that vanished
from the tab order. Suggestion chips already added hide themselves.
Behavior — tone, instructions, preview
- Tone is a real listbox (Radix Select), not a menu. Render your own trigger
content rather than <Select.Value> so a tone id with no matching option can be
shown as `id · unknown tone` and KEPT — silently snapping a stale record to
the first option rewrites data the user never touched.
- Give the trigger aria-labelledby="<label id> <value id>" so it is announced as
"Tone, Concise" instead of only the label.
- Instructions are a plain <textarea> that autosizes: measure lineHeight +
padding + border, clamp between minRows and maxRows, then switch overflow-y to
auto. DOM writes only, so it belongs in a layout effect and lands before paint.
A ResizeObserver re-fits on WIDTH changes only — reacting to the height you
just wrote is an observer loop.
- Token estimate with no vocabulary shipped: a word-ish run costs one token per
~4 characters, every other non-space character costs one (CJK lands near one
per glyph). Print it as `~1,204 tokens` and let `countTokens` swap in the real
tokenizer.
- The preview bubble is a pure RENDERING of the draft — name + tone.voice +
"Expect me to be <traits>" — recomposed on every keystroke and never stored.
Fold a trait's first letter to lowercase only when its tail is already
lowercase, so "SQL first" survives while "Concise" reads correctly mid
sentence. With no name yet, use the fallback identity and mute the bubble.
Behavior — the save contract
- Dirtiness is computed PER FIELD against the baseline (join arrays on U+0000 so
["a b"] and ["a","b"] can never collide). The bar lists which fields moved
("Unsaved: name, traits") and every changed field gets its own Reset button:
a persona is five independent decisions, so "keep my new instructions, put the
old name back" must be one press.
- Blockers, in order: empty name, name over its budget, instructions over the
token budget. Save is disabled and the bar states the reason; nothing is ever
trimmed on the user's behalf.
- Save is one-shot: a ref lock that a double click cannot get past, released by
the next edit, the next landed baseline or a status change. Normalise (trim
the name) into BOTH the emitted value and the local draft, otherwise a
trailing space keeps the form dirty forever.
- Ctrl/Cmd+S and Ctrl/Cmd+Enter save. preventDefault stops the browser's Save
Page dialog; stopPropagation stops an app-level handler saving it twice.
- Cancel reverts immediately and leaves an Undo for a few seconds instead of
asking "are you sure?"; any later edit invalidates that Undo, and focus is
moved to whatever button replaced the one the user was standing on.
- Baseline drift: when `value` changes underneath, a CLEAN form follows it, a
DIRTY one keeps the draft and raises "Changed elsewhere — saving replaces that
copy". Track a savePending flag so your own landing save flashes "Saved"
instead of being reported as someone else's write, and clear that flag when
status flips to "error".
- status="error" keeps every character on screen and adds one destructive strip.
readOnly removes the bar, the shuffle and every chip's remove button but keeps
the text selectable; disabled dims the whole card.
- Clean up every timer (flash, refusal notice) and the ResizeObserver on unmount.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, bg-muted
and bg-muted/60 for chips, the identicon disc and the save bar, border /
border-input / border-destructive, text-muted-foreground, text-destructive,
bg-destructive/10, bg-primary for the unsaved dot, ring / focus-visible:ring-3
ring-ring/50 for focus, and var(--chart-1..5) for the identicon tint. No hex,
no rgb(), no oklch().
- cn() merges every className, including the consumer's on the root.
- Layout: header strip, then a wrapping identity row (avatar column + a
`repeat(auto-fit, minmax(min(11rem,100%),1fr))` grid holding Name and Tone),
then traits, instructions, preview, then the bar pinned at the bottom of the
card. min-w-0 on every flex child so a long instruction line cannot widen it.
- Motion: the save bar slides in under motion-safe only, the shuffle glyph turns
180 degrees per press through a CSS transition (state counter, no timer) with
motion-reduce:transition-none, the Select popup animates with
motion-reduce:animate-none. Nothing stops working when motion is off.
- A11y: the form is aria-labelledby its heading and aria-busy while saving;
every field has a real <label> (the tone trigger uses aria-labelledby); each
remove button is labelled "Remove trait X"; the identicon and the preview face
are aria-hidden because the name is already text; a single sr-only
role="status" region carries saving / error / refusal / blocked / dirty in
that priority order, so nothing is announced twice.
Customization levers
- Fields: `tones` and `traitSuggestions` are data — a support desk, a tutor or a
code reviewer is a prop, not a fork. Drop the preview with showPreview={false}
when the surrounding page already renders the persona.
- Face: pass avatarSrc={seed => `https://api.dicebear.com/9.x/bottts-neutral/
svg?seed=${seed}`} for DiceBear, or your own CDN; swap generateSeed for a
human-readable word list; change the identicon grid to 6x6 or the tint pool to
a subset of the chart tokens.
- Budgets: nameMaxLength, maxTraits, maxTraitLength, tokenBudget. Leave
tokenBudget undefined for a counter with no ceiling; pass countTokens to use
the model's real tokenizer.
- Voice of the preview: composeGreeting(draft, tone) replaces the whole sentence
— return the compiled system prompt instead if your product previews the
payload rather than the persona.
- Save ergonomics: drop the per-field Reset buttons for a leaner bar, replace
the undoable Cancel with an arm-then-confirm Discard, or lift `draft` into the
parent through onDraftChange for autosave.
- Density: minRows / maxRows for the instructions, and the card's px-4 py-4
rhythm plus text-sm are the only spacing knobs — override through className.Concepts
- Seed, not URL — the persona stores a seven-character seed and the face is derived from it, so shuffling costs no upload, no request and no migration;
avatarSrcturns the same seed into a DiceBear or CDN image whenever the product wants real art. - Deterministic shuffle — the next face is
hash(seed + salt), neverMath.random(): the server and the first client render agree, and a screenshot taken after three presses is reproducible forever. - Per-field dirtiness — the diff is computed field by field rather than on the whole object, which is what lets the bar say which decisions moved and lets each one be reverted alone instead of forcing an all-or-nothing Cancel.
- Refuse, don't truncate — a duplicate, an over-long trait or a full list bounces back into the input with a reason; budgets on the name and the instructions block Save instead of trimming, because quietly eating something a user typed is worse than refusing it loudly.
- Staging-text ownership — the chip box is a staging area, and only a commit that came from the box may clear it; a suggestion press must not wipe a half-typed trait, since the browser may not have blurred the input first.
- Baseline drift — when the saved copy changes underneath, a clean form follows it and a dirty one keeps the draft and warns that saving will replace the other copy; unsaved work is never overwritten just to stay in sync.
Fallback Notice
An inline "answered by a backup model" notice — the failed primary and the model that answered as a chip chain, reason-class copy, and a one-shot retry-with-primary button that suppresses itself when a retry cannot help.
Content Credentials
A C2PA-flavoured provenance panel — a four-rung signature verdict, the signing issuer, the created/edited/exported chain, an expandable raw manifest, and a compact chip that opens the whole thing in a popover.