{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "assistant-avatar",
  "title": "Assistant Avatar",
  "description": "An assistant identity chip whose ring carries the state — a portrait or a seed-derived token gradient inside, ripples while listening, a conic sweep while thinking, equalizer ticks while speaking.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/assistant-avatar.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * Keyframes\n *\n * They ship with the component through a React 19 hoisted <style> — no Tailwind\n * config edits, and every instance on the page dedupes by href.\n *\n * The sweep animates a REGISTERED custom property instead of rotating the arc\n * element. Rotating the element would rotate its border-radius too, so the\n * squircle variant would visibly tumble; animating `--zy-aa-angle` moves only\n * the paint inside a box that never turns. Browsers without `@property` fall\n * back to a static wedge, which still reads as \"thinking\".\n * -------------------------------------------------------------------------- */\nconst KEYFRAMES = `@property --zy-aa-angle{syntax:\"<angle>\";inherits:false;initial-value:0deg}\n@keyframes zy-aa-sweep{to{--zy-aa-angle:360deg}}\n@keyframes zy-aa-ripple{0%{transform:scale(0.8);opacity:0}18%{opacity:0.72}100%{transform:scale(1.04);opacity:0}}\n@keyframes zy-aa-tick{0%,100%{transform:scaleY(0.28)}50%{transform:scaleY(1)}}\n@keyframes zy-aa-enter{from{opacity:0}to{opacity:1}}`\n\n/* -------------------------------------------------------------------------- *\n * States\n *\n * What an assistant looks like from the reader's seat: resting, hearing them,\n * reasoning, answering, unreachable, broken. Everything below is a table keyed\n * by that union — a seventh state is four table rows and no new branching.\n * -------------------------------------------------------------------------- */\n\nexport type AssistantAvatarState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\" | \"offline\" | \"error\"\n\nexport type AssistantAvatarShape = \"circle\" | \"squircle\"\n\n/** Where the name / model stack goes, if anywhere. */\nexport type AssistantAvatarCaption = \"none\" | \"below\" | \"beside\"\n\n/** Diameter in px per named tier; a raw number goes straight through. */\nconst SIZES = { xs: 24, sm: 32, md: 40, lg: 56, xl: 80 } as const\n\n/**\n * Outer / inner corner radius per shape. The inner value is not the outer one:\n * the face is inset by 11%, so a concentric corner needs\n * (outer − inset) / faceWidth ≈ (30% − 11%) / 78% ≈ 24%.\n */\nconst SHAPE: Record<AssistantAvatarShape, { outer: string; inner: string }> = {\n  circle: { outer: \"9999px\", inner: \"9999px\" },\n  squircle: { outer: \"30%\", inner: \"24%\" },\n}\n\n/**\n * State → ring color. Every value is a theme variable, so the host palette (and\n * dark mode) picks the actual color; the root publishes the winner as\n * `--zy-aa-tone` and every layer paints with that one variable.\n */\nconst TONE: Record<AssistantAvatarState, string> = {\n  idle: \"var(--primary)\",\n  listening: \"var(--chart-2)\",\n  thinking: \"var(--chart-1)\",\n  speaking: \"var(--chart-4)\",\n  offline: \"var(--muted-foreground)\",\n  error: \"var(--destructive)\",\n}\n\n/**\n * How present the always-painted track ring is. This is the reduced-motion\n * fallback in one number: with every effect layer hidden, hue plus weight is\n * the only thing left that says \"this assistant is busy\".\n */\nconst TRACK: Record<AssistantAvatarState, string> = {\n  idle: \"26%\",\n  listening: \"72%\",\n  thinking: \"64%\",\n  speaking: \"72%\",\n  offline: \"22%\",\n  error: \"82%\",\n}\n\n/** Multiplier on `speed` for each state's effect cycle. */\nconst CYCLE: Record<AssistantAvatarState, number> = {\n  idle: 1,\n  listening: 0.9,\n  thinking: 0.75,\n  speaking: 0.42,\n  offline: 1,\n  error: 1,\n}\n\nconst DEFAULT_LABELS: Record<AssistantAvatarState, string> = {\n  idle: \"Ready\",\n  listening: \"Listening\",\n  thinking: \"Thinking\",\n  speaking: \"Speaking\",\n  offline: \"Offline\",\n  error: \"Unavailable\",\n}\n\n/** The five chart slots a gradient face may draw from. */\nconst FACE_TOKENS = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-4)\", \"var(--chart-5)\"]\n\n/** FNV-1a — small, stable, and identical on the server and the client. */\nfunction hash(input: string): number {\n  let h = 2166136261\n  for (let i = 0; i < input.length; i += 1) {\n    h ^= input.charCodeAt(i)\n    h = Math.imul(h, 16777619)\n  }\n  return h >>> 0\n}\n\n/**\n * Two distinct chart tokens for a seed. Same seed, same face — forever, on\n * every machine — so a roster of assistants keeps its colors across reloads\n * without anyone storing a palette.\n */\nfunction facePair(seed: string): [string, string] {\n  const h = hash(seed)\n  const a = h % FACE_TOKENS.length\n  // +1 guarantees b !== a, so a face is never a flat single-color disc.\n  const b = (a + 1 + ((h >>> 8) % (FACE_TOKENS.length - 1))) % FACE_TOKENS.length\n  return [FACE_TOKENS[a], FACE_TOKENS[b]]\n}\n\n/** First letters of the first two words — \"Zyeon Copilot\" → \"ZC\". */\nfunction initialsOf(name: string): string {\n  const words = name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"AI\"\n  const letters = words.slice(0, 2).map(word => [...word][0] ?? \"\")\n  return letters.join(\"\").toUpperCase()\n}\n\n/* -------------------------------------------------------------------------- *\n * Layer classes\n *\n * Every layer is a sibling of the face and painted BEFORE it. The face is\n * opaque, so it clips the outer 11% band out of whatever the layer draws —\n * that is what turns a full conic disc into an arc and a full-length bar into a\n * tick, with no mask-composite and no shape-specific geometry.\n * -------------------------------------------------------------------------- */\n\nconst RIPPLE = cn(\n  \"absolute inset-0 border-solid motion-reduce:hidden\",\n  \"[border-radius:var(--zy-aa-radius)] [border-width:var(--zy-aa-ring)] border-[color:var(--zy-aa-tone)]\",\n)\n\n/**\n * The second ripple's offset rides in the shorthand's own delay slot rather than\n * in a separate `animation-delay` utility: the shorthand resets every longhand,\n * and two arbitrary properties have no guaranteed order in the output.\n */\nconst RIPPLE_ANIMATION = \"[animation:zy-aa-ripple_var(--zy-aa-cycle)_ease-out_infinite]\"\nconst RIPPLE_ANIMATION_OFFSET =\n  \"[animation:zy-aa-ripple_var(--zy-aa-cycle)_ease-out_calc(var(--zy-aa-cycle)*-0.5)_infinite]\"\n\nexport interface AssistantAvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** What the assistant is doing right now. @default \"idle\" */\n  state?: AssistantAvatarState\n  /** Portrait URL. A load failure falls back to the gradient face, permanently for that URL. */\n  src?: string\n  /** Who this is. Drives the initials, the caption and the announcement. @default \"Assistant\" */\n  name?: string\n  /** Secondary line under the name — a model id, a version, a role. Caption only. */\n  model?: string\n  /** Palette seed for the gradient face. Defaults to `name`, so identity alone decides the colors. */\n  seed?: string\n  /** Replaces the initials: an icon, a glyph, two custom letters. SVG children are auto-sized. */\n  fallback?: React.ReactNode\n  /** Diameter: a named tier or a number of pixels. @default \"md\" */\n  size?: keyof typeof SIZES | number\n  /** Outline shape; the face corner is derived so the two stay concentric. @default \"circle\" */\n  shape?: AssistantAvatarShape\n  /** Whether to print `name` (and `model`) and where. @default \"none\" */\n  caption?: AssistantAvatarCaption\n  /** Bars in the `speaking` equalizer, clamped to 0..16. @default 8 */\n  ticks?: number\n  /** Seconds per cycle at `idle` speed; every state scales its own effect from it. @default 2.4 */\n  speed?: number\n  /**\n   * Allow the state effects at all. `false` keeps the colored track and drops\n   * every animation — the switch for a fifty-row list where fifty ripples would\n   * cost more than they say. @default true\n   */\n  animate?: boolean\n  /** Override the state wording; this is also what a screen reader hears. */\n  labels?: Partial<Record<AssistantAvatarState, string>>\n  /**\n   * Own a polite live region that speaks \"<name>, <state>\" on every change.\n   * Turn it off in lists: one region per row talks over itself. @default true\n   */\n  announce?: boolean\n  /**\n   * How long a state must survive before it is announced, in ms. A turn flips\n   * listening → thinking → speaking in under a second; without this a screen\n   * reader narrates blips the user never saw. 0 announces immediately. @default 400\n   */\n  announceDelay?: number\n}\n\n/**\n * AssistantAvatar — the identity chip of an AI assistant, with its current state\n * carried by the ring around it.\n *\n * It owns no audio, no timers beyond the announcement latch and no network: the\n * state is a prop, the portrait is a URL, and everything else is CSS keyed off\n * one table.\n */\nexport const AssistantAvatar = React.forwardRef<HTMLSpanElement, AssistantAvatarProps>(function AssistantAvatar(\n  {\n    state = \"idle\",\n    src,\n    name = \"Assistant\",\n    model,\n    seed,\n    fallback,\n    size = \"md\",\n    shape = \"circle\",\n    caption = \"none\",\n    ticks = 8,\n    speed = 2.4,\n    animate = true,\n    labels,\n    announce = true,\n    announceDelay = 400,\n    className,\n    style,\n    ...props\n  },\n  ref,\n) {\n  // Remembering WHICH url failed (instead of a boolean) makes a src swap retry\n  // by itself: a new url is not the url that broke, so the image comes back.\n  const [failedSrc, setFailedSrc] = React.useState<string | null>(null)\n  const showImage = Boolean(src) && failedSrc !== src\n\n  // The visible ring follows the state immediately; the announcement lags, so a\n  // state nobody really saw is never spoken. A zero delay is derived in render\n  // instead of through the timer — no state, no cascading update.\n  const [held, setHeld] = React.useState(state)\n  React.useEffect(() => {\n    if (announceDelay <= 0) return\n    const timer = window.setTimeout(() => setHeld(state), announceDelay)\n    return () => window.clearTimeout(timer)\n  }, [state, announceDelay])\n  const spoken = announceDelay <= 0 ? state : held\n\n  const px =\n    typeof size === \"number\" ? (Number.isFinite(size) ? Math.max(16, Math.round(size)) : SIZES.md) : SIZES[size]\n  const cycle = Number.isFinite(speed) ? Math.max(0.2, speed) : 2.4\n  const tickCount = Number.isFinite(ticks) ? Math.max(0, Math.min(16, Math.round(ticks))) : 8\n  const [faceA, faceB] = facePair(seed ?? name)\n\n  const bars = React.useMemo(\n    () =>\n      Array.from({ length: tickCount }, (_, i) => ({\n        angle: (360 / tickCount) * i,\n        // Golden-ratio phase offset: neighbours never peak together and the\n        // pattern does not repeat around the ring, so eight bars read as a\n        // voice rather than as a rotating cog.\n        delay: ((i * 0.6180339887) % 1).toFixed(3),\n      })),\n    [tickCount],\n  )\n\n  const labelFor = (s: AssistantAvatarState) => labels?.[s] ?? DEFAULT_LABELS[s]\n  const captioned = caption !== \"none\"\n  const moving = animate && (state === \"listening\" || state === \"thinking\" || state === \"speaking\")\n\n  const vars = {\n    \"--zy-aa-size\": `${px}px`,\n    // A floor keeps the ring visible at 24px, where 5.5% is under a pixel.\n    \"--zy-aa-ring\": \"max(1.5px, calc(var(--zy-aa-size) * 0.055))\",\n    /** Tick length: 1% longer than the face inset, so a peaking bar tucks under the face. */\n    \"--zy-aa-band\": \"calc(var(--zy-aa-size) * 0.12)\",\n    \"--zy-aa-tick\": \"calc(var(--zy-aa-ring) * 1.15)\",\n    \"--zy-aa-radius\": SHAPE[shape].outer,\n    \"--zy-aa-face-radius\": SHAPE[shape].inner,\n    // Public knob: set --zy-aa-hue on any ancestor to pin every state to a brand color.\n    \"--zy-aa-tone\": `var(--zy-aa-hue, ${TONE[state]})`,\n    \"--zy-aa-track\": `color-mix(in oklab, var(--zy-aa-tone) ${TRACK[state]}, transparent)`,\n    \"--zy-aa-cycle\": `${(cycle * CYCLE[state]).toFixed(2)}s`,\n    \"--zy-aa-face-a\": faceA,\n    \"--zy-aa-face-b\": faceB,\n    ...style,\n  } as React.CSSProperties\n\n  return (\n    <span\n      className={cn(\n        \"inline-flex max-w-full min-w-0 align-middle\",\n        caption === \"below\" ? \"flex-col items-center gap-2\" : \"flex-row items-center gap-2.5\",\n        className,\n      )}\n      data-state={state}\n      ref={ref}\n      style={vars}\n      {...props}\n    >\n      <style href=\"zyeon-assistant-avatar\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n\n      {/*\n        Pure decoration: every word this conveys also exists as text below, so\n        exposing it would only make a screen reader say the assistant twice.\n      */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"relative isolate block size-[var(--zy-aa-size)] shrink-0\",\n          \"transition-[opacity,filter] duration-300 motion-reduce:transition-none\",\n          state === \"offline\" && \"opacity-60 [filter:grayscale(0.9)]\",\n        )}\n      >\n        {/* thinking — one conic wedge turning inside the band. It claims no\n            progress, because the model has not said how far along it is. */}\n        {moving && state === \"thinking\" ? (\n          <span\n            className={cn(\n              \"absolute inset-0 [border-radius:var(--zy-aa-radius)]\",\n              \"[background:conic-gradient(from_var(--zy-aa-angle),transparent_0deg,color-mix(in_oklab,var(--zy-aa-tone)_80%,transparent)_115deg,transparent_200deg)]\",\n              \"[animation:zy-aa-sweep_var(--zy-aa-cycle)_linear_infinite,zy-aa-enter_240ms_ease-out]\",\n              \"motion-reduce:hidden\",\n            )}\n          />\n        ) : null}\n\n        {/* The track. Always painted, and the only thing left under reduced\n            motion — which is why its weight, not just its hue, moves with the\n            state. Drawn after the sweep so the arc keeps a crisp outer edge. */}\n        <span\n          className={cn(\n            \"absolute inset-0 border-solid\",\n            \"[border-radius:var(--zy-aa-radius)] [border-width:var(--zy-aa-ring)] border-[color:var(--zy-aa-track)]\",\n            \"transition-colors duration-300 motion-reduce:transition-none\",\n          )}\n        />\n\n        {/* listening — two rings half a cycle apart, travelling from the face\n            edge to the rim. They stop at scale 1.04 so a row of avatars never\n            grows into its neighbour. */}\n        {moving && state === \"listening\" ? (\n          <>\n            <span className={cn(RIPPLE, RIPPLE_ANIMATION)} />\n            <span className={cn(RIPPLE, RIPPLE_ANIMATION_OFFSET)} />\n          </>\n        ) : null}\n\n        {/* speaking — bars standing on the rim, pointing inward. Each one grows\n            past the face inset at its peak, so the equalizer reads as syllables\n            escaping from behind the portrait. */}\n        {moving && state === \"speaking\" && tickCount > 0 ? (\n          <span className=\"absolute inset-0 [animation:zy-aa-enter_240ms_ease-out] motion-reduce:hidden\">\n            {bars.map(bar => (\n              <span className=\"absolute inset-0\" key={bar.angle} style={{ transform: `rotate(${bar.angle}deg)` }}>\n                {/* Centred with a negative margin, never with a translate: the\n                    bar's own transform is the animation, and Tailwind's\n                    translate utility lives on a different property in v3 than\n                    in v4 — a margin is immune to both. */}\n                <span\n                  className={cn(\n                    \"absolute top-0 left-1/2 ml-[calc(var(--zy-aa-tick)*-0.5)] h-[var(--zy-aa-band)] w-[var(--zy-aa-tick)]\",\n                    \"origin-top rounded-full bg-[color:var(--zy-aa-tone)]\",\n                    \"[animation:zy-aa-tick_var(--zy-aa-cycle)_ease-in-out_infinite]\",\n                  )}\n                  style={{ animationDelay: `calc(var(--zy-aa-cycle) * -${bar.delay})` }}\n                />\n              </span>\n            ))}\n          </span>\n        ) : null}\n\n        {/* The face. Painted last, and opaque on purpose: it is what crops every\n            layer above into a band. The gradient stays under the portrait too,\n            so a transparent avatar SVG lands on the palette instead of on a\n            hole. */}\n        <span\n          className=\"absolute inset-[11%] flex items-center justify-center overflow-hidden bg-card select-none [border-radius:var(--zy-aa-face-radius)]\"\n          style={{\n            backgroundImage:\n              \"linear-gradient(145deg, color-mix(in oklab, var(--zy-aa-face-a) 30%, var(--card)), color-mix(in oklab, var(--zy-aa-face-b) 18%, var(--card)))\",\n          }}\n        >\n          {showImage ? (\n            // eslint-disable-next-line @next/next/no-img-element -- registry 组件保持框架无关,不绑 next/image\n            <img\n              alt=\"\"\n              className=\"size-full object-cover\"\n              decoding=\"async\"\n              loading=\"lazy\"\n              onError={() => setFailedSrc(src ?? null)}\n              src={src}\n            />\n          ) : (\n            <span\n              className=\"flex size-full items-center justify-center leading-none font-medium tracking-tight text-foreground [&_svg]:size-[46%]\"\n              style={{ fontSize: \"calc(var(--zy-aa-size) * 0.34)\" }}\n            >\n              {fallback ?? initialsOf(name)}\n            </span>\n          )}\n        </span>\n      </span>\n\n      {captioned ? (\n        <span\n          className={cn(\n            \"flex min-w-0 flex-col gap-0.5\",\n            caption === \"below\" ? \"items-center text-center\" : \"items-start text-left\",\n          )}\n        >\n          <span className=\"max-w-full truncate text-sm leading-none font-medium\">{name}</span>\n          {model ? (\n            <span className=\"max-w-full truncate font-mono text-xs leading-none text-muted-foreground\">{model}</span>\n          ) : null}\n        </span>\n      ) : null}\n\n      {/*\n        Permanently mounted, because several screen readers skip a live region\n        that appears in the same frame as its text. The name rides along: on a\n        page with three assistants, a bare \"Thinking\" belongs to nobody.\n      */}\n      <span\n        aria-live={announce ? \"polite\" : undefined}\n        className=\"sr-only\"\n        role={announce ? \"status\" : undefined}\n      >\n        {`${name}, ${labelFor(spoken)}`}\n      </span>\n    </span>\n  )\n})\n\nAssistantAvatar.displayName = \"AssistantAvatar\"\n\nexport default AssistantAvatar\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}