AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronsUpDown,
  LoaderCircle,
  Lock,
  Plus,
  RotateCcw,
  Shuffle,
  TriangleAlert,
  Undo2,
  X,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/persona-editor.json

Prompt

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; avatarSrc turns the same seed into a DiceBear or CDN image whenever the product wants real art.
  • Deterministic shuffle — the next face is hash(seed + salt), never Math.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.

On This Page