AI

Persona Picker

Choose the assistant persona a turn runs under — seeded avatar cards or a compact combobox, capability tags, the system-prompt fragment each persona quietly injects, unavailable rows that say why, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, ChevronsUpDown, Lock, Plus, RefreshCcw, UsersRound } from "lucide-react"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { PersonaPickerItem, PersonaPickerStatus } from "./persona-picker.contract"

/* ------------------------------------------------------------------ *
 * Seed → face
 *
 * The contract carries a SEED, not a URL, so the default face costs zero
 * network requests and is identical on the server, in the browser and in a

Installation

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

Prompt

Build a React + TypeScript + Tailwind "PersonaPicker" component with zod and
lucide-react, plus a shadcn Tooltip for the one icon-only affordance.

Contract
- A zod schema (`personaPickerItemSchema` in a sibling contract file) is the
  single source of truth for ONE persona: { id, name, tagline, avatarSeed,
  tags: string[], systemHint?, badge?, unavailable?: { reason } }.
- `avatarSeed` is a SEED, not a URL. Storing a seed is what makes the same
  persona keep the same face across sessions, servers and screenshots, and it
  lets the component draw a face with zero network by default. `avatarSrc?:
  (persona) => string | undefined` upgrades the seed to a real image (DiceBear,
  your own uploads); returning undefined keeps the built-in monogram.
- `systemHint` is the honest field: the system-prompt fragment this persona
  prepends. A picker that hides it is asking the user to choose a personality by
  its haircut.
- `unavailable` is an OBJECT with a required `reason`, not a boolean. A persona
  the user cannot pick is only honest if it says why ("Team plan only"); making
  the reason part of the type means you cannot forget it.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  LIST's own state, independent of any persona's availability: `status="error"`
  means the roster failed to load, while a persona your plan can't call is a
  `ready` list with an `unavailable` row in it.
- Props: items: Persona[]; status; value? (controlled, null = nothing selected);
  defaultValue? (uncontrolled seed); onValueChange?(id, persona) — it hands back
  the whole persona so the caller never has to look it up; variant?: "cards" |
  "compact" (default "cards"); columns?: 1 | 2 | 3 (cards only); maxTags? (3,
  clamped >= 1); avatarSrc?; showSystemHint? (true); onCreate?; createLabel?;
  placeholder?; emptyState?; emptyMessage?; errorMessage?; onRetry?;
  skeletonCount? (3, clamped >= 1); label? ("Assistant persona"); className plus
  the remaining div props, ref forwarded to the root.

Behavior
- ONE selection contract, TWO interaction models, and they are not
  interchangeable. `cards` is a role="radiogroup" of role="radio" tiles with a
  roving tabindex: Tab lands on the chosen card, Arrow keys move focus AND
  select in one step (radio semantics), Home/End jump to the ends, and the ring
  WRAPS at both ends. `compact` is a role="combobox" trigger over a
  role="listbox" popup driven by aria-activedescendant: focus never leaves the
  trigger, ↓/↑ move the active option and CLAMP at the ends (a wrapping list
  makes "am I at the bottom?" unanswerable), Enter/Space commits, Escape closes
  and keeps the committed value, Tab closes and lets focus move on.
- Controlled and uncontrolled both work: `value` present = the parent owns it;
  otherwise an internal state moves and `onValueChange` still fires. Committing
  is the only write — browsing a list never mutates the value, so Escape
  restores the selection by construction instead of from a snapshot.
- A committed id that is NOT in `items` (someone deleted the persona) is
  surfaced, never silently reassigned: the cards variant prints a note naming
  the missing id, the compact trigger shows the raw id over "Not in this persona
  list". Auto-picking a neighbour would overwrite a user's choice with a guess.
- An `unavailable` persona stays reachable — arrow keys land on it so its reason
  is announced — and simply never commits. Clicking it is a no-op, not a silent
  write, and Enter inside the listbox leaves the panel open so the reason can be
  re-read.
- Tags are capped at `maxTags` with an explicit "+N", and the FULL list always
  reaches the accessibility tree through the row's accessible name, so folding
  is a layout decision and never a loss of information.
- Selecting must not reflow: the check mark occupies its box whether or not it
  is checked, so a grid of cards does not jump one pixel when the choice moves.
- The face degrades in one step: `avatarSrc` → <img>, an image that fails
  onError → a monogram tinted from a hash of the seed. The failure flag is reset
  by adjusting state DURING RENDER when the URL changes, so a swapped avatar
  gets a fresh attempt instead of inheriting the previous one's failure.
- The compact panel flips above the trigger when there is not enough room below.
  The measurement runs inside a requestAnimationFrame (the panel is already
  mounted, so it measures a real height instead of guessing) and the frame is
  cancelled on unmount. An outside `pointerdown` — not click — closes it, so the
  panel is gone before the press lands on whatever is underneath. Closing
  restores focus to the trigger; leaving focus on a node that is about to
  unmount drops it to <body>.
- The panel can never outlive its branch: switching variant or losing the data
  closes it by adjusting state during render, so `aria-controls` never points at
  an id that is no longer in the document.
- The "create a persona" affordance lives OUTSIDE both containers on purpose —
  a full-width dashed tile under the radiogroup (a radiogroup owns radios, and a
  button that answers aria-checked would be a lie), and a sibling icon button
  next to the compact trigger (a listbox owns options, and a button inside the
  popup is unreachable while focus stays on the combobox). It also appears
  inside the empty state, where creating one IS the whole point of the panel.
- Four first-class states, not `&&` afterthoughts: loading = placeholder cards
  shaped like the real ones plus an sr-only role="status"; empty (also used when
  `ready` arrives with an empty array, because a control with a border and no
  rows reads as broken) = an explanation plus the create affordance; error =
  role="alert" with a whitespace-preserving message and an optional Retry;
  ready = the grid or the combobox.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  border-primary + bg-primary/5 + ring-primary for the chosen card, bg-primary +
  text-primary-foreground for the check mark, bg-accent/40 for hover,
  bg-popover / text-popover-foreground for the panel, border-destructive/40 +
  bg-destructive/5 for the error envelope. The monogram takes its colour from
  var(--chart-1..5), indexed by a hash of the seed — swap the chart tokens and
  every face re-skins with the theme. No hard-coded colours anywhere.
- Columns are CONTAINER queries (@container/personas + @xl/... + @4xl/...), not
  viewport breakpoints: the picker reacts to the width it was given, so it
  behaves the same in a 320px sidebar and in a full-width settings page.
- Long names and taglines use `wrap-anywhere` (overflow-wrap: anywhere) plus
  `min-w-0`, NOT `break-words`: only the former lowers the element's min-content
  width, which is what actually stops a 45-character persona name from widening
  the whole grid. The compact trigger is the one place text truncates, and
  truncation there is CSS-only — the full string stays in the DOM and in the
  accessibility tree.
- A single persistent sr-only role="status" spells out the current persona,
  mounted for the whole ready branch so a CHANGE is announced from an element
  the screen reader is already watching. Each card's accessible name is one
  sentence — name · badge · tagline · skills · why it is unavailable.
- The avatar is aria-hidden: the row already names the persona, and a second
  announcement of the same name is noise. Unavailable rows use aria-disabled,
  never the native `disabled` attribute, which would remove them from the tab
  order together with the reason they are there to explain.
- Every transition is motion-reduce-guarded and the loading pulse stops under
  prefers-reduced-motion; nothing about selection depends on an animation.
- cn() merges the consumer's className into the root, which also spreads the
  remaining div props and forwards its ref.

Customization levers
- Layout: `variant` picks the interaction model, `columns` (1/2/3) sets the card
  density, and the container-query thresholds are the two class strings to edit
  if your cards carry more text than a tagline.
- Which sub-blocks exist: drop `showSystemHint` for a picker where the fragment
  is private; omit `onCreate` and every create affordance disappears; pass
  `emptyState` to replace the empty body wholesale; omit `onRetry` and the error
  envelope stops offering one.
- Faces: `avatarSrc` is the whole avatar policy in one function — DiceBear
  (`https://api.dicebear.com/9.x/notionists/svg?seed=`), your CDN, or a data URL.
  Return undefined for the monogram; re-key the tint by changing the hash if you
  want personas grouped by team colour instead of by seed.
- Density of information: `maxTags` decides how many capability chips are drawn
  before "+N"; `skeletonCount` matches the loading placeholder to the roster
  size you usually return.
- Wording: `label`, `placeholder`, `createLabel`, `emptyMessage`, `errorMessage`
  are the entire copy deck — translate them, don't fork the component.
- Selection policy stays with you: `onValueChange` receives the id and the
  persona, and nothing is written unless you write it. To pin a default,
  pass `defaultValue` (uncontrolled) or drive `value` from your own store.

Concepts

  • Persona as a saved preset — a persona is not "an option with an icon": it bundles a voice, a job and the ground rules the next turn runs under. That is why the row carries a tagline and capability tags instead of a label, and why the panel underneath quotes the system-prompt fragment verbatim — choosing a personality should not mean choosing by haircut.
  • One contract, two interaction models — the same items + value + onValueChange render either a radiogroup of comparable tiles or a combobox over a listbox. They are deliberately not the same widget in two sizes: a radiogroup selects as focus moves and wraps at the ends, a listbox moves an active option with aria-activedescendant, clamps at the ends and commits on Enter. Picking the wrong one gives half your users the wrong keyboard.
  • A seed, not a URL — storing avatarSeed keeps the face stable across sessions and servers, costs no network in the default render, and survives a dead CDN: avatarSrc turns the seed into an image, and an image that fails falls back to a monogram tinted from a hash of the same seed, so a broken avatar never becomes a torn icon in the middle of a card.
  • Unavailable with a reason — a persona your plan cannot call still appears, still takes arrow-key focus so a screen reader can read why, and still refuses to commit. aria-disabled rather than the native disabled is what keeps it reachable; hiding it instead would leave the user hunting for a persona a teammate told them about.
  • A stale selection is surfaced, not repaired — when the committed id is no longer in the list, the picker names the missing id and waits. Auto-selecting the nearest persona would overwrite a deliberate choice with a guess, and the user would never know it happened.
  • The create affordance sits outside the group — a radiogroup owns radios and a listbox owns options, so the "create a persona" button lives beside them (a dashed tile under the grid, an icon button next to the trigger) instead of pretending to be a choice that can be checked.

On This Page