{
  "name": "kura-ui",
  "version": "0.0.0",
  "homepage": "https://kuro.abstergo.fyi",
  "description": "Agent-first, AI-native React components — composers, voice, shaders and agent surfaces.",
  "install": {
    "npm": "npm install @kura-ui/react",
    "css": "@import \"@kura-ui/react/styles.css\";",
    "shadcnTheme": "npx shadcn@latest add https://kuro.abstergo.fyi/r/kura-theme.json",
    "cliInit": "npx @kura-ui/cli init"
  },
  "categories": [
    {
      "id": "primitives",
      "title": "Primitives",
      "description": "Foundational controls every other component is built from."
    },
    {
      "id": "input",
      "title": "Input",
      "description": "Prompt composers, commands, pickers and shortcut surfaces."
    },
    {
      "id": "voice",
      "title": "Voice",
      "description": "Dictation, recording, audio visualisation and voice agents."
    },
    {
      "id": "conversation",
      "title": "Conversation",
      "description": "Messages, streaming output, code, citations and artifacts."
    },
    {
      "id": "agent",
      "title": "Agent",
      "description": "Tool calls, reasoning, timelines, approvals and budgets."
    },
    {
      "id": "feedback",
      "title": "Feedback",
      "description": "Thinking, loading and status indicators."
    },
    {
      "id": "effects",
      "title": "Effects",
      "description": "Shaders, gradients and ambient visuals."
    }
  ],
  "components": [
    {
      "slug": "agent-orb",
      "name": "AgentOrb",
      "category": "voice",
      "status": "beta",
      "description": "Glassy liquid sphere for voice agents, drawn in a shader: breathes when idle, reacts to mic and voice levels, swirls while thinking.",
      "whenToUse": [
        "The central visual of a voice agent or call screen: idle, listening, thinking, speaking, error.",
        "Showing that an assistant is hearing the user (mic level) or talking back (TTS level).",
        "A 'talk to the agent' entry point inside a button or a card.",
        "An ambient, branded presence for an AI assistant in an empty state."
      ],
      "whenNotToUse": [
        "Precise audio feedback such as a recording meter or waveform — use audio-waveform or voice-dictation.",
        "Text chat 'thinking' feedback inside a message list — use thinking-indicator or shimmer-text.",
        "A full-bleed background — use mesh-gradient or aurora.",
        "More than one per view: the orb is the accent element; repeat it and it stops meaning anything."
      ],
      "props": [
        {
          "name": "state",
          "type": "\"idle\" | \"listening\" | \"thinking\" | \"speaking\" | \"error\"",
          "default": "\"idle\"",
          "description": "What the agent is doing. idle breathes slowly, listening ripples with the input level, thinking swirls, speaking pulses with the output level, error turns red and calms down."
        },
        {
          "name": "colors",
          "type": "\"jade\" | \"ocean\" | \"amber\" | \"graphite\" | readonly [string, string, string, string]",
          "default": "\"jade\"",
          "description": "A palette preset, or four CSS colours `[deep, mid, bright, glint]` (OKLCH, hex, `var(--token)`…). jade is the Kura signal colour."
        },
        {
          "name": "analyser",
          "type": "AudioAnalyser",
          "description": "From `useAudioAnalyser`. Its level drives the orb while listening or speaking. Read every frame on the GPU loop — no React re-renders."
        },
        {
          "name": "inputLevel",
          "type": "number",
          "description": "Microphone level 0–1 used while `listening`. Overrides `analyser`."
        },
        {
          "name": "outputLevel",
          "type": "number",
          "description": "Agent voice level 0–1 used while `speaking`. Overrides `analyser`."
        },
        {
          "name": "label",
          "type": "string",
          "description": "Accessible text for the state. Default: Voice agent idle / Listening / Thinking / Speaking / Voice agent error."
        },
        {
          "name": "announce",
          "type": "boolean",
          "default": "true",
          "description": "Render the label in a polite live region. Set false when several orbs are on screen or a parent already announces the state."
        },
        {
          "name": "maxDpr",
          "type": "number",
          "default": "2",
          "description": "Device-pixel-ratio cap."
        }
      ],
      "parts": [
        {
          "name": "AgentOrb",
          "description": "Square root (`aspect-square`); size it with a width class. `data-state` mirrors `state`.",
          "dataSlot": "agent-orb"
        },
        {
          "name": "ShaderCanvas",
          "description": "The WebGL sphere and its halo (aria-hidden). The sphere fills ~70% of the box.",
          "dataSlot": "agent-orb-canvas"
        },
        {
          "name": "Fallback",
          "description": "CSS gradient sphere used when WebGL2 is unavailable; it still spins while thinking.",
          "dataSlot": "agent-orb-fallback"
        }
      ],
      "a11y": {
        "role": "status",
        "notes": [
          "The canvas is decorative (aria-hidden). The state is exposed as visually hidden text in a role=status live region, announced politely when it changes.",
          "Audio levels are never announced. Pass `label` to describe custom situations (e.g. “Muted”).",
          "If the orb is inside a button, give the button its own aria-label and set `announce={false}` to avoid double announcements."
        ],
        "keyboard": [],
        "reducedMotion": "No breathing, swirl, flow or audio reaction: a still frame per state. State changes cross-fade colour and brightness over ~0.4s, then rendering stops."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "shader-canvas"
        ]
      },
      "related": [
        "voice-dictation",
        "audio-waveform",
        "thinking-indicator",
        "mesh-gradient",
        "shader-canvas"
      ],
      "tags": [
        "voice",
        "orb",
        "agent",
        "audio",
        "webgl",
        "shader",
        "assistant",
        "speaking",
        "listening"
      ],
      "examples": [
        {
          "name": "AgentOrbMinimal",
          "title": "Idle orb",
          "description": "Size it with a class; it breathes slowly.",
          "kind": "minimal",
          "code": "import { AgentOrb } from \"@kura-ui/react/agent-orb\";\n\nexport function AgentOrbMinimal() {\n  return <AgentOrb className=\"size-48\" />;\n}\n"
        },
        {
          "name": "AgentOrbFull",
          "title": "States, palettes and audio",
          "description": "Every state and preset, driven by a simulated speech analyser.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { useAudioAnalyser } from \"@kura-ui/react/hooks\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { AgentOrb, type AgentOrbPreset, type AgentOrbState } from \"@kura-ui/react/agent-orb\";\n\nconst states: AgentOrbState[] = [\"idle\", \"listening\", \"thinking\", \"speaking\", \"error\"];\n\nconst presets: AgentOrbPreset[] = [\"jade\", \"ocean\", \"amber\", \"graphite\"];\n\nexport function AgentOrbFull() {\n  const [state, setState] = React.useState<AgentOrbState>(\"speaking\");\n  const [preset, setPreset] = React.useState<AgentOrbPreset>(\"jade\");\n  // Simulated audio for the demo; pass `source: micStream` in a real app.\n  const analyser = useAudioAnalyser({ simulate: \"speech\" });\n  return (\n    <div className=\"flex w-full max-w-md flex-col items-center gap-5\">\n      <AgentOrb state={state} colors={preset} analyser={analyser} className=\"size-64\" />\n      <div\n        role=\"radiogroup\"\n        aria-label=\"Agent state\"\n        className=\"flex flex-wrap justify-center gap-1\"\n      >\n        {states.map((s) => (\n          <Button\n            key={s}\n            size=\"sm\"\n            variant={s === state ? \"secondary\" : \"ghost\"}\n            role=\"radio\"\n            aria-checked={s === state}\n            onClick={() => setState(s)}\n            className=\"capitalize\"\n          >\n            {s}\n          </Button>\n        ))}\n      </div>\n      <div role=\"radiogroup\" aria-label=\"Palette\" className=\"flex gap-1\">\n        {presets.map((p) => (\n          <Button\n            key={p}\n            size=\"xs\"\n            variant={p === preset ? \"secondary\" : \"ghost\"}\n            role=\"radio\"\n            aria-checked={p === preset}\n            onClick={() => setPreset(p)}\n            className=\"capitalize\"\n          >\n            {p}\n          </Button>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "AgentOrbStates",
          "title": "All five states",
          "description": "idle, listening, thinking, speaking and error side by side.",
          "kind": "state",
          "code": "\"use client\";\n\nimport { useAudioAnalyser } from \"@kura-ui/react/hooks\";\nimport { AgentOrb, type AgentOrbState } from \"@kura-ui/react/agent-orb\";\n\nconst states: AgentOrbState[] = [\"idle\", \"listening\", \"thinking\", \"speaking\", \"error\"];\n\nexport function AgentOrbStates() {\n  const analyser = useAudioAnalyser({ simulate: \"speech\" });\n  return (\n    <div className=\"grid w-full grid-cols-5 gap-2\">\n      {states.map((s) => (\n        <figure key={s} className=\"flex flex-col items-center gap-1\">\n          <AgentOrb state={s} analyser={analyser} announce={false} className=\"w-full max-w-32\" />\n          <figcaption className=\"text-kura-fg-subtle text-xs capitalize\">{s}</figcaption>\n        </figure>\n      ))}\n    </div>\n  );\n}\n"
        },
        {
          "name": "AgentOrbVoiceSession",
          "title": "Voice session card",
          "description": "A scripted call with captions, ShimmerText while thinking and IconButton controls.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Mic, MicOff, PhoneOff } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useAudioAnalyser } from \"@kura-ui/react/hooks\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\nimport { AgentOrb, type AgentOrbState } from \"@kura-ui/react/agent-orb\";\n\nconst script: Array<{ state: AgentOrbState; ms: number; caption: string }> = [\n  { state: \"listening\", ms: 3200, caption: \"Listening…\" },\n  { state: \"thinking\", ms: 2400, caption: \"Checking your calendar…\" },\n  { state: \"speaking\", ms: 4200, caption: \"You're free after 3pm on Thursday.\" },\n];\n\nexport function AgentOrbVoiceSession() {\n  const [step, setStep] = React.useState(0);\n  const [muted, setMuted] = React.useState(false);\n  const analyser = useAudioAnalyser({ simulate: \"speech\" });\n  const current = script[step % script.length]!;\n  React.useEffect(() => {\n    const id = setTimeout(() => setStep((s) => s + 1), current.ms);\n    return () => clearTimeout(id);\n  }, [current]);\n  const state: AgentOrbState = muted && current.state === \"listening\" ? \"idle\" : current.state;\n  return (\n    <div className=\"flex w-full max-w-sm flex-col items-center gap-4 rounded-kura-2xl bg-kura-surface p-6 shadow-kura-md\">\n      <AgentOrb\n        state={state}\n        analyser={analyser}\n        label={muted ? \"Muted\" : undefined}\n        className=\"size-44\"\n      />\n      <p className=\"flex h-10 items-center text-center text-kura-fg-muted text-sm\" aria-hidden>\n        {state === \"thinking\" ? <ShimmerText>{current.caption}</ShimmerText> : current.caption}\n      </p>\n      <div className=\"flex items-center gap-2\">\n        <IconButton\n          label={muted ? \"Unmute\" : \"Mute\"}\n          variant=\"secondary\"\n          size=\"lg\"\n          aria-pressed={muted}\n          icon={muted ? <MicOff /> : <Mic />}\n          onClick={() => setMuted((m) => !m)}\n        />\n        <IconButton label=\"End call\" variant=\"danger\" size=\"lg\" icon={<PhoneOff />} />\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Give it a width (`className=\"size-48\"` or `w-full max-w-64`); it is square. The sphere fills ~70% so the halo has room — do not clip it with overflow-hidden.",
        "For real audio: `const mic = useAudioAnalyser({ source: micStream })` for listening and a second analyser on the TTS <audio> element for speaking; switch `analyser` with the state. For demos use `useAudioAnalyser({ simulate: \"speech\" })`.",
        "`inputLevel`/`outputLevel` are fine for levels you already have (e.g. from a WebRTC SDK) at ≤ 30 updates/s; the shader smooths them.",
        "Changing `state` never restarts the animation: uniforms ease toward the new state, so you can flip states as fast as your agent emits events.",
        "Only one orb per view should be animated; for lists of agents use a static avatar."
      ],
      "title": "Agent Orb",
      "importPath": "@kura-ui/react/agent-orb",
      "exports": [
        "AgentOrb",
        "AgentOrbProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/agent-orb",
      "markdownUrl": "https://kuro.abstergo.fyi/components/agent-orb.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/agent-orb.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/agent-orb.json",
        "cli": "npx @kura-ui/cli add agent-orb",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "kura-shader-color",
        "shader-canvas",
        "use-audio-analyser",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "state": {
            "description": "What the agent is doing. idle breathes slowly, listening ripples with the input level, thinking swirls, speaking pulses with the output level, error turns red and calms down.",
            "x-ts-type": "\"idle\" | \"listening\" | \"thinking\" | \"speaking\" | \"error\"",
            "type": "string",
            "enum": [
              "error",
              "idle",
              "listening",
              "speaking",
              "thinking"
            ],
            "default": "idle"
          },
          "colors": {
            "description": "A palette preset, or four CSS colours `[deep, mid, bright, glint]` (OKLCH, hex, `var(--token)`…). jade is the Kura signal colour.",
            "x-ts-type": "\"jade\" | \"ocean\" | \"amber\" | \"graphite\" | readonly [string, string, string, string]",
            "default": "jade"
          },
          "analyser": {
            "description": "From `useAudioAnalyser`. Its level drives the orb while listening or speaking. Read every frame on the GPU loop — no React re-renders.",
            "x-ts-type": "AudioAnalyser"
          },
          "inputLevel": {
            "description": "Microphone level 0–1 used while `listening`. Overrides `analyser`.",
            "x-ts-type": "number",
            "type": "number"
          },
          "outputLevel": {
            "description": "Agent voice level 0–1 used while `speaking`. Overrides `analyser`.",
            "x-ts-type": "number",
            "type": "number"
          },
          "label": {
            "description": "Accessible text for the state. Default: Voice agent idle / Listening / Thinking / Speaking / Voice agent error.",
            "x-ts-type": "string",
            "type": "string"
          },
          "announce": {
            "description": "Render the label in a polite live region. Set false when several orbs are on screen or a parent already announces the state.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "maxDpr": {
            "description": "Device-pixel-ratio cap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/agent-orb/agent-orb.glsl.ts",
          "type": "registry:ui",
          "content": "import { GLSL_COLOR } from \"@/lib/kura/shader-color\";\n\n/**\n * The agent orb: an analytic sphere (no geometry) shaded like thick glass holding a luminous\n * liquid. Per pixel we\n *   1. find the (audio-deformed) silhouette and the sphere normal,\n *   2. refract the view ray and march 5 steps through a domain-warped 3D noise volume — the\n *      \"liquid\", twisted around the view axis for the thinking swirl,\n *   3. add ridged-noise caustic filaments where light would focus (opposite the key light),\n *   4. light it: wrap diffuse, a fresnel rim, a soft window reflection and a tight specular,\n *   5. composite a faint halo outside the sphere, in premultiplied alpha.\n * All lighting happens in linear RGB; one sRGB encode + IGN dither at the end.\n */\nexport const AGENT_ORB_FRAG = /* glsl */ `\nuniform vec3 u_deep;\nuniform vec3 u_mid;\nuniform vec3 u_bright;\nuniform vec3 u_glint;\nuniform float u_energy;  // 0–1 inner brightness and turbulence\nuniform float u_swirl;   // 0–1 vortex strength\nuniform float u_spin;    // integrated vortex angle\nuniform float u_phase;   // integrated flow time\nuniform float u_level;   // smoothed audio level 0–1\nuniform float u_breath;  // 0–1 idle breathing amplitude\nuniform float u_halo;    // 0–1 outer halo\nuniform float u_light;   // 1 on light backgrounds, 0 on dark\n\n${GLSL_COLOR}\n\nvec4 permute4(vec4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\nvec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }\nfloat snoise(vec3 v) {\n  const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);\n  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n  vec3 i = floor(v + dot(v, C.yyy));\n  vec3 x0 = v - i + dot(i, C.xxx);\n  vec3 g = step(x0.yzx, x0.xyz);\n  vec3 l = 1.0 - g;\n  vec3 i1 = min(g.xyz, l.zxy);\n  vec3 i2 = max(g.xyz, l.zxy);\n  vec3 x1 = x0 - i1 + C.xxx;\n  vec3 x2 = x0 - i2 + C.yyy;\n  vec3 x3 = x0 - D.yyy;\n  i = mod(i, 289.0);\n  vec4 p = permute4(permute4(permute4(\n    i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));\n  float n_ = 1.0 / 7.0;\n  vec3 ns = n_ * D.wyz - D.xzx;\n  vec4 j = p - 49.0 * floor(p * ns.z * ns.z);\n  vec4 x_ = floor(j * ns.z);\n  vec4 y_ = floor(j - 7.0 * x_);\n  vec4 x = x_ * ns.x + ns.yyyy;\n  vec4 y = y_ * ns.x + ns.yyyy;\n  vec4 h = 1.0 - abs(x) - abs(y);\n  vec4 b0 = vec4(x.xy, y.xy);\n  vec4 b1 = vec4(x.zw, y.zw);\n  vec4 s0 = floor(b0) * 2.0 + 1.0;\n  vec4 s1 = floor(b1) * 2.0 + 1.0;\n  vec4 sh = -step(h, vec4(0.0));\n  vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;\n  vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;\n  vec3 p0 = vec3(a0.xy, h.x);\n  vec3 p1 = vec3(a0.zw, h.y);\n  vec3 p2 = vec3(a1.xy, h.z);\n  vec3 p3 = vec3(a1.zw, h.w);\n  vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));\n  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;\n  vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);\n  m = m * m;\n  return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));\n}\n\nmat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }\n\nfloat fbm(vec3 p) {\n  float v = 0.0, a = 0.5;\n  for (int i = 0; i < 3; i++) { v += a * snoise(p); p = p * 2.03 + 11.7; a *= 0.5; }\n  return v;\n}\n\nvoid main() {\n  float s = min(u_resolution.x, u_resolution.y);\n  float px = 2.0 / s;\n  vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) * px;\n  float t = u_phase;\n\n  // Silhouette: breathing + audio-driven wobble along the rim.\n  float R = 0.7 * (1.0 + 0.022 * u_breath * sin(u_time * 1.55) + 0.075 * u_level);\n  float r = length(p);\n  vec2 dir = r > 0.0 ? p / r : vec2(0.0, 1.0);\n  float wob = snoise(vec3(dir * 1.2, t * 0.55)) * (0.006 + 0.03 * u_level)\n            + snoise(vec3(dir * 2.6, t * 1.4 + 3.0)) * 0.014 * u_level;\n  float edge = R * (1.0 + wob);\n  float mask = smoothstep(edge + px * 1.2, edge - px * 1.2, r);\n\n  vec3 col = vec3(0.0);\n  if (mask > 0.0) {\n    vec2 q = p / edge;\n    float z = sqrt(max(0.0, 1.0 - dot(q, q)));\n    vec3 n = normalize(vec3(q, z));\n    vec3 L = normalize(vec3(-0.55, 0.65, 0.55));\n\n    // March the refracted ray through the liquid: large, slow masses of colour (deep ↔ mid),\n    // lit from within where the density is high.\n    vec3 rd = refract(vec3(0.0, 0.0, -1.0), n, 0.72);\n    vec3 ro = vec3(q, z);\n    float chord = 1.5 * z + 0.3;\n    float dens = 0.0;\n    float rib = 0.0;\n    for (int k = 0; k < 5; k++) {\n      float fk = (float(k) + 0.5) / 5.0;\n      vec3 P = ro + rd * chord * fk;\n      // Vortex: twist around the view axis, faster toward the core.\n      float rr = length(P.xy);\n      P.xy = rot(u_spin * (1.0 - 0.5 * rr) + u_swirl * 2.4 * (1.0 - rr)) * P.xy;\n      vec3 w = P * 0.95 + vec3(0.0, -t * 0.18, t * 0.3);\n      vec3 warp = vec3(snoise(w * 0.7 + t * 0.12), snoise(w * 0.7 + 7.3), snoise(w * 0.7 - 4.1 - t * 0.1));\n      float d = fbm(w + warp * (0.6 + 0.6 * u_energy));\n      dens += smoothstep(-0.3, 0.45, d) * (1.0 - fk * 0.35);\n      // Thin luminous threads that appear with energy.\n      rib += pow(1.0 - abs(snoise(w * 1.3 + warp)), 6.0) * (1.0 - fk * 0.6);\n    }\n    dens /= 4.1;\n    rib /= 3.8;\n\n    // Volume: light travels further through the edges, so they absorb more.\n    float thickness = sqrt(z);\n    vec3 body = mix(u_deep, u_mid, dens);\n    body = mix(body, u_bright, smoothstep(0.55, 1.0, dens) * (0.35 + 0.45 * u_energy));\n    float diff = clamp(dot(n, L) * 0.5 + 0.5, 0.0, 1.0);\n    col = body * mix(0.5, 1.12, diff) * mix(0.72, 1.0, thickness);\n\n    // Core: a soft inner light, offset away from the key light, swelling with the voice.\n    vec2 coreP = q - vec2(0.1, -0.16);\n    float core = exp(-dot(coreP, coreP) * (3.2 - 1.2 * u_level));\n    col += u_bright * core * (0.12 + 0.35 * u_energy + 0.5 * u_level);\n    col += u_bright * rib * (0.03 + 0.22 * u_energy * u_energy + 0.25 * u_level);\n\n    // Caustics: soft pools of focused light drifting opposite the key light.\n    vec2 cq = rot(u_spin * 0.35) * q;\n    float cn = snoise(vec3(cq * 2.6, t * 0.35)) * 0.6 + snoise(vec3(cq * 5.2 + 3.0, t * 0.5)) * 0.4;\n    float caus = smoothstep(0.25, 0.85, cn);\n    float focus = smoothstep(0.05, 0.85, dot(q, -normalize(L.xy))) * smoothstep(1.0, 0.7, length(q));\n    col += mix(u_bright, u_glint, 0.35) * caus * focus * (0.28 + 0.55 * u_energy + 0.4 * u_level);\n\n    // Subsurface: light pooled along the lower inner edge.\n    float sss = pow(1.0 - z, 1.4) * smoothstep(0.3, -0.9, q.y + q.x * 0.35);\n    col += u_bright * sss * (0.4 + 0.35 * u_energy);\n\n    // Glass: a coloured fresnel rim, a bright crescent on the lit side, a crisp specular.\n    float fres = pow(1.0 - z, 3.0);\n    col = mix(col, u_bright * 0.85, fres * 0.45);\n    float lit = smoothstep(0.1, 0.95, dot(normalize(q + 1e-4), normalize(L.xy)));\n    float crescent = pow(1.0 - z, 2.2) * lit * smoothstep(0.995, 0.93, length(q));\n    col += u_glint * crescent * 0.55;\n    vec3 H = normalize(vec3(-0.42, 0.52, 0.74));\n    float window = smoothstep(0.955, 0.99, dot(n, H));\n    col += u_glint * window * 0.12;\n    float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 120.0);\n    col += u_glint * spec * 1.2;\n  }\n\n  // Halo: soft falloff outside the rim, stronger while speaking.\n  float outside = max(r - edge, 0.0);\n  float haloA = u_halo * exp(-outside * 10.0) * smoothstep(0.32, 0.05, outside) * (1.0 - mask) * (0.5 - 0.2 * u_light);\n  vec3 orb = linearToSrgb(col);\n  vec3 haloC = linearToSrgb(u_bright);\n  float a = mask + haloA;\n  vec3 premul = orb * mask + haloC * haloA;\n  premul += (ign(gl_FragCoord.xy) - 0.5) / 255.0 * a;\n  fragColor = vec4(premul, a);\n}\n`;\n"
        },
        {
          "target": "components/kura/agent-orb/agent-orb.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { AudioAnalyser } from \"@/hooks/kura/use-audio-analyser\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { useResolvedColors, type Vec3 } from \"@/lib/kura/shader-color\";\nimport { ShaderCanvas } from \"@/components/kura/shader-canvas\";\nimport { AGENT_ORB_FRAG } from \"./agent-orb.glsl\";\n\nexport type AgentOrbState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\" | \"error\";\nexport type AgentOrbPreset = \"jade\" | \"ocean\" | \"amber\" | \"graphite\";\n\n/** Palettes as [deep, mid, bright, glint] OKLCH colours. */\nexport const agentOrbPresets: Record<AgentOrbPreset, readonly [string, string, string, string]> = {\n  jade: [\n    \"oklch(0.3 0.06 188)\",\n    \"oklch(0.56 0.115 174)\",\n    \"oklch(0.84 0.13 166)\",\n    \"oklch(0.98 0.03 150)\",\n  ],\n  ocean: [\n    \"oklch(0.27 0.07 255)\",\n    \"oklch(0.53 0.13 242)\",\n    \"oklch(0.82 0.1 218)\",\n    \"oklch(0.98 0.02 210)\",\n  ],\n  amber: [\n    \"oklch(0.33 0.08 42)\",\n    \"oklch(0.64 0.15 55)\",\n    \"oklch(0.87 0.13 82)\",\n    \"oklch(0.99 0.03 95)\",\n  ],\n  graphite: [\n    \"oklch(0.22 0.006 85)\",\n    \"oklch(0.46 0.01 85)\",\n    \"oklch(0.82 0.01 85)\",\n    \"oklch(0.99 0 0)\",\n  ],\n};\n\nconst ERROR_PALETTE = [\n  \"oklch(0.3 0.08 25)\",\n  \"oklch(0.55 0.17 25)\",\n  \"oklch(0.78 0.13 35)\",\n  \"oklch(0.97 0.03 45)\",\n] as const;\n\nconst STATE_LABEL: Record<AgentOrbState, string> = {\n  idle: \"Voice agent idle\",\n  listening: \"Listening\",\n  thinking: \"Thinking\",\n  speaking: \"Speaking\",\n  error: \"Voice agent error\",\n};\n\ninterface Targets {\n  energy: number;\n  swirl: number;\n  spinSpeed: number;\n  flow: number;\n  breath: number;\n  halo: number;\n  error: number;\n}\n\nconst TARGETS: Record<AgentOrbState, Targets> = {\n  idle: { energy: 0.2, swirl: 0.05, spinSpeed: 0.05, flow: 0.22, breath: 1, halo: 0.25, error: 0 },\n  listening: {\n    energy: 0.45,\n    swirl: 0.1,\n    spinSpeed: 0.1,\n    flow: 0.45,\n    breath: 0.4,\n    halo: 0.45,\n    error: 0,\n  },\n  thinking: {\n    energy: 0.6,\n    swirl: 0.85,\n    spinSpeed: 1.1,\n    flow: 0.6,\n    breath: 0.3,\n    halo: 0.4,\n    error: 0,\n  },\n  speaking: {\n    energy: 0.55,\n    swirl: 0.15,\n    spinSpeed: 0.18,\n    flow: 0.55,\n    breath: 0.2,\n    halo: 0.7,\n    error: 0,\n  },\n  error: { energy: 0.15, swirl: 0, spinSpeed: 0.02, flow: 0.12, breath: 0.6, halo: 0.3, error: 1 },\n};\n\nexport interface AgentOrbProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** What the agent is doing. Drives motion, colour and the announced label. Default \"idle\". */\n  state?: AgentOrbState;\n  /** A preset, or four CSS colours `[deep, mid, bright, glint]`. Default \"jade\". */\n  colors?: AgentOrbPreset | readonly [string, string, string, string];\n  /**\n   * Audio analyser (from `useAudioAnalyser`). Its level drives the orb while `listening` (input)\n   * or `speaking` (output). Overridden by `inputLevel`/`outputLevel` when those are set.\n   */\n  analyser?: AudioAnalyser;\n  /** Microphone level 0–1, used while `listening`. */\n  inputLevel?: number;\n  /** Agent voice level 0–1, used while `speaking`. */\n  outputLevel?: number;\n  /** Accessible text for the current state. Default: \"Listening\", \"Thinking\", … */\n  label?: string;\n  /** Announce state changes politely to screen readers. Default true. */\n  announce?: boolean;\n  /** Device-pixel-ratio cap. Default 2. */\n  maxDpr?: number;\n}\n\nfunction lerp(a: number, b: number, k: number) {\n  return a + (b - a) * k;\n}\n\n/**\n * The voice-agent orb: a glassy, liquid sphere rendered in a fragment shader. It breathes when\n * idle, ripples with the microphone while listening, swirls while thinking, pulses with the\n * agent's voice while speaking and turns red on error — every transition is smoothed on the GPU\n * uniforms. Decorative canvas plus a polite text equivalent of the state.\n */\nexport function AgentOrb({\n  state = \"idle\",\n  colors = \"jade\",\n  analyser,\n  inputLevel,\n  outputLevel,\n  label,\n  announce = true,\n  maxDpr = 2,\n  className,\n  style,\n  ref,\n  ...props\n}: AgentOrbProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const palette = typeof colors === \"string\" ? agentOrbPresets[colors] : colors;\n  const list = React.useMemo(() => [...palette, ...ERROR_PALETTE, \"var(--kura-canvas)\"], [palette]);\n  const { colors: resolved, version } = useResolvedColors(rootRef, list);\n\n  // Everything the render loop needs, without re-creating uniforms on every prop change.\n  const live = React.useRef({ state, analyser, inputLevel, outputLevel, reducedMotion });\n  live.current = { state, analyser, inputLevel, outputLevel, reducedMotion };\n\n  // Animated uniform state, eased toward the targets of the current `state` every frame.\n  const sim = React.useRef({\n    ...TARGETS[state],\n    level: 0,\n    spin: 0,\n    phase: 0,\n    last: 0,\n    out: {\n      deep: [0, 0, 0] as Vec3,\n      mid: [0, 0, 0] as Vec3,\n      bright: [0, 0, 0] as Vec3,\n      glint: [0, 0, 0] as Vec3,\n    },\n    light: 0,\n  });\n\n  // Under reduced motion the shader is frozen in time; wake it briefly to fade between states.\n  const [awake, setAwake] = React.useState(true);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: wake on state/theme/palette changes.\n  React.useEffect(() => {\n    if (!reducedMotion) return;\n    setAwake(true);\n    const id = setTimeout(() => setAwake(false), 900);\n    return () => clearTimeout(id);\n  }, [reducedMotion, state, version, list]);\n\n  const uniforms = React.useMemo(() => {\n    const tick = () => {\n      const s = sim.current;\n      const now = performance.now();\n      const dt = s.last ? Math.min(0.1, (now - s.last) / 1000) : 1;\n      s.last = now;\n      const {\n        state: st,\n        analyser: an,\n        inputLevel: inL,\n        outputLevel: outL,\n        reducedMotion: rm,\n      } = live.current;\n      const target = TARGETS[st];\n      const k = 1 - Math.exp(-dt / 0.35); // ~350ms ease, retargets mid-flight\n      s.energy = lerp(s.energy, target.energy, k);\n      s.swirl = lerp(s.swirl, target.swirl, k);\n      s.spinSpeed = lerp(s.spinSpeed, target.spinSpeed, k);\n      s.flow = lerp(s.flow, target.flow, k);\n      s.breath = lerp(s.breath, target.breath, k);\n      s.halo = lerp(s.halo, target.halo, k);\n      s.error = lerp(s.error, target.error, 1 - Math.exp(-dt / 0.25));\n\n      let level = 0;\n      if (!rm && (st === \"listening\" || st === \"speaking\")) {\n        const manual = st === \"listening\" ? inL : outL;\n        level = manual ?? an?.read().level ?? 0;\n        level = Math.min(1, Math.max(0, level));\n      }\n      // Fast attack, slower release, like a VU meter.\n      s.level = lerp(s.level, level, 1 - Math.exp(-dt / (level > s.level ? 0.05 : 0.18)));\n      if (!rm) {\n        s.spin += dt * s.spinSpeed * 1.6;\n        s.phase += dt * s.flow;\n      }\n\n      const c = resolved.current;\n      const mix = (i: number, out: Vec3) => {\n        const a = c[i]?.linear ?? [0.3, 0.3, 0.3];\n        const b = c[i + 4]?.linear ?? a;\n        for (let j = 0; j < 3; j++) out[j] = lerp(a[j]!, b[j]!, s.error);\n        return out;\n      };\n      mix(0, s.out.deep);\n      mix(1, s.out.mid);\n      mix(2, s.out.bright);\n      mix(3, s.out.glint);\n      s.light = (c[8]?.oklab[0] ?? 1) > 0.55 ? 1 : 0;\n      return s.out.deep;\n    };\n    const s = sim.current;\n    // ShaderCanvas evaluates uniforms in insertion order: `u_deep` runs the tick first.\n    return {\n      u_deep: tick,\n      u_mid: () => s.out.mid,\n      u_bright: () => s.out.bright,\n      u_glint: () => s.out.glint,\n      u_energy: () => s.energy + s.level * 0.5,\n      u_swirl: () => s.swirl,\n      u_spin: () => s.spin,\n      u_phase: () => s.phase,\n      u_level: () => s.level,\n      u_breath: () => s.breath,\n      u_halo: () => s.halo + s.level * 0.6,\n      u_light: () => s.light,\n    };\n  }, [resolved]);\n\n  const text = label ?? STATE_LABEL[state];\n  const [deep, mid, bright, glint] = state === \"error\" ? ERROR_PALETTE : palette;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"agent-orb\"\n      data-state={state}\n      className={cn(\"relative isolate aspect-square\", className)}\n      style={style}\n      {...props}\n    >\n      <ShaderCanvas\n        data-slot=\"agent-orb-canvas\"\n        fragmentShader={AGENT_ORB_FRAG}\n        uniforms={uniforms}\n        respectReducedMotion={false}\n        speed={reducedMotion ? 0 : 1}\n        paused={reducedMotion && !awake}\n        maxDpr={maxDpr}\n        className=\"absolute inset-0\"\n        fallback={\n          <div\n            aria-hidden\n            data-slot=\"agent-orb-fallback\"\n            className={cn(\n              \"absolute inset-[15%] overflow-hidden rounded-full transition-[scale] duration-(--kura-duration-slower) ease-kura-out motion-reduce:transition-none\",\n              state === \"speaking\" && \"scale-[1.04] motion-reduce:scale-100\",\n            )}\n            style={{\n              background: mid,\n              boxShadow: `0 0 48px -8px color-mix(in oklab, ${bright} 45%, transparent)`,\n            }}\n          >\n            <div\n              className={cn(\n                \"absolute inset-0 rounded-full\",\n                state === \"thinking\" &&\n                  \"animate-kura-spin [animation-duration:2.4s] motion-reduce:animate-none\",\n              )}\n              style={{\n                background: `conic-gradient(from 200deg, ${mid}, ${deep}, ${mid}, ${bright}, ${mid})`,\n              }}\n            />\n            <div\n              className=\"absolute inset-0 rounded-full\"\n              style={{\n                background: `radial-gradient(circle at 34% 28%, ${glint} 0%, transparent 16%), radial-gradient(circle at 60% 82%, color-mix(in oklab, ${bright} 70%, transparent) 0%, transparent 42%), radial-gradient(circle at 50% 50%, transparent 58%, color-mix(in oklab, ${bright} 55%, transparent) 100%)`,\n              }}\n            />\n          </div>\n        }\n      />\n      <span className=\"sr-only\" {...(announce ? { role: \"status\", \"aria-live\": \"polite\" } : {})}>\n        {text}\n      </span>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/agent-orb/index.ts",
          "type": "registry:ui",
          "content": "export { AgentOrb, type AgentOrbProps } from \"./agent-orb\";\n"
        }
      ]
    },
    {
      "slug": "agent-timeline",
      "name": "AgentTimeline",
      "category": "agent",
      "status": "beta",
      "description": "Vertical plan tracker for multi-step agents: queued/active/done/failed/skipped steps, nested sub-steps, live durations and a connector that fills.",
      "whenToUse": [
        "Show an agent's plan and its progress (coding agents, research agents, deploy pipelines driven by a model).",
        "Show a todo list the model maintains (Claude Code / Cursor style task lists) with the current step highlighted.",
        "Nest sub-steps under a step, or attach a ToolCall / CodeBlock to a step via `content`."
      ],
      "whenNotToUse": [
        "A single tool invocation — use tool-call.",
        "Free-form model thinking — use reasoning.",
        "Generic (non-agent) wizards or checkout steppers — use a regular stepper; the active state here uses the AI signal colour."
      ],
      "props": [
        {
          "name": "steps",
          "type": "readonly AgentTimelineStep[]",
          "required": true,
          "description": "The plan, in display order. AgentTimelineStep = { id, title, status: \"queued\" | \"active\" | \"done\" | \"failed\" | \"skipped\", description?, duration? (ms), startedAt?, steps? (nested), content? }."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Agent progress\"",
          "description": "Accessible name of the list (e.g. the task name)."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "default": "\"md\"",
          "description": "md for plan panels, sm for dense sidebars. Nested sub-steps always render at sm."
        }
      ],
      "parts": [
        {
          "name": "AgentTimeline",
          "description": "Root. `data-size`, `data-progress=\"done/total\"`; contains a polite status region.",
          "dataSlot": "agent-timeline"
        },
        {
          "name": "List",
          "description": "The <ol> (nested lists for sub-steps).",
          "dataSlot": "agent-timeline-list"
        },
        {
          "name": "Step",
          "description": "<li> with `data-status`; the active step has `aria-current=\"step\"`.",
          "dataSlot": "agent-timeline-step"
        },
        {
          "name": "Node",
          "description": "Status node: hollow ring (queued), jade pulse (active), check (done), cross (failed), dash (skipped).",
          "dataSlot": "agent-timeline-node"
        },
        {
          "name": "Connector",
          "description": "Hairline track to the next step; its fill scales in from the top (`data-filled`) once the step is done or skipped.",
          "dataSlot": "agent-timeline-connector"
        },
        {
          "name": "Title",
          "description": "Shimmers while active; struck through when skipped.",
          "dataSlot": "agent-timeline-title"
        },
        {
          "name": "Duration",
          "description": "Mono tabular duration; ticks live for the active step.",
          "dataSlot": "agent-timeline-duration"
        },
        {
          "name": "Description",
          "description": "Muted secondary line (danger-tinted on failure).",
          "dataSlot": "agent-timeline-description"
        },
        {
          "name": "Content",
          "description": "The step's `content` slot.",
          "dataSlot": "agent-timeline-content"
        }
      ],
      "a11y": {
        "role": "list",
        "notes": [
          "Rendered as an ordered list; each step's status is spoken after its title (“Edit files (in progress)”) and the active step has aria-current=\"step\".",
          "A polite status region announces “Step 3 of 5”, “A step failed” or “All steps complete” — the live timers are never announced.",
          "Nodes and connectors are decorative (aria-hidden); status is never conveyed by colour alone."
        ],
        "reducedMotion": "The active pulse and title shimmer stop; connectors fade in instead of growing; check/cross icons appear without scale."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "shimmer-text"
        ]
      },
      "related": [
        "tool-call",
        "reasoning",
        "approval-prompt",
        "shimmer-text"
      ],
      "tags": [
        "agent",
        "plan",
        "timeline",
        "steps",
        "progress",
        "todo",
        "stepper",
        "tasks"
      ],
      "examples": [
        {
          "name": "AgentTimelineMinimal",
          "title": "Three steps",
          "description": "Done, active and queued.",
          "kind": "minimal",
          "code": "import { AgentTimeline } from \"@kura-ui/react/agent-timeline\";\n\nexport function AgentTimelineMinimal() {\n  return (\n    <AgentTimeline\n      className=\"w-full max-w-md\"\n      steps={[\n        { id: \"read\", title: \"Read the failing test\", status: \"done\", duration: 1200 },\n        { id: \"fix\", title: \"Patch the date parser\", status: \"active\" },\n        { id: \"verify\", title: \"Re-run the test suite\", status: \"queued\" },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "AgentTimelineFull",
          "title": "Live agent run",
          "description": "A plan that progresses on its own: live timers, nested sub-steps, a description, filling connectors.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { AgentTimeline, type AgentTimelineStep } from \"@kura-ui/react/agent-timeline\";\n\nconst PLAN: Plan[] = [\n  { id: \"explore\", title: \"Explore the repository\", ms: 1400 },\n  {\n    id: \"plan\",\n    title: \"Plan the migration\",\n    ms: 2200,\n    sub: [\n      { id: \"plan-a\", title: \"List call sites of fetchUser()\" },\n      { id: \"plan-b\", title: \"Check the auth middleware\" },\n    ],\n  },\n  { id: \"edit\", title: \"Edit 4 files\", ms: 2600 },\n  { id: \"test\", title: \"Run the test suite\", ms: 2000 },\n  { id: \"pr\", title: \"Open a pull request\", ms: 1200 },\n];\n\nfunction stepsAt(cursor: number, startedAt: number | undefined): AgentTimelineStep[] {\n  return PLAN.map((p, i) => ({\n    id: p.id,\n    title: p.title,\n    status: i < cursor ? \"done\" : i === cursor ? \"active\" : \"queued\",\n    duration: i < cursor ? p.ms : undefined,\n    startedAt: i === cursor ? startedAt : undefined,\n    description: p.id === \"edit\" && i <= cursor ? \"src/api/user.ts, src/lib/auth.ts +2\" : undefined,\n    steps: p.sub?.map((s, j) => ({\n      id: s.id,\n      title: s.title,\n      status:\n        i < cursor ? \"done\" : i === cursor ? (j === 0 ? \"done\" : \"active\") : (\"queued\" as const),\n      duration: i < cursor || (i === cursor && j === 0) ? 800 + j * 400 : undefined,\n    })),\n  }));\n}\n\nexport function AgentTimelineFull() {\n  const [cursor, setCursor] = React.useState(0);\n  const [startedAt, setStartedAt] = React.useState<number>();\n  const [run, setRun] = React.useState(0);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the plan\n  React.useEffect(() => {\n    let i = 0;\n    setCursor(0);\n    setStartedAt(Date.now());\n    let id: ReturnType<typeof setTimeout>;\n    const next = () => {\n      id = setTimeout(() => {\n        i++;\n        setCursor(i);\n        setStartedAt(Date.now());\n        if (i < PLAN.length) next();\n      }, PLAN[i]!.ms);\n    };\n    next();\n    return () => clearTimeout(id);\n  }, [run]);\n\n  const done = cursor >= PLAN.length;\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-4\">\n      <AgentTimeline steps={stepsAt(cursor, startedAt)} label=\"Migration plan\" size=\"md\" />\n      <div className=\"flex items-center justify-between\">\n        <span className=\"font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n          {Math.min(cursor, PLAN.length)}/{PLAN.length} steps\n        </span>\n        <Button variant=\"secondary\" size=\"sm\" disabled={!done} onClick={() => setRun((r) => r + 1)}>\n          <RotateCcw /> Replay\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "AgentTimelineStates",
          "title": "Every state, small",
          "description": "Done, skipped, failed and queued at size sm.",
          "kind": "state",
          "code": "import { AgentTimeline } from \"@kura-ui/react/agent-timeline\";\n\nexport function AgentTimelineStates() {\n  return (\n    <AgentTimeline\n      size=\"sm\"\n      className=\"w-full max-w-sm\"\n      steps={[\n        { id: \"1\", title: \"Install dependencies\", status: \"done\", duration: 8300 },\n        { id: \"2\", title: \"Generate types\", status: \"skipped\", description: \"Already up to date\" },\n        {\n          id: \"3\",\n          title: \"Build the docs\",\n          status: \"failed\",\n          duration: 21_400,\n          description: \"next build exited with code 1\",\n        },\n        { id: \"4\", title: \"Deploy preview\", status: \"queued\" },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "AgentTimelineWithTools",
          "title": "Steps with tool calls",
          "description": "Inline ToolCalls attached to steps through `content`, inside a task card.",
          "kind": "composition",
          "code": "import { FileText, Terminal } from \"lucide-react\";\nimport { ToolCall } from \"@kura-ui/react/tool-call\";\nimport { AgentTimeline } from \"@kura-ui/react/agent-timeline\";\n\nexport function AgentTimelineWithTools() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-4 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm\">\n      <div className=\"flex items-baseline justify-between\">\n        <h3 className=\"font-medium text-kura-fg text-sm\">Fix flaky date test</h3>\n        <span className=\"font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">2/3</span>\n      </div>\n      <AgentTimeline\n        label=\"Fix flaky date test\"\n        steps={[\n          {\n            id: \"read\",\n            title: \"Read the failing test\",\n            status: \"done\",\n            duration: 900,\n            content: (\n              <ToolCall\n                variant=\"inline\"\n                name=\"read_file\"\n                icon={<FileText />}\n                status=\"success\"\n                duration={120}\n                args={{ path: \"src/date.test.ts\" }}\n              />\n            ),\n          },\n          {\n            id: \"run\",\n            title: \"Reproduce with a fixed timezone\",\n            status: \"active\",\n            content: (\n              <ToolCall\n                variant=\"inline\"\n                name=\"run_command\"\n                icon={<Terminal />}\n                status=\"running\"\n                args={{ command: \"TZ=Pacific/Auckland pnpm test date\" }}\n              />\n            ),\n          },\n          { id: \"fix\", title: \"Patch and verify\", status: \"queued\" },\n        ]}\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Keep `id`s stable across updates so timers and connector transitions animate instead of remounting.",
        "Only one step should be `active` at a time per level; it carries the jade signal.",
        "Pass `duration` (ms) for finished steps; omit it for the active step to get a live timer (or pass `startedAt` from your server).",
        "Map a model-maintained todo list: pending → queued, in_progress → active, completed → done."
      ],
      "title": "Agent Timeline",
      "importPath": "@kura-ui/react/agent-timeline",
      "exports": [
        "AgentStepStatus",
        "AgentTimeline",
        "AgentTimelineProps",
        "AgentTimelineStep",
        "formatStepDuration"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/agent-timeline",
      "markdownUrl": "https://kuro.abstergo.fyi/components/agent-timeline.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/agent-timeline.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/agent-timeline.json",
        "cli": "npx @kura-ui/cli add agent-timeline",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "shimmer-text",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "steps": {
            "description": "The plan, in display order. AgentTimelineStep = { id, title, status: \"queued\" | \"active\" | \"done\" | \"failed\" | \"skipped\", description?, duration? (ms), startedAt?, steps? (nested), content? }.",
            "x-ts-type": "readonly AgentTimelineStep[]",
            "type": "array"
          },
          "label": {
            "description": "Accessible name of the list (e.g. the task name).",
            "x-ts-type": "string",
            "type": "string",
            "default": "Agent progress"
          },
          "size": {
            "description": "md for plan panels, sm for dense sidebars. Nested sub-steps always render at sm.",
            "x-ts-type": "\"sm\" | \"md\"",
            "type": "string",
            "enum": [
              "md",
              "sm"
            ],
            "default": "md"
          }
        },
        "required": [
          "steps"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/agent-timeline/agent-timeline.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Check, Minus, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type AgentStepStatus = \"queued\" | \"active\" | \"done\" | \"failed\" | \"skipped\";\n\nexport interface AgentTimelineStep {\n  /** Stable key. */\n  id: string;\n  /** Short imperative title, e.g. \"Run the test suite\". */\n  title: React.ReactNode;\n  status: AgentStepStatus;\n  /** Secondary line under the title (muted). */\n  description?: React.ReactNode;\n  /** Final duration in ms, shown on the right. */\n  duration?: number;\n  /** Start time of an active step; shows a live timer. Default: when the step became active. */\n  startedAt?: Date | number;\n  /** Nested sub-steps, rendered as a smaller timeline under this step. */\n  steps?: readonly AgentTimelineStep[];\n  /** Extra content under the step (a ToolCall, a CodeBlock, a link…). */\n  content?: React.ReactNode;\n}\n\nexport interface AgentTimelineProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** The plan. Order is display order. */\n  steps: readonly AgentTimelineStep[];\n  /** Accessible name of the list. Default “Agent progress”. */\n  label?: string;\n  /** `md` (default) for a plan panel; `sm` for dense sidebars. Nested sub-steps always render one size down. */\n  size?: \"sm\" | \"md\";\n}\n\nconst spoken: Record<AgentStepStatus, string> = {\n  queued: \"queued\",\n  active: \"in progress\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n};\n\nexport function formatStepDuration(ms: number): string {\n  if (ms < 1000) return `${Math.round(ms)}ms`;\n  const s = ms / 1000;\n  if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`;\n  const r = Math.round(s);\n  return `${Math.floor(r / 60)}m ${String(r % 60).padStart(2, \"0\")}s`;\n}\n\nfunction useLiveDuration(active: boolean, startedAt?: Date | number): number | null {\n  const start = React.useRef<number | null>(null);\n  const [now, setNow] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    if (!active) {\n      start.current = null;\n      setNow(null);\n      return;\n    }\n    start.current = startedAt != null ? +new Date(startedAt) : Date.now();\n    setNow(Date.now());\n    const id = setInterval(() => setNow(Date.now()), 200);\n    return () => clearInterval(id);\n  }, [active, startedAt]);\n  return active && start.current != null && now != null ? now - start.current : null;\n}\n\nfunction Node({ status, small }: { status: AgentStepStatus; small: boolean }) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"agent-timeline-node\"\n      data-status={status}\n      className={cn(\n        \"relative z-10 grid shrink-0 place-items-center rounded-full bg-kura-surface transition-[background-color,color,box-shadow] duration-(--kura-duration-base) ease-kura-out\",\n        small ? \"size-4 [&_svg]:size-2.5\" : \"size-5 [&_svg]:size-3\",\n        status === \"queued\" &&\n          \"text-kura-fg-subtle shadow-[inset_0_0_0_1.5px_var(--kura-border-strong)]\",\n        status === \"active\" && \"bg-kura-accent-subtle text-kura-accent-text\",\n        status === \"done\" &&\n          \"bg-kura-surface-sunken text-kura-success-text shadow-[inset_0_0_0_1px_var(--kura-border)]\",\n        status === \"failed\" && \"bg-kura-danger-subtle text-kura-danger-text\",\n        status === \"skipped\" && \"text-kura-fg-subtle shadow-[inset_0_0_0_1px_var(--kura-border)]\",\n      )}\n    >\n      {status === \"active\" && (\n        <>\n          <span className=\"absolute inset-0 animate-kura-pulse rounded-full shadow-[0_0_0_3px_var(--kura-accent-subtle)] motion-reduce:animate-none\" />\n          <span className={cn(\"rounded-full bg-current\", small ? \"size-1.5\" : \"size-2\")} />\n        </>\n      )}\n      {status === \"done\" && <Check strokeWidth={3} className=\"animate-kura-pop-in\" />}\n      {status === \"failed\" && <X strokeWidth={3} className=\"animate-kura-pop-in\" />}\n      {status === \"skipped\" && <Minus strokeWidth={3} />}\n    </span>\n  );\n}\n\nfunction Step({ step, last, small }: { step: AgentTimelineStep; last: boolean; small: boolean }) {\n  const active = step.status === \"active\";\n  const live = useLiveDuration(active, step.startedAt);\n  const ms = step.duration ?? live;\n  // The connector below a step fills once the agent has moved past it.\n  const filled = step.status === \"done\" || step.status === \"skipped\";\n  return (\n    <li\n      data-slot=\"agent-timeline-step\"\n      data-status={step.status}\n      aria-current={active ? \"step\" : undefined}\n      className={cn(\"relative flex gap-3\", !last && (small ? \"pb-3\" : \"pb-5\"))}\n    >\n      {!last && (\n        <span\n          aria-hidden\n          data-slot=\"agent-timeline-connector\"\n          data-filled={filled}\n          className={cn(\n            \"absolute w-px overflow-hidden rounded-full bg-kura-border\",\n            small ? \"top-[19px] bottom-[3px] left-[7.5px]\" : \"top-[23px] bottom-[3px] left-[9.5px]\",\n          )}\n        >\n          <span\n            className={cn(\n              \"absolute inset-0 origin-top transition-[scale,opacity] duration-(--kura-duration-slower) ease-kura-in-out motion-reduce:transition-opacity\",\n              \"bg-kura-fg-subtle\",\n              filled ? \"scale-y-100 opacity-100\" : \"scale-y-0 opacity-0 motion-reduce:scale-y-100\",\n            )}\n          />\n        </span>\n      )}\n      <span className={cn(\"flex shrink-0\", small ? \"pt-px\" : \"pt-0.5\")}>\n        <Node status={step.status} small={small} />\n      </span>\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n        <div\n          className={cn(\n            \"flex items-baseline gap-3\",\n            small ? \"min-h-[18px] text-[13px]\" : \"min-h-6 text-sm\",\n          )}\n        >\n          <span\n            data-slot=\"agent-timeline-title\"\n            className={cn(\n              \"min-w-0 flex-1 transition-colors duration-(--kura-duration-base)\",\n              step.status === \"queued\" && \"text-kura-fg-muted\",\n              step.status === \"active\" && \"font-medium text-kura-fg\",\n              step.status === \"done\" && \"text-kura-fg\",\n              step.status === \"failed\" && \"text-kura-fg\",\n              step.status === \"skipped\" &&\n                \"text-kura-fg-subtle line-through decoration-kura-border-strong\",\n            )}\n          >\n            {active ? <ShimmerText duration={2.4}>{step.title}</ShimmerText> : step.title}\n            <span className=\"sr-only\"> ({spoken[step.status]})</span>\n          </span>\n          {ms != null && (\n            <span\n              data-slot=\"agent-timeline-duration\"\n              className=\"shrink-0 font-kura-mono text-kura-fg-subtle text-xs tabular-nums\"\n            >\n              {formatStepDuration(ms)}\n            </span>\n          )}\n        </div>\n        {step.description != null && (\n          <p\n            data-slot=\"agent-timeline-description\"\n            className={cn(\n              \"text-kura-fg-muted leading-relaxed\",\n              small ? \"text-xs\" : \"text-[13px]\",\n              step.status === \"failed\" && \"text-kura-danger-text\",\n            )}\n          >\n            {step.description}\n          </p>\n        )}\n        {step.content != null && (\n          <div data-slot=\"agent-timeline-content\" className=\"pt-1.5\">\n            {step.content}\n          </div>\n        )}\n        {step.steps && step.steps.length > 0 && (\n          <div className=\"pt-2\">\n            <StepList steps={step.steps} small label={undefined} />\n          </div>\n        )}\n      </div>\n    </li>\n  );\n}\n\nfunction StepList({\n  steps,\n  small,\n  label,\n}: {\n  steps: readonly AgentTimelineStep[];\n  small: boolean;\n  label: string | undefined;\n}) {\n  return (\n    <ol aria-label={label} data-slot=\"agent-timeline-list\" className=\"flex flex-col\">\n      {steps.map((step, i) => (\n        <Step key={step.id} step={step} last={i === steps.length - 1} small={small} />\n      ))}\n    </ol>\n  );\n}\n\n/**\n * Vertical plan/step tracker for multi-step agents. Steps are queued, active (jade pulse,\n * shimmering title, live timer), done, failed or skipped; sub-steps nest; the connector between\n * nodes fills as the agent moves past each step.\n */\nexport function AgentTimeline({\n  steps,\n  label = \"Agent progress\",\n  size = \"md\",\n  className,\n  ...props\n}: AgentTimelineProps) {\n  const total = steps.length;\n  const doneCount = steps.filter((s) => s.status === \"done\" || s.status === \"skipped\").length;\n  const activeIndex = steps.findIndex((s) => s.status === \"active\");\n  const failed = steps.find((s) => s.status === \"failed\");\n  const active = activeIndex >= 0 ? steps[activeIndex] : undefined;\n  const announcement = failed\n    ? \"A step failed\"\n    : active\n      ? `Step ${activeIndex + 1} of ${total}`\n      : doneCount === total\n        ? \"All steps complete\"\n        : \"\";\n\n  return (\n    <div\n      data-slot=\"agent-timeline\"\n      data-size={size}\n      data-progress={`${doneCount}/${total}`}\n      className={cn(\"font-kura-sans\", className)}\n      {...props}\n    >\n      <StepList steps={steps} small={size === \"sm\"} label={label} />\n      <span className=\"sr-only\" role=\"status\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/agent-timeline/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type AgentStepStatus,\n  AgentTimeline,\n  type AgentTimelineProps,\n  type AgentTimelineStep,\n  formatStepDuration,\n} from \"./agent-timeline\";\n"
        }
      ]
    },
    {
      "slug": "approval-prompt",
      "name": "ApprovalPrompt",
      "category": "agent",
      "status": "beta",
      "description": "Human-in-the-loop permission card (“Kura wants to run `rm -rf build`”) with risk level, details, Approve/Deny/Always allow, ⌘↵ and Esc, and resolved states.",
      "whenToUse": [
        "An agent needs explicit permission before a side effect: running a shell command, editing files, sending email, spending money, calling a destructive API.",
        "Show exactly what will run (verbatim command) and how risky it is before the user decides.",
        "Offer “Always allow” for a class of actions in this session (persist the rule yourself in onAlwaysAllow).",
        "Keep the decision visible in the transcript afterwards (Approved / Denied / Always allowed) without the card changing height."
      ],
      "whenNotToUse": [
        "Showing a tool call that does not need permission — use tool-call (it also has an awaiting-approval status for compact inline approvals).",
        "Generic confirm dialogs unrelated to an agent (deleting a document) — use a dialog.",
        "Progress of a multi-step plan — use agent-timeline."
      ],
      "props": [
        {
          "name": "title",
          "type": "React.ReactNode",
          "required": true,
          "description": "What the agent wants, e.g. \"Kura wants to run a command\". Labels the region."
        },
        {
          "name": "command",
          "type": "string",
          "description": "The exact command/action, shown verbatim in mono after a $ prompt (wraps, never truncated)."
        },
        {
          "name": "description",
          "type": "React.ReactNode",
          "description": "Why and what it touches; becomes the region's accessible description."
        },
        {
          "name": "risk",
          "type": "\"low\" | \"medium\" | \"high\"",
          "default": "\"medium\"",
          "description": "Badge (green/amber/red dot). high also turns Approve into a red “Run anyway” button."
        },
        {
          "name": "details",
          "type": "React.ReactNode",
          "description": "Content behind a “Details” expander: a diff (CodeBlock), arguments, affected files."
        },
        {
          "name": "decision",
          "type": "\"pending\" | \"approved\" | \"denied\" | \"always\"",
          "description": "Decision (controlled)."
        },
        {
          "name": "defaultDecision",
          "type": "\"pending\" | \"approved\" | \"denied\" | \"always\"",
          "default": "\"pending\"",
          "description": "Initial decision when uncontrolled (use a resolved value to render history)."
        },
        {
          "name": "onDecisionChange",
          "type": "(decision: ApprovalDecision) => void",
          "description": "Called with every decision."
        },
        {
          "name": "onApprove",
          "type": "() => void",
          "description": "Called on Approve (button or ⌘/Ctrl+Enter)."
        },
        {
          "name": "onDeny",
          "type": "() => void",
          "description": "Called on Deny (button or Esc)."
        },
        {
          "name": "onAlwaysAllow",
          "type": "() => void",
          "description": "Shows an “Always allow” ghost button; called when chosen."
        },
        {
          "name": "shortcuts",
          "type": "boolean",
          "default": "true",
          "description": "Bind ⌘/Ctrl+Enter (approve) and Esc (deny) on the document while pending, and show the keys on the buttons. Only one prompt on screen should bind them."
        },
        {
          "name": "approveLabel",
          "type": "string",
          "description": "Approve label. Default “Approve”, or “Run anyway” for high risk."
        },
        {
          "name": "denyLabel",
          "type": "string",
          "default": "\"Deny\"",
          "description": "Deny label."
        },
        {
          "name": "alwaysAllowLabel",
          "type": "string",
          "default": "\"Always allow\"",
          "description": "Always-allow label."
        },
        {
          "name": "icon",
          "type": "React.ReactNode",
          "description": "Icon in the header tile. Default a terminal."
        }
      ],
      "parts": [
        {
          "name": "ApprovalPrompt",
          "description": "<section> labelled by the title and the command (so stacked prompts have distinct names). `data-decision=\"pending\" | \"approved\" | \"denied\" | \"always\"`, `data-risk`.",
          "dataSlot": "approval-prompt"
        },
        {
          "name": "Title",
          "description": "Heading; mutes once resolved.",
          "dataSlot": "approval-prompt-title"
        },
        {
          "name": "Risk",
          "description": "Risk badge with a coloured dot and text.",
          "dataSlot": "approval-prompt-risk"
        },
        {
          "name": "Command",
          "description": "Mono command well; the <code> is `approval-prompt-code`.",
          "dataSlot": "approval-prompt-command"
        },
        {
          "name": "Details",
          "description": "Collapsible details (grid-rows transition, inert when closed).",
          "dataSlot": "approval-prompt-details"
        },
        {
          "name": "Footer",
          "description": "Fixed-height footer holding actions and the resolution, so resolving never shifts layout.",
          "dataSlot": "approval-prompt-footer"
        },
        {
          "name": "Waiting",
          "description": "Jade “Needs approval” pill — the view's one accent while pending.",
          "dataSlot": "approval-prompt-waiting"
        },
        {
          "name": "Actions",
          "description": "Always allow / Deny (Esc) / Approve (⌘↵); inert once resolved.",
          "dataSlot": "approval-prompt-actions"
        },
        {
          "name": "Resolution",
          "description": "role=\"status\" line that cross-fades in: Approved / Denied / Always allowed.",
          "dataSlot": "approval-prompt-resolution"
        }
      ],
      "a11y": {
        "role": "region",
        "notes": [
          "A <section> labelled by the title and described by the description, so screen-reader users land on the whole request.",
          "Buttons expose their shortcuts with aria-keyshortcuts; keycaps are also rendered visually.",
          "The resolution is a status region, so the decision is announced; the action row becomes inert and hidden.",
          "Risk is conveyed in text (“High risk”), not only colour. No dark patterns: Deny is as prominent as Approve."
        ],
        "keyboard": [
          {
            "keys": "Mod+Enter",
            "action": "Approve (while pending, when shortcuts is on)"
          },
          {
            "keys": "Escape",
            "action": "Deny (while pending, when shortcuts is on; not while typing in an input)"
          },
          {
            "keys": "Tab",
            "action": "Details, Always allow, Deny, Approve"
          }
        ],
        "reducedMotion": "The waiting pulse stops and resolved states cross-fade without blur; details open without height travel."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button",
          "kbd"
        ]
      },
      "related": [
        "tool-call",
        "agent-timeline",
        "code-block",
        "kbd-shortcut"
      ],
      "tags": [
        "approval",
        "permission",
        "human-in-the-loop",
        "confirm",
        "agent",
        "security",
        "shortcuts",
        "consent"
      ],
      "examples": [
        {
          "name": "ApprovalPromptMinimal",
          "title": "Low-risk command",
          "description": "Title, command and risk.",
          "kind": "minimal",
          "code": "import { ApprovalPrompt } from \"@kura-ui/react/approval-prompt\";\n\nexport function ApprovalPromptMinimal() {\n  return (\n    <ApprovalPrompt\n      title=\"Kura wants to run a command\"\n      command=\"pnpm install\"\n      risk=\"low\"\n      shortcuts={false}\n      className=\"w-full max-w-lg\"\n    />\n  );\n}\n"
        },
        {
          "name": "ApprovalPromptFull",
          "title": "High-risk, every option",
          "description": "Description, details, controlled decision, callbacks, Always allow, custom labels and ⌘↵ / Esc.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { type ApprovalDecision, ApprovalPrompt } from \"@kura-ui/react/approval-prompt\";\n\nexport function ApprovalPromptFull() {\n  const [decision, setDecision] = React.useState<ApprovalDecision>(\"pending\");\n  const [log, setLog] = React.useState<string[]>([]);\n  const note = (s: string) => setLog((l) => [...l.slice(-2), s]);\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-3\">\n      <ApprovalPrompt\n        title=\"Kura wants to delete a directory\"\n        command=\"rm -rf build\"\n        description=\"Clears stale output before rebuilding. Affects 1,204 files under ./build only.\"\n        risk=\"high\"\n        details={\n          <ul className=\"flex list-disc flex-col gap-1 pl-4\">\n            <li>Working directory: ~/code/kura-ui</li>\n            <li>Requested by step 3 of 5, “Rebuild the docs”</li>\n            <li>Not tracked by git — cannot be restored from history</li>\n          </ul>\n        }\n        decision={decision}\n        onDecisionChange={setDecision}\n        onApprove={() => note(\"approved\")}\n        onDeny={() => note(\"denied\")}\n        onAlwaysAllow={() => note(\"always allowed\")}\n        shortcuts\n        approveLabel=\"Run anyway\"\n        denyLabel=\"Deny\"\n        alwaysAllowLabel=\"Always allow\"\n      />\n      <div className=\"flex items-center justify-between\">\n        <span className=\"font-kura-mono text-kura-fg-subtle text-xs\">\n          {log.length ? log.join(\" → \") : \"Try ⌘↵ or Esc\"}\n        </span>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          disabled={decision === \"pending\"}\n          onClick={() => setDecision(\"pending\")}\n        >\n          <RotateCcw /> Reset\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ApprovalPromptResolved",
          "title": "Resolved states",
          "description": "Approved, denied and always-allowed, as they appear in history.",
          "kind": "state",
          "code": "\"use client\";\n\nimport { ApprovalPrompt } from \"@kura-ui/react/approval-prompt\";\n\nexport function ApprovalPromptResolved() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-3\">\n      <ApprovalPrompt\n        title=\"Kura wants to run a command\"\n        command=\"git push origin main\"\n        risk=\"medium\"\n        defaultDecision=\"approved\"\n        shortcuts={false}\n      />\n      <ApprovalPrompt\n        title=\"Kura wants to run a command\"\n        command=\"curl -X DELETE https://api.example.com/v1/projects/42\"\n        risk=\"high\"\n        defaultDecision=\"denied\"\n        shortcuts={false}\n      />\n      <ApprovalPrompt\n        title=\"Kura wants to read a file\"\n        command=\"cat .env.example\"\n        risk=\"low\"\n        defaultDecision=\"always\"\n        onAlwaysAllow={() => {}}\n        shortcuts={false}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ApprovalPromptWithDiff",
          "title": "Approve a file edit",
          "description": "A diff CodeBlock in the details, driving a ToolCall's status.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { FilePen } from \"lucide-react\";\nimport * as React from \"react\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\nimport { ToolCall } from \"@kura-ui/react/tool-call\";\nimport { type ApprovalDecision, ApprovalPrompt } from \"@kura-ui/react/approval-prompt\";\n\nconst DIFF = `@@ -1,4 +1,5 @@\n export const config = {\n-  retries: 1,\n+  retries: 3,\n+  backoff: \"exponential\",\n   timeout: 12_000,\n };`;\n\nexport function ApprovalPromptWithDiff() {\n  const [decision, setDecision] = React.useState<ApprovalDecision>(\"pending\");\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-2\">\n      <ToolCall\n        name=\"edit_file\"\n        title=\"Edit src/config.ts\"\n        icon={<FilePen />}\n        status={\n          decision === \"pending\" ? \"awaiting-approval\" : decision === \"denied\" ? \"error\" : \"success\"\n        }\n        error={decision === \"denied\" ? \"Denied by user.\" : undefined}\n        duration={decision === \"approved\" ? 240 : undefined}\n        summary=\"+2 −1\"\n      />\n      <ApprovalPrompt\n        title=\"Apply this edit?\"\n        icon={<FilePen />}\n        risk=\"low\"\n        decision={decision}\n        onDecisionChange={setDecision}\n        shortcuts={false}\n        approveLabel=\"Apply\"\n        details={<CodeBlock code={DIFF} language=\"ts\" diff header={false} />}\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Show the command verbatim — never paraphrase what will run.",
        "The decision is only UI state: actually gate the side effect on onApprove / onDecisionChange in your agent loop.",
        "Render past prompts with `defaultDecision` (or controlled `decision`) set to the stored outcome and `shortcuts={false}`.",
        "When several prompts are visible, enable `shortcuts` on the newest pending one only."
      ],
      "title": "Approval Prompt",
      "importPath": "@kura-ui/react/approval-prompt",
      "exports": [
        "ApprovalDecision",
        "ApprovalPrompt",
        "ApprovalPromptProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/approval-prompt",
      "markdownUrl": "https://kuro.abstergo.fyi/components/approval-prompt.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/approval-prompt.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/approval-prompt.json",
        "cli": "npx @kura-ui/cli add approval-prompt",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "kbd",
        "use-controllable-state",
        "use-hotkey",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "title": {
            "description": "What the agent wants, e.g. \"Kura wants to run a command\". Labels the region.",
            "x-ts-type": "React.ReactNode"
          },
          "command": {
            "description": "The exact command/action, shown verbatim in mono after a $ prompt (wraps, never truncated).",
            "x-ts-type": "string",
            "type": "string"
          },
          "description": {
            "description": "Why and what it touches; becomes the region's accessible description.",
            "x-ts-type": "React.ReactNode"
          },
          "risk": {
            "description": "Badge (green/amber/red dot). high also turns Approve into a red “Run anyway” button.",
            "x-ts-type": "\"low\" | \"medium\" | \"high\"",
            "type": "string",
            "enum": [
              "high",
              "low",
              "medium"
            ],
            "default": "medium"
          },
          "details": {
            "description": "Content behind a “Details” expander: a diff (CodeBlock), arguments, affected files.",
            "x-ts-type": "React.ReactNode"
          },
          "decision": {
            "description": "Decision (controlled).",
            "x-ts-type": "\"pending\" | \"approved\" | \"denied\" | \"always\"",
            "type": "string",
            "enum": [
              "always",
              "approved",
              "denied",
              "pending"
            ]
          },
          "defaultDecision": {
            "description": "Initial decision when uncontrolled (use a resolved value to render history).",
            "x-ts-type": "\"pending\" | \"approved\" | \"denied\" | \"always\"",
            "type": "string",
            "enum": [
              "always",
              "approved",
              "denied",
              "pending"
            ],
            "default": "pending"
          },
          "onDecisionChange": {
            "description": "Called with every decision.",
            "x-ts-type": "(decision: ApprovalDecision) => void"
          },
          "onApprove": {
            "description": "Called on Approve (button or ⌘/Ctrl+Enter).",
            "x-ts-type": "() => void"
          },
          "onDeny": {
            "description": "Called on Deny (button or Esc).",
            "x-ts-type": "() => void"
          },
          "onAlwaysAllow": {
            "description": "Shows an “Always allow” ghost button; called when chosen.",
            "x-ts-type": "() => void"
          },
          "shortcuts": {
            "description": "Bind ⌘/Ctrl+Enter (approve) and Esc (deny) on the document while pending, and show the keys on the buttons. Only one prompt on screen should bind them.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "approveLabel": {
            "description": "Approve label. Default “Approve”, or “Run anyway” for high risk.",
            "x-ts-type": "string",
            "type": "string"
          },
          "denyLabel": {
            "description": "Deny label.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Deny"
          },
          "alwaysAllowLabel": {
            "description": "Always-allow label.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Always allow"
          },
          "icon": {
            "description": "Icon in the header tile. Default a terminal.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "title"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/approval-prompt/approval-prompt.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Check, ChevronRight, ShieldCheck, Terminal, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/kura/button\";\nimport { Kbd } from \"@/components/kura/kbd\";\n\nexport type ApprovalDecision = \"pending\" | \"approved\" | \"denied\" | \"always\";\n\nexport interface ApprovalPromptProps extends Omit<React.ComponentProps<\"section\">, \"title\"> {\n  /** What the agent wants, e.g. \"Kura wants to run a command\". */\n  title: React.ReactNode;\n  /** The exact command or action, shown verbatim in mono, e.g. \"rm -rf build\". */\n  command?: string;\n  /** One or two sentences of context: why, what it touches. */\n  description?: React.ReactNode;\n  /** Risk level, shown as a badge; `high` makes the approve button red. Default \"medium\". */\n  risk?: \"low\" | \"medium\" | \"high\";\n  /** Extra detail behind a “Details” expander (a diff, the full arguments, affected files…). */\n  details?: React.ReactNode;\n  /** Decision (controlled). */\n  decision?: ApprovalDecision;\n  /** Initial decision when uncontrolled. Default \"pending\". */\n  defaultDecision?: ApprovalDecision;\n  /** Called with every decision the user makes. */\n  onDecisionChange?: (decision: ApprovalDecision) => void;\n  onApprove?: () => void;\n  onDeny?: () => void;\n  /** Shows an “Always allow” action when provided. */\n  onAlwaysAllow?: () => void;\n  /** Bind ⌘/Ctrl+Enter to approve and Esc to deny while pending. Default true. Only one prompt on screen should bind them. */\n  shortcuts?: boolean;\n  /** Approve button label. Default “Approve” (“Run anyway” when risk is high). */\n  approveLabel?: string;\n  /** Deny button label. Default “Deny”. */\n  denyLabel?: string;\n  /** Always-allow button label. Default “Always allow”. */\n  alwaysAllowLabel?: string;\n  /** Icon in the header tile. Default a terminal. */\n  icon?: React.ReactNode;\n}\n\nconst riskLabel = { low: \"Low risk\", medium: \"Medium risk\", high: \"High risk\" } as const;\n\nconst resolvedCopy: Record<Exclude<ApprovalDecision, \"pending\">, string> = {\n  approved: \"Approved\",\n  denied: \"Denied\",\n  always: \"Always allowed\",\n};\n\n/**\n * Human-in-the-loop permission card: “Kura wants to run `rm -rf build`”, with a risk badge, a\n * details expander, Approve / Deny / Always allow, ⌘↵ and Esc shortcuts, and calm resolved states\n * that keep the card's height.\n */\nexport function ApprovalPrompt({\n  title,\n  command,\n  description,\n  risk = \"medium\",\n  details,\n  decision: decisionProp,\n  defaultDecision = \"pending\",\n  onDecisionChange,\n  onApprove,\n  onDeny,\n  onAlwaysAllow,\n  shortcuts = true,\n  approveLabel,\n  denyLabel = \"Deny\",\n  alwaysAllowLabel = \"Always allow\",\n  icon,\n  className,\n  ...props\n}: ApprovalPromptProps) {\n  const [decision, setDecision] = useControllableState<ApprovalDecision>({\n    value: decisionProp,\n    defaultValue: defaultDecision,\n    onChange: onDecisionChange,\n  });\n  const pending = decision === \"pending\";\n  const [open, setOpen] = React.useState(false);\n  const id = React.useId();\n\n  const approve = () => {\n    if (!pending) return;\n    setDecision(\"approved\");\n    onApprove?.();\n  };\n  const deny = () => {\n    if (!pending) return;\n    setDecision(\"denied\");\n    onDeny?.();\n  };\n  const always = () => {\n    if (!pending) return;\n    setDecision(\"always\");\n    onAlwaysAllow?.();\n  };\n\n  useHotkey([\"mod\", \"enter\"], approve, { enabled: shortcuts && pending });\n  useHotkey([\"escape\"], deny, { enabled: shortcuts && pending, allowInInputs: false });\n\n  return (\n    <section\n      aria-labelledby={command != null ? `${id}-title ${id}-code` : `${id}-title`}\n      aria-describedby={description != null ? `${id}-desc` : undefined}\n      data-slot=\"approval-prompt\"\n      data-decision={decision}\n      data-risk={risk}\n      className={cn(\n        \"flex flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans shadow-kura-md transition-shadow duration-(--kura-duration-slow) ease-kura-out\",\n        !pending && \"shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex flex-col gap-3 p-4\">\n        <div className=\"flex items-start gap-3\">\n          <span\n            aria-hidden\n            className={cn(\n              \"grid size-8 shrink-0 place-items-center rounded-kura-md bg-kura-surface-sunken text-kura-fg-muted shadow-[inset_0_0_0_1px_var(--kura-border-subtle)] [&_svg]:size-4\",\n            )}\n          >\n            {icon ?? <Terminal />}\n          </span>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-1 pt-px\">\n            <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n              <h3\n                id={`${id}-title`}\n                data-slot=\"approval-prompt-title\"\n                className={cn(\n                  \"font-medium text-[15px] text-kura-fg leading-snug transition-colors duration-(--kura-duration-base)\",\n                  !pending && \"text-kura-fg-muted\",\n                )}\n              >\n                {title}\n              </h3>\n              <span\n                data-slot=\"approval-prompt-risk\"\n                data-risk={risk}\n                className={cn(\n                  \"inline-flex h-5 items-center gap-1.5 rounded-full px-2 font-medium text-[11px]\",\n                  risk === \"low\" && \"bg-kura-hover text-kura-fg-muted\",\n                  risk === \"medium\" && \"bg-kura-hover text-kura-warning-text\",\n                  risk === \"high\" && \"bg-kura-danger-subtle text-kura-danger-text\",\n                )}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"size-1.5 rounded-full\",\n                    risk === \"low\" && \"bg-kura-success\",\n                    risk === \"medium\" && \"bg-kura-warning\",\n                    risk === \"high\" && \"bg-kura-danger\",\n                  )}\n                />\n                {riskLabel[risk]}\n              </span>\n            </div>\n            {description != null && (\n              <p id={`${id}-desc`} className=\"text-[13px] text-kura-fg-muted leading-relaxed\">\n                {description}\n              </p>\n            )}\n          </div>\n        </div>\n\n        {command != null && (\n          <div\n            data-slot=\"approval-prompt-command\"\n            className=\"flex min-w-0 gap-2 rounded-kura-md bg-kura-surface-sunken px-3 py-2.5 font-kura-mono text-[13px] shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\"\n          >\n            <span aria-hidden className=\"select-none text-kura-fg-subtle\">\n              $\n            </span>\n            <code\n              id={`${id}-code`}\n              data-slot=\"approval-prompt-code\"\n              className=\"min-w-0 flex-1 whitespace-pre-wrap break-all text-kura-fg\"\n            >\n              {command}\n            </code>\n          </div>\n        )}\n\n        {details != null && (\n          <div className=\"flex flex-col\">\n            <button\n              type=\"button\"\n              aria-expanded={open}\n              aria-controls={`${id}-details`}\n              onClick={() => setOpen((o) => !o)}\n              className=\"-mx-1.5 inline-flex h-6 w-fit cursor-pointer items-center gap-1 rounded-kura-sm px-1.5 text-kura-fg-muted text-xs outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus\"\n            >\n              <ChevronRight\n                aria-hidden\n                className={cn(\n                  \"size-3 transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                  open && \"rotate-90\",\n                )}\n              />\n              {open ? \"Hide details\" : \"Details\"}\n            </button>\n            <div\n              id={`${id}-details`}\n              data-slot=\"approval-prompt-details\"\n              inert={!open || undefined}\n              className={cn(\n                \"grid transition-[grid-template-rows,opacity] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-opacity\",\n                open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n              )}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <div className=\"pt-2 text-[13px] text-kura-fg-muted\">{details}</div>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div\n        data-slot=\"approval-prompt-footer\"\n        className=\"relative flex h-14 items-center bg-kura-surface-sunken/60 px-4 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n      >\n        <div\n          data-slot=\"approval-prompt-actions\"\n          inert={!pending || undefined}\n          aria-hidden={!pending || undefined}\n          className={cn(\n            \"flex w-full items-center gap-2 transition-[opacity,filter] duration-(--kura-duration-fast) ease-kura-out\",\n            !pending && \"pointer-events-none opacity-0 blur-[2px] motion-reduce:blur-none\",\n          )}\n        >\n          <span\n            className=\"mr-auto inline-flex items-center gap-1.5 rounded-full bg-kura-accent-subtle px-2 py-0.5 font-medium text-kura-accent-text text-xs\"\n            data-slot=\"approval-prompt-waiting\"\n          >\n            <span\n              aria-hidden\n              className=\"size-1.5 animate-kura-pulse rounded-full bg-current motion-reduce:animate-none\"\n            />\n            Needs approval\n          </span>\n          {onAlwaysAllow && (\n            <Button variant=\"ghost\" size=\"sm\" onClick={always}>\n              {alwaysAllowLabel}\n            </Button>\n          )}\n          <Button\n            variant=\"secondary\"\n            size=\"sm\"\n            onClick={deny}\n            aria-keyshortcuts={shortcuts ? \"Escape\" : undefined}\n          >\n            {denyLabel}\n            {shortcuts && <Kbd keys={[\"esc\"]} variant=\"ghost\" size=\"sm\" className=\"-mr-1\" />}\n          </Button>\n          <Button\n            variant={risk === \"high\" ? \"danger\" : \"primary\"}\n            size=\"sm\"\n            onClick={approve}\n            aria-keyshortcuts={shortcuts ? \"Meta+Enter Control+Enter\" : undefined}\n          >\n            {approveLabel ?? (risk === \"high\" ? \"Run anyway\" : \"Approve\")}\n            {shortcuts && (\n              <Kbd\n                keys={[\"mod\", \"enter\"]}\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"-mr-1 [&_[data-slot=kbd-key]]:bg-white/15 [&_[data-slot=kbd-key]]:text-current\"\n              />\n            )}\n          </Button>\n        </div>\n\n        <div\n          data-slot=\"approval-prompt-resolution\"\n          role=\"status\"\n          className={cn(\n            \"absolute inset-0 flex items-center gap-2 px-4 text-[13px] transition-[opacity,filter] duration-(--kura-duration-base) ease-kura-out\",\n            pending\n              ? \"pointer-events-none opacity-0 blur-[2px] motion-reduce:blur-none\"\n              : \"opacity-100\",\n            decision === \"denied\" ? \"text-kura-fg-muted\" : \"text-kura-fg\",\n          )}\n        >\n          {!pending && (\n            <>\n              <span\n                aria-hidden\n                className={cn(\n                  \"grid size-5 animate-kura-pop-in place-items-center rounded-full [&_svg]:size-3\",\n                  decision === \"denied\"\n                    ? \"bg-kura-hover text-kura-fg-muted\"\n                    : \"bg-kura-hover text-kura-success-text\",\n                )}\n              >\n                {decision === \"denied\" ? (\n                  <X strokeWidth={2.5} />\n                ) : decision === \"always\" ? (\n                  <ShieldCheck />\n                ) : (\n                  <Check strokeWidth={2.5} />\n                )}\n              </span>\n              <span className=\"font-medium\">{resolvedCopy[decision]}</span>\n              {decision === \"always\" && (\n                <span className=\"text-kura-fg-subtle\">· won't ask again this session</span>\n              )}\n            </>\n          )}\n        </div>\n      </div>\n    </section>\n  );\n}\n"
        },
        {
          "target": "components/kura/approval-prompt/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type ApprovalDecision,\n  ApprovalPrompt,\n  type ApprovalPromptProps,\n} from \"./approval-prompt\";\n"
        }
      ]
    },
    {
      "slug": "artifact-card",
      "name": "ArtifactCard",
      "category": "conversation",
      "status": "beta",
      "description": "Card for something an agent made — document, code, web page, image, sheet or diagram — with generating/updating progress, versions and open-in-panel.",
      "whenToUse": [
        "An assistant message produced a standalone deliverable (a document, a code file, a web page, an image, a CSV, a diagram) that the user opens in a side panel or another page.",
        "Show an artifact while the agent is still writing it: status=\"generating\" with a live statusText and optional progress, then flip to \"ready\".",
        "An artifact the user asked to change: status=\"updating\" plus a ‹ v2/3 › version switcher (versions/version/onVersionChange).",
        "A list of everything the agent made in a session, e.g. a files/outputs sidebar (compact variant, selected marks the one that is open).",
        "A visual card with a thumbnail of the artifact under the title (variant=\"preview\" with a `preview` node: a text excerpt, an <img>, a headerless CodeBlock)."
      ],
      "whenNotToUse": [
        "Code the user should read inline in the answer — use code-block directly.",
        "A file the user uploaded or attached to the prompt — use attachment.",
        "One tool invocation with arguments and a result (read_file, run_command) — use tool-call.",
        "A link to an external web source the answer relies on — use citation.",
        "A multi-step plan of work — use agent-timeline."
      ],
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "The artifact's name, e.g. \"Q3 launch plan\" or \"pricing-table.tsx\". Truncates."
        },
        {
          "name": "kind",
          "type": "\"document\" | \"code\" | \"web\" | \"image\" | \"data\" | \"diagram\" | \"file\"",
          "default": "\"document\"",
          "description": "What the agent made. Picks the icon, the spoken label (Document, Code, Web page, Image, Spreadsheet, Diagram, File) and the preview placeholder."
        },
        {
          "name": "status",
          "type": "\"generating\" | \"updating\" | \"ready\" | \"error\"",
          "default": "\"ready\"",
          "description": "Lifecycle. generating = first write and updating = an edit (both: jade icon tile, shimmering statusText, progress bar, aria-busy); error = danger tile and message."
        },
        {
          "name": "meta",
          "type": "React.ReactNode",
          "description": "Secondary line when ready, e.g. \"Markdown · 1,240 words\" or \"TypeScript · 94 lines\". Default: the kind label."
        },
        {
          "name": "statusText",
          "type": "React.ReactNode",
          "description": "Live line while busy, e.g. \"Writing section 3 of 5…\". Default \"Generating…\" / \"Updating…\". Not announced on every change."
        },
        {
          "name": "error",
          "type": "React.ReactNode",
          "description": "Message for status=\"error\". Default \"Couldn't finish\"."
        },
        {
          "name": "progress",
          "type": "number",
          "description": "Completion 0–1 while busy: a determinate bar plus a mono percentage. Omit for an indeterminate sweep."
        },
        {
          "name": "icon",
          "type": "React.ReactNode",
          "description": "Replaces the kind icon (lucide)."
        },
        {
          "name": "variant",
          "type": "\"compact\" | \"preview\"",
          "default": "\"compact\"",
          "description": "compact = one-row chip sized to its content, for message text and lists; preview = full-width card with a 144px thumbnail area above the title."
        },
        {
          "name": "preview",
          "type": "React.ReactNode",
          "description": "Thumbnail content for variant=\"preview\": a text excerpt, an <img className=\"size-full object-cover\">, a headerless CodeBlock. It fades out at the bottom and is aria-hidden/inert. Default: a sketch of the kind that shimmers while busy."
        },
        {
          "name": "versions",
          "type": "number",
          "default": "1",
          "description": "Number of versions. Above 1 shows a ‹ v2/3 › switcher (disabled while busy)."
        },
        {
          "name": "version",
          "type": "number",
          "description": "Shown version, 1-based (controlled)."
        },
        {
          "name": "defaultVersion",
          "type": "number",
          "description": "Initial version when uncontrolled. Default: the latest (`versions`)."
        },
        {
          "name": "onVersionChange",
          "type": "(version: number) => void",
          "description": "Called with the new 1-based version from the switcher."
        },
        {
          "name": "onOpen",
          "type": "() => void",
          "description": "Open the artifact, e.g. into a side panel. The title becomes a button stretched over the whole card."
        },
        {
          "name": "href",
          "type": "string",
          "description": "Open by navigating instead: the title becomes a link stretched over the card."
        },
        {
          "name": "selected",
          "type": "boolean",
          "default": "false",
          "description": "This artifact is the one currently open (e.g. in the side panel): a stronger ring and aria-current on the open control."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "description": "Shows a Retry icon button when status=\"error\"."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra controls at the end of the row, e.g. Copy / Download IconButtons with size=\"sm\". They sit above the stretched open target."
        }
      ],
      "parts": [
        {
          "name": "ArtifactCard",
          "description": "Root. `data-kind`, `data-status`, `data-variant`, `data-selected`; `aria-busy` while generating/updating.",
          "dataSlot": "artifact-card"
        },
        {
          "name": "Preview",
          "description": "Thumbnail area (variant=\"preview\" only), aria-hidden and inert.",
          "dataSlot": "artifact-card-preview"
        },
        {
          "name": "Header",
          "description": "Row with icon, title, meta, versions and actions.",
          "dataSlot": "artifact-card-header"
        },
        {
          "name": "Icon",
          "description": "36px kind tile; jade while busy, danger on error.",
          "dataSlot": "artifact-card-icon"
        },
        {
          "name": "Open",
          "description": "The title as a button (onOpen) or link (href) whose ::after covers the card. Without either, the title renders as `artifact-card-title`.",
          "dataSlot": "artifact-card-open"
        },
        {
          "name": "Meta",
          "description": "Second line: meta, shimmering statusText + percentage, or the error.",
          "dataSlot": "artifact-card-meta"
        },
        {
          "name": "Versions",
          "description": "‹ v2/3 › switcher, read as “Version 2 of 3”.",
          "dataSlot": "artifact-card-versions"
        },
        {
          "name": "Actions",
          "description": "Retry (on error) and your `actions`.",
          "dataSlot": "artifact-card-actions"
        },
        {
          "name": "Progress",
          "description": "2px role=progressbar at the bottom edge (of the preview, or of the compact card) while busy.",
          "dataSlot": "artifact-card-progress"
        }
      ],
      "a11y": {
        "notes": [
          "The open control is a real button or link named “<title>, <kind>[, generating | , updating | , failed]”; its ::after stretches over the card so the whole card is clickable while Versions and Actions stay separate tab stops.",
          "`selected` sets aria-current on the open control.",
          "The preview thumbnail is decorative (aria-hidden + inert): put anything a screen-reader user needs into title/meta.",
          "A polite status region announces lifecycle changes (“Generating Pricing page”, “Pricing page ready”, “… failed”); statusText and progress ticks are not announced.",
          "While busy the root has aria-busy and the bar is a named role=progressbar (with aria-valuenow when `progress` is given)."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Open control, then the version arrows, Retry and your actions"
          },
          {
            "keys": "Enter / Space",
            "action": "Open the artifact (Enter follows the link for href)"
          }
        ],
        "reducedMotion": "The shimmer and the indeterminate sweep stop (a still tinted track remains); the hover zoom of the preview, the press scale and the chevron nudge are removed. Colour and opacity changes stay."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "icon-button",
          "shimmer-text"
        ]
      },
      "related": [
        "message",
        "code-block",
        "attachment",
        "tool-call",
        "streaming-text"
      ],
      "tags": [
        "artifact",
        "canvas",
        "document",
        "file",
        "output",
        "generation",
        "versions",
        "preview"
      ],
      "examples": [
        {
          "name": "ArtifactCardMinimal",
          "title": "Compact card",
          "description": "Title, meta and an open action that toggles `selected`.",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArtifactCard } from \"@kura-ui/react/artifact-card\";\n\nexport function ArtifactCardMinimal() {\n  const [open, setOpen] = React.useState(false);\n  return (\n    <ArtifactCard\n      title=\"Q3 launch plan\"\n      kind=\"document\"\n      meta=\"Markdown · 1,240 words\"\n      selected={open}\n      onOpen={() => setOpen((o) => !o)}\n      className=\"w-80\"\n    />\n  );\n}\n"
        },
        {
          "name": "ArtifactCardFull",
          "title": "Written live, then edited",
          "description": "Preview variant generating with progress and statusText, then ready with versions, Copy/Download actions and an edit that creates v2.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Copy, Download, RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { ArtifactCard } from \"@kura-ui/react/artifact-card\";\n\nconst PLAN = [\n  \"Pricing page rewrite\",\n  \"Lead with the outcome, not the plan names. Most visitors arrive from a comparison search and decide in the first screen.\",\n  \"Collapse the four tiers into three. Team and Business overlap on every feature that matters to a buyer under fifty seats.\",\n  \"Move the FAQ above the fold on mobile and answer the two questions sales hears most: data residency and seat changes.\",\n];\n\nconst STEPS = [\"Outlining…\", \"Writing the summary…\", \"Writing section 2 of 3…\", \"Polishing…\"];\n\nexport function ArtifactCardFull() {\n  const [run, setRun] = React.useState(0);\n  const [progress, setProgress] = React.useState(0);\n  const [version, setVersion] = React.useState(1);\n  const [open, setOpen] = React.useState(false);\n  const busy = progress < 1;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` restarts the simulation\n  React.useEffect(() => {\n    setProgress(0);\n    const id = setInterval(() => {\n      setProgress((p) => {\n        const next = Math.min(1, p + 0.035 + Math.random() * 0.03);\n        if (next >= 1) clearInterval(id);\n        return next;\n      });\n    }, 140);\n    return () => clearInterval(id);\n  }, [run]);\n\n  const visible = busy ? Math.max(1, Math.ceil(progress * PLAN.length)) : PLAN.length;\n  const versions = run + 1;\n\n  return (\n    <div className=\"flex w-full max-w-sm flex-col gap-3\">\n      <ArtifactCard\n        variant=\"preview\"\n        kind=\"document\"\n        title=\"Pricing page rewrite\"\n        status={busy ? (run === 0 ? \"generating\" : \"updating\") : \"ready\"}\n        statusText={STEPS[Math.min(STEPS.length - 1, Math.floor(progress * STEPS.length))]}\n        progress={progress}\n        meta={`Markdown · ${version === versions ? \"412\" : \"386\"} words · edited just now`}\n        versions={busy ? Math.max(1, versions - 1) : versions}\n        version={busy ? Math.max(1, versions - 1) : version}\n        onVersionChange={setVersion}\n        selected={open}\n        onOpen={() => setOpen((o) => !o)}\n        actions={\n          <>\n            <IconButton size=\"sm\" label=\"Copy\" icon={<Copy />} disabled={busy} />\n            <IconButton size=\"sm\" label=\"Download\" icon={<Download />} disabled={busy} />\n          </>\n        }\n        preview={\n          <div className=\"flex flex-col gap-2 p-5 text-[11px] text-kura-fg-muted leading-relaxed\">\n            {PLAN.slice(0, visible).map((line, i) =>\n              i === 0 ? (\n                <p key={line} className=\"font-semibold text-[13px] text-kura-fg tracking-tight\">\n                  {line}\n                </p>\n              ) : (\n                <p key={line} className=\"animate-kura-fade-in\">\n                  {line}\n                </p>\n              ),\n            )}\n          </div>\n        }\n      />\n      <div className=\"flex items-center justify-between text-kura-fg-subtle text-xs\">\n        <span>{open ? \"Open in the side panel\" : \"Click the card to open it\"}</span>\n        <Button\n          size=\"xs\"\n          variant=\"ghost\"\n          disabled={busy}\n          onClick={() => {\n            setRun((r) => r + 1);\n            setVersion(run + 2);\n          }}\n        >\n          <RotateCcw />\n          Ask for an edit\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ArtifactCardStates",
          "title": "Every status",
          "description": "Generating (indeterminate), updating with progress and versions, ready, failed with Retry.",
          "kind": "state",
          "code": "\"use client\";\n\nimport { ArtifactCard } from \"@kura-ui/react/artifact-card\";\n\nexport function ArtifactCardStates() {\n  return (\n    <div className=\"flex w-full max-w-sm flex-col gap-2.5\">\n      <ArtifactCard\n        title=\"Onboarding email sequence\"\n        status=\"generating\"\n        statusText=\"Drafting email 2 of 4…\"\n        onOpen={() => {}}\n        className=\"w-full\"\n      />\n      <ArtifactCard\n        kind=\"code\"\n        title=\"use-session.ts\"\n        status=\"updating\"\n        progress={0.64}\n        statusText=\"Applying 3 edits…\"\n        versions={2}\n        onOpen={() => {}}\n        className=\"w-full\"\n      />\n      <ArtifactCard\n        kind=\"data\"\n        title=\"Churn by cohort\"\n        meta=\"CSV · 12 columns · 4,218 rows\"\n        onOpen={() => {}}\n        className=\"w-full\"\n      />\n      <ArtifactCard\n        kind=\"web\"\n        title=\"Waitlist landing page\"\n        status=\"error\"\n        error=\"Stopped: the response hit the output limit\"\n        onRetry={() => {}}\n        onOpen={() => {}}\n        className=\"w-full\"\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ArtifactCardKinds",
          "title": "Every kind",
          "description": "Document, code, web page, image, spreadsheet and diagram.",
          "kind": "variant",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArtifactCard } from \"@kura-ui/react/artifact-card\";\n\nconst KINDS = [\n  { kind: \"document\", title: \"Board update — October\", meta: \"Markdown · 860 words\" },\n  { kind: \"code\", title: \"rate-limiter.ts\", meta: \"TypeScript · 94 lines\" },\n  { kind: \"web\", title: \"Pricing page\", meta: \"React · interactive\" },\n  { kind: \"image\", title: \"Hero illustration\", meta: \"PNG · 1600 × 900\" },\n  { kind: \"data\", title: \"Q3 pipeline\", meta: \"CSV · 1,204 rows\" },\n  { kind: \"diagram\", title: \"Auth flow\", meta: \"Mermaid · 9 nodes\" },\n] as const;\n\nexport function ArtifactCardKinds() {\n  return (\n    <div className=\"grid w-full max-w-xl grid-cols-1 gap-2.5 sm:grid-cols-2\">\n      {KINDS.map((a) => (\n        <ArtifactCard key={a.kind} {...a} onOpen={() => {}} className=\"w-full\" />\n      ))}\n    </div>\n  );\n}\n"
        },
        {
          "name": "ArtifactCardComposition",
          "title": "In a message, opening a side panel",
          "description": "An assistant Message with a code artifact whose preview is a headerless CodeBlock; the card toggles a side panel with the full file.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { Message } from \"@kura-ui/react/message\";\nimport { ArtifactCard } from \"@kura-ui/react/artifact-card\";\n\nconst CODE = `import { Ratelimit } from \"@upstash/ratelimit\";\nimport { kv } from \"@vercel/kv\";\n\n// 20 requests per 10 seconds, per user, sliding window.\nexport const limiter = new Ratelimit({\n  redis: kv,\n  limiter: Ratelimit.slidingWindow(20, \"10 s\"),\n  prefix: \"chat\",\n});\n\nexport async function guard(userId: string) {\n  const { success, reset } = await limiter.limit(userId);\n  if (!success) {\n    const retry = Math.ceil((reset - Date.now()) / 1000);\n    throw new Response(\"Slow down\", {\n      status: 429,\n      headers: { \"Retry-After\": String(retry) },\n    });\n  }\n}`;\n\nexport function ArtifactCardComposition() {\n  const [open, setOpen] = React.useState(true);\n  return (\n    <div className=\"flex w-full max-w-3xl items-start gap-4\">\n      <div className=\"flex min-w-0 flex-1 flex-col gap-4\">\n        <Message from=\"user\">Rate limit the chat route to 20 requests per 10 seconds.</Message>\n        <Message from=\"assistant\" name=\"Kura\">\n          <p>\n            Here's a sliding-window limiter keyed by user. Call <code>guard()</code> at the top of\n            the route; it throws a 429 with <code>Retry-After</code> when someone is over.\n          </p>\n          <ArtifactCard\n            variant=\"preview\"\n            kind=\"code\"\n            title=\"rate-limiter.ts\"\n            meta=\"TypeScript · 21 lines\"\n            selected={open}\n            onOpen={() => setOpen((o) => !o)}\n            className=\"max-w-xs\"\n            preview={\n              <CodeBlock\n                code={CODE}\n                language=\"ts\"\n                header={false}\n                lineNumbers={false}\n                copyable={false}\n                className=\"rounded-none bg-transparent text-[11px] shadow-none\"\n              />\n            }\n          />\n        </Message>\n      </div>\n      {open && (\n        <section\n          aria-label=\"rate-limiter.ts\"\n          className=\"hidden w-[22rem] shrink-0 animate-kura-fade-in flex-col overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-md md:flex\"\n        >\n          <header className=\"flex h-11 items-center gap-2 pr-1.5 pl-3.5 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\">\n            <span className=\"min-w-0 flex-1 truncate font-medium text-sm\">rate-limiter.ts</span>\n            <IconButton size=\"sm\" label=\"Close panel\" icon={<X />} onClick={() => setOpen(false)} />\n          </header>\n          <CodeBlock\n            code={CODE}\n            language=\"ts\"\n            header={false}\n            maxHeight={320}\n            className=\"rounded-none text-xs shadow-none\"\n          />\n        </section>\n      )}\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Keep the same card mounted from generating → ready so the transition (and the single announcement) is smooth; switch `status`, don't swap components.",
        "Drive `selected` from the id of the artifact open in your panel, so the card the user is looking at is marked.",
        "Use variant=\"compact\" inside message text and lists (it sizes to content — give it a width like w-full or max-w-sm when stacking); use \"preview\" when there is room for a thumbnail.",
        "For images pass preview={<img src={url} alt=\"\" className=\"size-full object-cover\" />} — the thumbnail is decorative, the title carries the name.",
        "Pass `progress` only when you really know it (e.g. sections written / planned); otherwise leave it out for the indeterminate sweep.",
        "Map Anthropic/OpenAI canvas or artifact tool calls (create → generating, update/rewrite → updating with versions + 1) onto one card per artifact id."
      ],
      "title": "Artifact Card",
      "importPath": "@kura-ui/react/artifact-card",
      "exports": [
        "ArtifactCard",
        "ArtifactCardProps",
        "ArtifactKind",
        "ArtifactStatus",
        "artifactKindLabel"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/artifact-card",
      "markdownUrl": "https://kuro.abstergo.fyi/components/artifact-card.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/artifact-card.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/artifact-card.json",
        "cli": "npx @kura-ui/cli add artifact-card",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "icon-button",
        "shimmer-text",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "title": {
            "description": "The artifact's name, e.g. \"Q3 launch plan\" or \"pricing-table.tsx\". Truncates.",
            "x-ts-type": "string",
            "type": "string"
          },
          "kind": {
            "description": "What the agent made. Picks the icon, the spoken label (Document, Code, Web page, Image, Spreadsheet, Diagram, File) and the preview placeholder.",
            "x-ts-type": "\"document\" | \"code\" | \"web\" | \"image\" | \"data\" | \"diagram\" | \"file\"",
            "type": "string",
            "enum": [
              "code",
              "data",
              "diagram",
              "document",
              "file",
              "image",
              "web"
            ],
            "default": "document"
          },
          "status": {
            "description": "Lifecycle. generating = first write and updating = an edit (both: jade icon tile, shimmering statusText, progress bar, aria-busy); error = danger tile and message.",
            "x-ts-type": "\"generating\" | \"updating\" | \"ready\" | \"error\"",
            "type": "string",
            "enum": [
              "error",
              "generating",
              "ready",
              "updating"
            ],
            "default": "ready"
          },
          "meta": {
            "description": "Secondary line when ready, e.g. \"Markdown · 1,240 words\" or \"TypeScript · 94 lines\". Default: the kind label.",
            "x-ts-type": "React.ReactNode"
          },
          "statusText": {
            "description": "Live line while busy, e.g. \"Writing section 3 of 5…\". Default \"Generating…\" / \"Updating…\". Not announced on every change.",
            "x-ts-type": "React.ReactNode"
          },
          "error": {
            "description": "Message for status=\"error\". Default \"Couldn't finish\".",
            "x-ts-type": "React.ReactNode"
          },
          "progress": {
            "description": "Completion 0–1 while busy: a determinate bar plus a mono percentage. Omit for an indeterminate sweep.",
            "x-ts-type": "number",
            "type": "number"
          },
          "icon": {
            "description": "Replaces the kind icon (lucide).",
            "x-ts-type": "React.ReactNode"
          },
          "variant": {
            "description": "compact = one-row chip sized to its content, for message text and lists; preview = full-width card with a 144px thumbnail area above the title.",
            "x-ts-type": "\"compact\" | \"preview\"",
            "type": "string",
            "enum": [
              "compact",
              "preview"
            ],
            "default": "compact"
          },
          "preview": {
            "description": "Thumbnail content for variant=\"preview\": a text excerpt, an <img className=\"size-full object-cover\">, a headerless CodeBlock. It fades out at the bottom and is aria-hidden/inert. Default: a sketch of the kind that shimmers while busy.",
            "x-ts-type": "React.ReactNode"
          },
          "versions": {
            "description": "Number of versions. Above 1 shows a ‹ v2/3 › switcher (disabled while busy).",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "version": {
            "description": "Shown version, 1-based (controlled).",
            "x-ts-type": "number",
            "type": "number"
          },
          "defaultVersion": {
            "description": "Initial version when uncontrolled. Default: the latest (`versions`).",
            "x-ts-type": "number",
            "type": "number"
          },
          "onVersionChange": {
            "description": "Called with the new 1-based version from the switcher.",
            "x-ts-type": "(version: number) => void"
          },
          "onOpen": {
            "description": "Open the artifact, e.g. into a side panel. The title becomes a button stretched over the whole card.",
            "x-ts-type": "() => void"
          },
          "href": {
            "description": "Open by navigating instead: the title becomes a link stretched over the card.",
            "x-ts-type": "string",
            "type": "string"
          },
          "selected": {
            "description": "This artifact is the one currently open (e.g. in the side panel): a stronger ring and aria-current on the open control.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onRetry": {
            "description": "Shows a Retry icon button when status=\"error\".",
            "x-ts-type": "() => void"
          },
          "actions": {
            "description": "Extra controls at the end of the row, e.g. Copy / Download IconButtons with size=\"sm\". They sit above the stretched open target.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "title"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/artifact-card/artifact-card.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport {\n  AppWindow,\n  ChevronLeft,\n  ChevronRight,\n  File,\n  FileCode2,\n  FileText,\n  Image,\n  RotateCw,\n  Table2,\n  Workflow,\n} from \"lucide-react\";\nimport type * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type ArtifactKind = \"document\" | \"code\" | \"web\" | \"image\" | \"data\" | \"diagram\" | \"file\";\nexport type ArtifactStatus = \"generating\" | \"updating\" | \"ready\" | \"error\";\n\nexport interface ArtifactCardProps extends Omit<React.ComponentProps<\"div\">, \"title\"> {\n  /** The artifact's name, e.g. \"Q3 launch plan\" or \"pricing-table.tsx\". */\n  title: string;\n  /** What the agent made. Picks the icon, the spoken label and the preview placeholder. Default \"document\". */\n  kind?: ArtifactKind;\n  /** Lifecycle. generating = first write; updating = an edit to an existing artifact. Default \"ready\". */\n  status?: ArtifactStatus;\n  /** Secondary line when ready, e.g. \"Markdown · 1,240 words\". Default: the kind's label. */\n  meta?: React.ReactNode;\n  /** Live line while generating/updating, e.g. \"Writing section 3 of 5…\". Default \"Generating…\" / \"Updating…\". */\n  statusText?: React.ReactNode;\n  /** Message shown when status=\"error\". Default \"Couldn't finish\". */\n  error?: React.ReactNode;\n  /** Completion 0–1 while busy. Omit for an indeterminate sweep. */\n  progress?: number;\n  /** Replaces the kind icon. */\n  icon?: React.ReactNode;\n  /** `compact` = a one-row chip for message text (default); `preview` = a card with a thumbnail area. */\n  variant?: \"compact\" | \"preview\";\n  /** Thumbnail content for variant=\"preview\" (a text excerpt, an image, a CodeBlock…). Decorative: it is hidden from screen readers. */\n  preview?: React.ReactNode;\n  /** Number of versions. Shows a ‹ v2/3 › switcher when greater than 1. */\n  versions?: number;\n  /** Shown version, 1-based (controlled). */\n  version?: number;\n  /** Initial version when uncontrolled. Default: the latest (`versions`). */\n  defaultVersion?: number;\n  /** Called with the new 1-based version from the switcher. */\n  onVersionChange?: (version: number) => void;\n  /** Open the artifact (e.g. in a side panel). Makes the whole card a button. */\n  onOpen?: () => void;\n  /** Open the artifact by navigating instead. Makes the whole card a link. */\n  href?: string;\n  /** The artifact is the one currently open, e.g. in the side panel. */\n  selected?: boolean;\n  /** Shows a Retry button when status=\"error\". */\n  onRetry?: () => void;\n  /** Extra buttons at the end of the row, e.g. Copy / Download IconButtons (size \"sm\"). */\n  actions?: React.ReactNode;\n}\n\nconst KINDS: Record<ArtifactKind, { label: string; icon: React.ReactNode }> = {\n  document: { label: \"Document\", icon: <FileText /> },\n  code: { label: \"Code\", icon: <FileCode2 /> },\n  web: { label: \"Web page\", icon: <AppWindow /> },\n  image: { label: \"Image\", icon: <Image /> },\n  data: { label: \"Spreadsheet\", icon: <Table2 /> },\n  diagram: { label: \"Diagram\", icon: <Workflow /> },\n  file: { label: \"File\", icon: <File /> },\n};\n\n/** Spoken label for a kind, e.g. \"Web page\". */\nexport function artifactKindLabel(kind: ArtifactKind): string {\n  return KINDS[kind].label;\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\n/**\n * A card for something an agent made — a document, a file of code, a web page, an image, a sheet\n * or a diagram. Shows what it is, whether it is still being written, which version you are on, and\n * opens it (usually into a side panel) on click.\n */\nexport function ArtifactCard({\n  title,\n  kind = \"document\",\n  status = \"ready\",\n  meta,\n  statusText,\n  error,\n  progress,\n  icon,\n  variant = \"compact\",\n  preview,\n  versions = 1,\n  version: versionProp,\n  defaultVersion,\n  onVersionChange,\n  onOpen,\n  href,\n  selected = false,\n  onRetry,\n  actions,\n  className,\n  ...props\n}: ArtifactCardProps) {\n  const [version, setVersion] = useControllableState({\n    value: versionProp,\n    defaultValue: defaultVersion ?? versions,\n    onChange: onVersionChange,\n  });\n  const k = KINDS[kind];\n  const busy = status === \"generating\" || status === \"updating\";\n  const compact = variant === \"compact\";\n  const openable = onOpen != null || href != null;\n  const pct = progress == null ? null : Math.round(clamp01(progress) * 100);\n  const shownVersion = Math.min(Math.max(1, version), Math.max(1, versions));\n\n  const announcement =\n    status === \"generating\"\n      ? `Generating ${title}`\n      : status === \"updating\"\n        ? `Updating ${title}`\n        : status === \"error\"\n          ? `${title} failed`\n          : `${title} ready`;\n\n  const titleClass =\n    \"min-w-0 truncate text-left font-medium text-kura-fg text-sm leading-5 outline-none\";\n  // The open control stretches over the whole card (after: overlay), so the card is one target\n  // while the version switcher and actions stay separately clickable above it.\n  const stretch =\n    \"after:absolute after:inset-0 after:z-0 after:rounded-[inherit] after:content-['']\";\n  const spoken = (\n    <span className=\"sr-only\">\n      , {k.label}\n      {busy ? `, ${status === \"generating\" ? \"generating\" : \"updating\"}` : \"\"}\n      {status === \"error\" ? \", failed\" : \"\"}\n    </span>\n  );\n\n  let titleNode: React.ReactNode;\n  if (href != null) {\n    titleNode = (\n      <a\n        href={href}\n        data-slot=\"artifact-card-open\"\n        aria-current={selected ? \"true\" : undefined}\n        className={cn(titleClass, stretch)}\n      >\n        {title}\n        {spoken}\n      </a>\n    );\n  } else if (onOpen != null) {\n    titleNode = (\n      <button\n        type=\"button\"\n        data-slot=\"artifact-card-open\"\n        aria-current={selected ? \"true\" : undefined}\n        onClick={onOpen}\n        className={cn(titleClass, stretch, \"cursor-pointer\")}\n      >\n        {title}\n        {spoken}\n      </button>\n    );\n  } else {\n    titleNode = (\n      <span data-slot=\"artifact-card-title\" className={titleClass}>\n        {title}\n        {spoken}\n      </span>\n    );\n  }\n\n  const progressBar = busy && (\n    <div\n      data-slot=\"artifact-card-progress\"\n      role=\"progressbar\"\n      aria-label={`${status === \"generating\" ? \"Generating\" : \"Updating\"} ${title}`}\n      aria-valuemin={pct == null ? undefined : 0}\n      aria-valuemax={pct == null ? undefined : 100}\n      aria-valuenow={pct ?? undefined}\n      className=\"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-0.5 overflow-hidden bg-kura-accent-subtle\"\n    >\n      {pct == null ? (\n        <div\n          className=\"h-full animate-kura-shimmer [background-size:250%_100%] motion-reduce:animate-none motion-reduce:bg-none\"\n          style={\n            {\n              \"--kura-shimmer-duration\": \"1.4s\",\n              backgroundImage:\n                \"linear-gradient(90deg, transparent 30%, var(--kura-accent) 50%, transparent 70%)\",\n            } as React.CSSProperties\n          }\n        />\n      ) : (\n        <div\n          className=\"h-full origin-left bg-kura-accent transition-transform duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\"\n          style={{ transform: `scaleX(${pct / 100})` }}\n        />\n      )}\n    </div>\n  );\n\n  return (\n    <div\n      data-slot=\"artifact-card\"\n      data-kind={kind}\n      data-status={status}\n      data-variant={variant}\n      data-selected={selected || undefined}\n      aria-busy={busy || undefined}\n      className={cn(\n        \"group/artifact relative isolate flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans shadow-kura-sm\",\n        \"transition-[box-shadow,transform] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-none\",\n        compact ? \"w-fit max-w-full\" : \"w-full\",\n        openable &&\n          \"hover:shadow-kura-md has-[[data-slot=artifact-card-open]:active]:scale-[0.99] motion-reduce:has-[[data-slot=artifact-card-open]:active]:scale-100\",\n        selected &&\n          \"shadow-[0_0_0_1px_var(--kura-border-strong),0_0_0_4px_var(--kura-hover)] hover:shadow-[0_0_0_1px_var(--kura-border-strong),0_0_0_4px_var(--kura-hover)]\",\n        \"has-[[data-slot=artifact-card-open]:focus-visible]:shadow-kura-focus\",\n        className,\n      )}\n      {...props}\n    >\n      {!compact && (\n        <div className=\"relative\">\n          <div\n            data-slot=\"artifact-card-preview\"\n            aria-hidden\n            inert\n            className=\"relative h-36 overflow-hidden bg-kura-surface-sunken shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n          >\n            <div\n              className={cn(\n                \"absolute inset-0 origin-top [mask-image:linear-gradient(to_bottom,black_62%,transparent)]\",\n                \"transition-[transform,opacity] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-opacity\",\n                openable &&\n                  \"group-hover/artifact:scale-[1.015] motion-reduce:group-hover/artifact:scale-100\",\n                status === \"updating\" && \"opacity-60\",\n              )}\n            >\n              {preview ?? <PreviewPlaceholder kind={kind} busy={busy} />}\n            </div>\n          </div>\n          {progressBar}\n        </div>\n      )}\n\n      <div\n        data-slot=\"artifact-card-header\"\n        className={cn(\"flex min-w-0 items-center gap-3\", compact ? \"p-2 pr-2.5\" : \"px-3 py-2.5\")}\n      >\n        <span\n          aria-hidden\n          data-slot=\"artifact-card-icon\"\n          className={cn(\n            \"relative grid size-9 shrink-0 place-items-center rounded-kura-md bg-kura-gray-3 text-kura-fg-muted shadow-[inset_0_0_0_1px_var(--kura-border-subtle)] [&_svg]:size-4\",\n            \"transition-colors duration-(--kura-duration-base) ease-kura-out\",\n            busy && \"bg-kura-accent-subtle text-kura-accent-text\",\n            status === \"error\" && \"bg-kura-danger-subtle text-kura-danger-text\",\n          )}\n        >\n          {icon ?? k.icon}\n        </span>\n\n        <div className=\"flex min-w-0 flex-1 flex-col\">\n          {titleNode}\n          <span\n            data-slot=\"artifact-card-meta\"\n            className=\"flex min-w-0 items-center gap-1.5 text-kura-fg-muted text-xs leading-5\"\n          >\n            {busy ? (\n              <>\n                <ShimmerText className=\"truncate\">\n                  {statusText ?? (status === \"generating\" ? \"Generating…\" : \"Updating…\")}\n                </ShimmerText>\n                {pct != null && (\n                  <span className=\"shrink-0 font-kura-mono text-[11px] text-kura-fg-subtle tabular-nums\">\n                    {pct}%\n                  </span>\n                )}\n              </>\n            ) : status === \"error\" ? (\n              <span className=\"truncate text-kura-danger-text\">{error ?? \"Couldn't finish\"}</span>\n            ) : (\n              <span className=\"truncate\">{meta ?? k.label}</span>\n            )}\n          </span>\n        </div>\n\n        {versions > 1 && (\n          <div\n            data-slot=\"artifact-card-versions\"\n            className=\"relative z-10 flex shrink-0 items-center text-kura-fg-subtle\"\n          >\n            <IconButton\n              size=\"xs\"\n              label=\"Previous version\"\n              icon={<ChevronLeft />}\n              disabled={shownVersion <= 1 || busy}\n              onClick={() => setVersion(shownVersion - 1)}\n            />\n            <span className=\"min-w-[4.5ch] text-center font-kura-mono text-[11px] tabular-nums\">\n              <span className=\"sr-only\">Version </span>v{shownVersion}\n              <span className=\"text-kura-fg-subtle/70\">\n                <span aria-hidden>/</span>\n                <span className=\"sr-only\"> of </span>\n                {versions}\n              </span>\n            </span>\n            <IconButton\n              size=\"xs\"\n              label=\"Next version\"\n              icon={<ChevronRight />}\n              disabled={shownVersion >= versions || busy}\n              onClick={() => setVersion(shownVersion + 1)}\n            />\n          </div>\n        )}\n\n        {((status === \"error\" && onRetry) || actions != null) && (\n          <div\n            data-slot=\"artifact-card-actions\"\n            className=\"relative z-10 flex shrink-0 items-center gap-0.5\"\n          >\n            {status === \"error\" && onRetry && (\n              <IconButton size=\"sm\" label=\"Retry\" icon={<RotateCw />} onClick={onRetry} />\n            )}\n            {actions}\n          </div>\n        )}\n\n        {openable && compact && actions == null && versions <= 1 && status !== \"error\" && (\n          <ChevronRight\n            aria-hidden\n            className=\"size-4 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-fast) ease-kura-out group-hover/artifact:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover/artifact:translate-x-0\"\n          />\n        )}\n      </div>\n\n      {compact && progressBar}\n\n      <span className=\"sr-only\" role=\"status\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nconst LINES: Record<ArtifactKind, number[]> = {\n  document: [46, 0, 94, 88, 91, 62, 0, 90, 84],\n  code: [38, 62, 54, 0, 70, 48, 66, 40, 0, 30],\n  web: [],\n  image: [],\n  data: [],\n  diagram: [],\n  file: [72, 88, 64, 80, 56],\n};\n\n/** Drawn when no `preview` is given: a quiet sketch of the kind (and a shimmer while it is written). */\nfunction PreviewPlaceholder({ kind, busy }: { kind: ArtifactKind; busy: boolean }) {\n  const bar = cn(\n    \"h-1.5 rounded-full bg-kura-gray-5\",\n    busy &&\n      \"animate-kura-shimmer [background-size:250%_100%] motion-reduce:animate-none motion-reduce:bg-none\",\n  );\n  const shimmer: React.CSSProperties | undefined = busy\n    ? {\n        backgroundImage:\n          \"linear-gradient(90deg, var(--kura-gray-5) 35%, var(--kura-gray-7) 50%, var(--kura-gray-5) 65%)\",\n      }\n    : undefined;\n\n  if (kind === \"web\") {\n    return (\n      <div className=\"flex h-full flex-col gap-3 p-4\">\n        <div className=\"flex items-center gap-1\">\n          {[0, 1, 2].map((i) => (\n            <span key={i} className=\"size-1.5 rounded-full bg-kura-gray-6\" />\n          ))}\n          <span className=\"ml-2 h-1.5 w-24 rounded-full bg-kura-gray-5\" />\n        </div>\n        <div className={cn(bar, \"h-3 w-1/2\")} style={shimmer} />\n        <div className=\"grid flex-1 grid-cols-3 gap-2\">\n          {[0, 1, 2].map((i) => (\n            <div key={i} className=\"rounded-kura-sm bg-kura-gray-4\" />\n          ))}\n        </div>\n      </div>\n    );\n  }\n  if (kind === \"data\") {\n    return (\n      <div className=\"grid grid-cols-4 gap-px p-4 opacity-80\">\n        {Array.from({ length: 20 }, (_, i) => (\n          <span key={i} className={cn(\"h-4 bg-kura-gray-4\", i < 4 && \"bg-kura-gray-6\")} />\n        ))}\n      </div>\n    );\n  }\n  if (kind === \"image\" || kind === \"diagram\") {\n    return (\n      <div className=\"grid h-full place-items-center text-kura-fg-subtle/60 [&_svg]:size-10 [&_svg]:stroke-[1.25]\">\n        {KINDS[kind].icon}\n      </div>\n    );\n  }\n  return (\n    <div className=\"flex flex-col gap-2 p-5\">\n      {LINES[kind].map((w, i) =>\n        w === 0 ? (\n          <span key={i} className=\"h-1\" />\n        ) : (\n          <span\n            key={i}\n            className={cn(bar, i === 0 && kind === \"document\" && \"h-2.5 bg-kura-gray-6\")}\n            style={{\n              ...shimmer,\n              width: `${w}%`,\n              marginLeft: kind === \"code\" && i % 3 === 1 ? \"1rem\" : undefined,\n            }}\n          />\n        ),\n      )}\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/artifact-card/index.ts",
          "type": "registry:ui",
          "content": "export {\n  ArtifactCard,\n  type ArtifactCardProps,\n  type ArtifactKind,\n  type ArtifactStatus,\n  artifactKindLabel,\n} from \"./artifact-card\";\n"
        }
      ]
    },
    {
      "slug": "attachment",
      "name": "Attachment",
      "category": "input",
      "status": "beta",
      "description": "File and image attachment chips with thumbnails, upload progress rings, error/retry, remove, a “+N” overflow stack and a dropzone.",
      "whenToUse": [
        "Show files the user attached to a prompt before sending (inside or above a composer).",
        "Display upload progress, failures and a retry action for files sent to a model.",
        "Render the files that belong to a sent message or an agent artifact list.",
        "A standalone drop area for documents to index, e.g. “Add files to this project”."
      ],
      "whenNotToUse": [
        "A complete prompt input with paste and drag-drop built in — use composer (it renders attachment for you).",
        "Rich previews of generated documents or code — use artifact-card or code-block.",
        "Audio recordings with playback — use audio-waveform with `peaks`."
      ],
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "description": "File name shown on the chip; long names truncate with an ellipsis."
        },
        {
          "name": "size",
          "type": "number",
          "description": "Size in bytes, rendered as “2.4 MB”."
        },
        {
          "name": "type",
          "type": "string",
          "description": "MIME type. Chooses the file-type icon (image, pdf, code, text, sheet, audio, video, archive) and whether a preview renders as a thumbnail."
        },
        {
          "name": "previewUrl",
          "type": "string",
          "description": "Image URL (object URL or remote). With an image type the attachment renders as a 44px square thumbnail."
        },
        {
          "name": "status",
          "type": "\"ready\" | \"uploading\" | \"error\"",
          "default": "\"ready\"",
          "description": "Upload state. Uploading shows a progress ring; error shows a danger tint."
        },
        {
          "name": "progress",
          "type": "number",
          "description": "Upload progress 0..1 while uploading. Omit for an indeterminate spinning arc."
        },
        {
          "name": "error",
          "type": "string",
          "description": "Error message for status=error; shown as the title and announced via role=alert."
        },
        {
          "name": "variant",
          "type": "\"auto\" | \"chip\" | \"tile\"",
          "default": "\"auto\"",
          "description": "auto = thumbnail tile for images that have a previewUrl, a chip (icon + name + size) otherwise."
        },
        {
          "name": "onRemove",
          "type": "() => void",
          "description": "Render a × button (revealed on hover/focus, always visible on touch)."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "description": "Render a Retry action while status is error."
        }
      ],
      "parts": [
        {
          "name": "Attachment",
          "description": "One file. Exposes data-variant (chip|tile), data-kind and data-status (ready|uploading|error).",
          "dataSlot": "attachment"
        },
        {
          "name": "AttachmentRemove",
          "description": "The × button, labelled “Remove <name>”. Rendered when onRemove is set.",
          "dataSlot": "attachment-remove"
        },
        {
          "name": "AttachmentList",
          "description": "Wrapping <ul> of attachments with pop-in/out and gap-closing layout motion, plus the “+N” stack.",
          "dataSlot": "attachment-list",
          "props": [
            {
              "name": "items",
              "type": "readonly AttachmentItem[]",
              "required": true,
              "description": "Items: { id, name, size?, type?, url?, status?, progress?, error?, file? }."
            },
            {
              "name": "onRemove",
              "type": "(id: string) => void",
              "description": "Remove handler."
            },
            {
              "name": "onRetry",
              "type": "(id: string) => void",
              "description": "Retry handler."
            },
            {
              "name": "max",
              "type": "number",
              "description": "Show at most this many items, then a “+N” stack button that reveals the rest."
            },
            {
              "name": "variant",
              "type": "\"auto\" | \"chip\" | \"tile\"",
              "default": "\"auto\"",
              "description": "Forwarded to every item."
            },
            {
              "name": "label",
              "type": "string",
              "default": "\"Attachments\"",
              "description": "Accessible name of the list."
            }
          ]
        },
        {
          "name": "AttachmentOverflow",
          "description": "The “+N” stack button (a counter with two offset cards behind it).",
          "dataSlot": "attachment-overflow"
        },
        {
          "name": "AttachmentDropzone",
          "description": "Click-to-browse + drop target with a dashed hairline. data-dragging is set only while a drag carries files.",
          "dataSlot": "attachment-dropzone",
          "props": [
            {
              "name": "onFiles",
              "type": "(files: File[], rejected: File[]) => void",
              "required": true,
              "description": "Accepted files, and those rejected by accept/maxSize."
            },
            {
              "name": "accept",
              "type": "string",
              "description": "Like <input accept>, e.g. \"image/*,.pdf\"."
            },
            {
              "name": "multiple",
              "type": "boolean",
              "default": "true",
              "description": "Allow several files."
            },
            {
              "name": "maxSize",
              "type": "number",
              "description": "Reject files larger than this (bytes)."
            },
            {
              "name": "disabled",
              "type": "boolean",
              "description": "Disable browsing and dropping."
            },
            {
              "name": "title",
              "type": "React.ReactNode",
              "default": "\"Drop files or browse\"",
              "description": "Headline."
            },
            {
              "name": "description",
              "type": "React.ReactNode",
              "description": "Hint under the headline."
            }
          ]
        },
        {
          "name": "AttachmentProgressRing",
          "description": "Standalone circular progress (currentColor) used by uploading attachments.",
          "dataSlot": "attachment-progress"
        }
      ],
      "a11y": {
        "notes": [
          "Image tiles render <img alt={name}>; chips render the name as text, so every file has a readable name.",
          "The remove button is a real <button> labelled “Remove <file name>” with a 28px hit area; it is revealed on hover and on keyboard focus within the item.",
          "Upload failures are announced once with role=alert; progress is not announced on every tick.",
          "The list is a <ul aria-label=\"Attachments\">; the overflow button is labelled “Show N more attachments”.",
          "The dropzone is a native <button> that opens the file picker; the drop target never steals focus."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Moves through each attachment's Retry and Remove buttons"
          },
          {
            "keys": "Enter / Space",
            "action": "Activates Remove, Retry, the +N stack or the dropzone"
          }
        ],
        "reducedMotion": "Items fade in and out without scale, blur or layout travel; the indeterminate ring pulses instead of spinning; hover lifts are removed."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "motion"
        ],
        "kura": []
      },
      "related": [
        "composer",
        "artifact-card",
        "audio-waveform"
      ],
      "tags": [
        "file",
        "upload",
        "attachment",
        "image",
        "thumbnail",
        "dropzone",
        "progress",
        "drag-and-drop"
      ],
      "examples": [
        {
          "name": "AttachmentMinimal",
          "title": "A single file",
          "kind": "minimal",
          "code": "import { Attachment } from \"@kura-ui/react/attachment\";\n\nexport function AttachmentMinimal() {\n  return <Attachment name=\"q3-board-report.pdf\" type=\"application/pdf\" size={2_480_000} />;\n}\n"
        },
        {
          "name": "AttachmentFull",
          "title": "Mixed states with overflow",
          "description": "Thumbnails, a live upload, a failed upload with retry, remove buttons and a +N stack.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AttachmentList } from \"@kura-ui/react/attachment\";\nimport { type AttachmentItem } from \"@kura-ui/react/attachment\";\n\nconst initial: AttachmentItem[] = [\n  { id: \"1\", name: \"brief.md\", type: \"text/markdown\", size: 4_210 },\n  { id: \"2\", name: \"harbour.png\", type: \"image/png\", size: 812_000, url: photo(205, 160) },\n  {\n    id: \"3\",\n    name: \"transactions.csv\",\n    type: \"text/csv\",\n    size: 18_400_000,\n    status: \"uploading\",\n    progress: 0.18,\n  },\n  {\n    id: \"4\",\n    name: \"schema.ts\",\n    type: \"text/typescript\",\n    size: 9_800,\n    status: \"error\",\n    error: \"Network connection lost\",\n  },\n  { id: \"5\", name: \"dunes.jpg\", type: \"image/jpeg\", size: 1_200_000, url: photo(30, 25) },\n  { id: \"6\", name: \"interview.m4a\", type: \"audio/mp4\", size: 6_300_000 },\n  { id: \"7\", name: \"notes.zip\", type: \"application/zip\", size: 340_000 },\n];\n\nconst photo = (a: number, b: number) =>\n  `data:image/svg+xml;utf8,${encodeURIComponent(\n    `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><defs><linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"hsl(${a} 55% 78%)\"/><stop offset=\"1\" stop-color=\"hsl(${a + 20} 45% 90%)\"/></linearGradient></defs><rect width=\"64\" height=\"64\" fill=\"url(#g)\"/><circle cx=\"44\" cy=\"20\" r=\"7\" fill=\"hsl(${b} 80% 92%)\"/><path d=\"M0 46 L18 30 L32 42 L44 34 L64 50 V64 H0Z\" fill=\"hsl(${b} 25% 38%)\"/><path d=\"M0 54 L22 44 L40 52 L64 46 V64 H0Z\" fill=\"hsl(${b} 20% 26%)\"/></svg>`,\n  )}`;\n\nexport function AttachmentFull() {\n  const [items, setItems] = React.useState(initial);\n\n  // Simulate an upload ticking forward.\n  React.useEffect(() => {\n    const id = setInterval(() => {\n      setItems((list) =>\n        list.map((item) => {\n          if (item.status !== \"uploading\") return item;\n          const progress = Math.min(1, (item.progress ?? 0) + 0.07 + Math.random() * 0.06);\n          return progress >= 1\n            ? { ...item, status: \"ready\", progress: undefined }\n            : { ...item, progress };\n        }),\n      );\n    }, 450);\n    return () => clearInterval(id);\n  }, []);\n\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-3\">\n      <AttachmentList\n        items={items}\n        max={4}\n        onRemove={(id) => setItems((list) => list.filter((i) => i.id !== id))}\n        onRetry={(id) =>\n          setItems((list) =>\n            list.map((i) =>\n              i.id === id ? { ...i, status: \"uploading\", progress: 0, error: undefined } : i,\n            ),\n          )\n        }\n      />\n      <p className=\"text-kura-fg-subtle text-xs\">\n        Hover a file to remove it · Retry the failed upload · Open the +N stack\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "AttachmentWithDropzone",
          "title": "Dropzone feeding a list",
          "description": "Browse or drop files; accepted ones upload, rejected ones are reported.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AttachmentDropzone, AttachmentList } from \"@kura-ui/react/attachment\";\nimport { type AttachmentItem, createAttachment } from \"@kura-ui/react/attachment\";\n\nexport function AttachmentWithDropzone() {\n  const [items, setItems] = React.useState<AttachmentItem[]>([]);\n  const [rejected, setRejected] = React.useState<string | null>(null);\n\n  React.useEffect(() => {\n    if (!items.some((i) => i.status === \"uploading\")) return;\n    const id = setInterval(() => {\n      setItems((list) =>\n        list.map((i) => {\n          if (i.status !== \"uploading\") return i;\n          const progress = (i.progress ?? 0) + 0.2;\n          return progress >= 1\n            ? { ...i, status: \"ready\", progress: undefined }\n            : { ...i, progress };\n        }),\n      );\n    }, 300);\n    return () => clearInterval(id);\n  }, [items]);\n\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-3\">\n      <AttachmentDropzone\n        accept=\"image/*,.pdf,.csv,.md,.txt\"\n        maxSize={10 * 1024 * 1024}\n        description=\"Images, PDF, CSV or Markdown · up to 10 MB\"\n        onFiles={(files, bad) => {\n          setRejected(bad.length ? `${bad.map((f) => f.name).join(\", \")} not supported` : null);\n          setItems((list) => [\n            ...list,\n            ...files.map((f) => createAttachment(f, { status: \"uploading\", progress: 0 })),\n          ]);\n        }}\n      />\n      {rejected && (\n        <p role=\"alert\" className=\"text-kura-danger-text text-xs\">\n          {rejected}\n        </p>\n      )}\n      {items.length > 0 && (\n        <AttachmentList\n          items={items}\n          onRemove={(id) =>\n            setItems((list) => {\n              const gone = list.find((i) => i.id === id);\n              if (gone?.url?.startsWith(\"blob:\")) URL.revokeObjectURL(gone.url);\n              return list.filter((i) => i.id !== id);\n            })\n          }\n        />\n      )}\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Keep attachments in state as AttachmentItem[] and render <AttachmentList items={…} />; use createAttachment(file) to build items (it creates an object URL for images — revoke it with URL.revokeObjectURL when removing).",
        "Helpers exported alongside: formatBytes, attachmentKind, acceptsFile, filesFromDataTransfer, dragHasFiles.",
        "Progress is 0..1, not a percentage. Omit progress for an indeterminate ring.",
        "Inside a composer, prefer composer's built-in ComposerAttachments, which wires paste, drop and removal."
      ],
      "title": "Attachment",
      "importPath": "@kura-ui/react/attachment",
      "exports": [
        "Attachment",
        "AttachmentDropzone",
        "AttachmentDropzoneProps",
        "AttachmentItem",
        "AttachmentKind",
        "AttachmentList",
        "AttachmentListProps",
        "AttachmentProgressRing",
        "AttachmentProgressRingProps",
        "AttachmentProps",
        "AttachmentStatus",
        "acceptsFile",
        "attachmentExtension",
        "attachmentKind",
        "createAttachment",
        "dragHasFiles",
        "filesFromDataTransfer",
        "formatBytes"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/attachment",
      "markdownUrl": "https://kuro.abstergo.fyi/components/attachment.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/attachment.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/attachment.json",
        "cli": "npx @kura-ui/cli add attachment",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-motion",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "name": {
            "description": "File name shown on the chip; long names truncate with an ellipsis.",
            "x-ts-type": "string",
            "type": "string"
          },
          "size": {
            "description": "Size in bytes, rendered as “2.4 MB”.",
            "x-ts-type": "number",
            "type": "number"
          },
          "type": {
            "description": "MIME type. Chooses the file-type icon (image, pdf, code, text, sheet, audio, video, archive) and whether a preview renders as a thumbnail.",
            "x-ts-type": "string",
            "type": "string"
          },
          "previewUrl": {
            "description": "Image URL (object URL or remote). With an image type the attachment renders as a 44px square thumbnail.",
            "x-ts-type": "string",
            "type": "string"
          },
          "status": {
            "description": "Upload state. Uploading shows a progress ring; error shows a danger tint.",
            "x-ts-type": "\"ready\" | \"uploading\" | \"error\"",
            "type": "string",
            "enum": [
              "error",
              "ready",
              "uploading"
            ],
            "default": "ready"
          },
          "progress": {
            "description": "Upload progress 0..1 while uploading. Omit for an indeterminate spinning arc.",
            "x-ts-type": "number",
            "type": "number"
          },
          "error": {
            "description": "Error message for status=error; shown as the title and announced via role=alert.",
            "x-ts-type": "string",
            "type": "string"
          },
          "variant": {
            "description": "auto = thumbnail tile for images that have a previewUrl, a chip (icon + name + size) otherwise.",
            "x-ts-type": "\"auto\" | \"chip\" | \"tile\"",
            "type": "string",
            "enum": [
              "auto",
              "chip",
              "tile"
            ],
            "default": "auto"
          },
          "onRemove": {
            "description": "Render a × button (revealed on hover/focus, always visible on touch).",
            "x-ts-type": "() => void"
          },
          "onRetry": {
            "description": "Render a Retry action while status is error.",
            "x-ts-type": "() => void"
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/attachment/attachment-utils.ts",
          "type": "registry:ui",
          "content": "/** Upload lifecycle of one attachment. */\nexport type AttachmentStatus = \"ready\" | \"uploading\" | \"error\";\n\n/** Broad file family, used to pick an icon and a label. */\nexport type AttachmentKind =\n  | \"image\"\n  | \"pdf\"\n  | \"code\"\n  | \"text\"\n  | \"sheet\"\n  | \"audio\"\n  | \"video\"\n  | \"archive\"\n  | \"file\";\n\n/** Plain-data description of an attachment, as stored in app or composer state. */\nexport interface AttachmentItem {\n  /** Stable id, unique within the list. */\n  id: string;\n  /** File name shown on the chip, e.g. \"q3-report.pdf\". */\n  name: string;\n  /** Size in bytes. */\n  size?: number;\n  /** MIME type, e.g. \"image/png\". Used for the icon and the thumbnail decision. */\n  type?: string;\n  /** Preview URL (object URL or remote) for images. */\n  url?: string;\n  /** Default \"ready\". */\n  status?: AttachmentStatus;\n  /** Upload progress 0..1 while `status` is \"uploading\". Omit for indeterminate. */\n  progress?: number;\n  /** Error message shown when `status` is \"error\". */\n  error?: string;\n  /** The original File, when the attachment came from a picker, paste or drop. */\n  file?: File;\n}\n\nconst CODE_EXT =\n  /\\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|sql|html?|css|scss|vue|svelte|json|ya?ml|toml|xml)$/i;\n\n/** Classify a file by MIME type, falling back to its extension. */\nexport function attachmentKind(name: string, type = \"\"): AttachmentKind {\n  const t = type.toLowerCase();\n  const n = name.toLowerCase();\n  if (t.startsWith(\"image/\") || /\\.(png|jpe?g|gif|webp|avif|svg|heic)$/.test(n)) return \"image\";\n  if (t === \"application/pdf\" || n.endsWith(\".pdf\")) return \"pdf\";\n  if (t.startsWith(\"audio/\") || /\\.(mp3|wav|m4a|ogg|flac|webm)$/.test(n)) return \"audio\";\n  if (t.startsWith(\"video/\") || /\\.(mp4|mov|mkv|avi)$/.test(n)) return \"video\";\n  if (/zip|tar|gzip|rar|7z/.test(t) || /\\.(zip|tar|gz|tgz|rar|7z)$/.test(n)) return \"archive\";\n  if (/spreadsheet|excel|csv/.test(t) || /\\.(csv|tsv|xlsx?|numbers)$/.test(n)) return \"sheet\";\n  if (CODE_EXT.test(n)) return \"code\";\n  if (t.startsWith(\"text/\") || /\\.(md|mdx|txt|rtf|docx?)$/.test(n)) return \"text\";\n  return \"file\";\n}\n\n/** Short uppercase extension (\"PDF\", \"TSX\") or the kind when there is none. */\nexport function attachmentExtension(name: string): string {\n  const match = /\\.([a-z0-9]{1,5})$/i.exec(name);\n  return match?.[1] ? match[1].toUpperCase() : \"\";\n}\n\n/** 1536 → \"1.5 KB\". Uses binary units and at most one decimal, like Finder. */\nexport function formatBytes(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"\";\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let value = bytes / 1024;\n  let unit = 0;\n  while (value >= 1024 && unit < units.length - 1) {\n    value /= 1024;\n    unit++;\n  }\n  return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`;\n}\n\n/**\n * Does `file` match an `accept` string like \"image/*,.pdf,text/csv\"? An empty or missing accept\n * string accepts everything (same semantics as `<input type=\"file\" accept>`).\n */\nexport function acceptsFile(file: Pick<File, \"name\" | \"type\">, accept?: string): boolean {\n  if (!accept) return true;\n  const name = file.name.toLowerCase();\n  const type = (file.type || \"\").toLowerCase();\n  return accept\n    .split(\",\")\n    .map((s) => s.trim().toLowerCase())\n    .filter(Boolean)\n    .some((rule) => {\n      if (rule.startsWith(\".\")) return name.endsWith(rule);\n      if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1));\n      return type === rule;\n    });\n}\n\nlet counter = 0;\n\n/**\n * Turn a File into an AttachmentItem. Images get an object URL preview — call\n * `URL.revokeObjectURL(item.url)` when you discard the item.\n */\nexport function createAttachment(\n  file: File,\n  overrides: Partial<AttachmentItem> = {},\n): AttachmentItem {\n  counter += 1;\n  const isImage = attachmentKind(file.name, file.type) === \"image\";\n  const url =\n    isImage && typeof URL !== \"undefined\" && typeof URL.createObjectURL === \"function\"\n      ? URL.createObjectURL(file)\n      : undefined;\n  return {\n    id: `att-${Date.now().toString(36)}-${counter}`,\n    name: file.name,\n    size: file.size,\n    type: file.type,\n    url,\n    status: \"ready\",\n    file,\n    ...overrides,\n  };\n}\n\n/** Files from a DataTransfer (drop or paste). Ignores non-file items such as dragged text. */\nexport function filesFromDataTransfer(data: DataTransfer | null): File[] {\n  if (!data) return [];\n  if (data.files && data.files.length > 0) return Array.from(data.files);\n  const out: File[] = [];\n  for (const item of Array.from(data.items ?? [])) {\n    if (item.kind === \"file\") {\n      const f = item.getAsFile();\n      if (f) out.push(f);\n    }\n  }\n  return out;\n}\n\n/** True when a drag carries files (not text or links). Safe to call during dragenter/dragover. */\nexport function dragHasFiles(data: DataTransfer | null): boolean {\n  if (!data) return false;\n  return Array.from(data.types ?? []).includes(\"Files\");\n}\n"
        },
        {
          "target": "components/kura/attachment/attachment.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport {\n  CircleAlert,\n  CloudUpload,\n  FileArchive,\n  FileAudio,\n  FileCode,\n  File as FileIcon,\n  FileImage,\n  FileSpreadsheet,\n  FileText,\n  FileVideo,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { springs } from \"@/lib/kura/motion\";\nimport {\n  type AttachmentItem,\n  type AttachmentKind,\n  type AttachmentStatus,\n  acceptsFile,\n  attachmentExtension,\n  attachmentKind,\n  dragHasFiles,\n  filesFromDataTransfer,\n  formatBytes,\n} from \"./attachment-utils\";\n\nconst KIND_ICON: Record<AttachmentKind, React.ComponentType<{ className?: string }>> = {\n  image: FileImage,\n  pdf: FileText,\n  code: FileCode,\n  text: FileText,\n  sheet: FileSpreadsheet,\n  audio: FileAudio,\n  video: FileVideo,\n  archive: FileArchive,\n  file: FileIcon,\n};\n\n// ---------------------------------------------------------------------------------------------\n// Progress ring\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentProgressRingProps extends Omit<React.ComponentProps<\"svg\">, \"children\"> {\n  /** 0..1. Omit for an indeterminate spinning arc. */\n  progress?: number;\n  /** Stroke width in px. Default 2. */\n  thickness?: number;\n}\n\n/** Circular upload progress. Inherits `currentColor`; the track is the same color at 20%. */\nexport function AttachmentProgressRing({\n  progress,\n  thickness = 2,\n  className,\n  ...props\n}: AttachmentProgressRingProps) {\n  const determinate = typeof progress === \"number\" && Number.isFinite(progress);\n  const value = determinate ? Math.min(1, Math.max(0, progress)) : 0.28;\n  const r = 12 - thickness / 2;\n  return (\n    <svg\n      data-slot=\"attachment-progress\"\n      data-indeterminate={!determinate || undefined}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden\n      className={cn(\n        \"-rotate-90 size-6 shrink-0\",\n        !determinate &&\n          \"animate-kura-spin [animation-duration:1.1s] motion-reduce:animate-kura-pulse\",\n        className,\n      )}\n      {...props}\n    >\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r={r}\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={thickness}\n      />\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r={r}\n        stroke=\"currentColor\"\n        strokeWidth={thickness}\n        strokeLinecap=\"round\"\n        pathLength={100}\n        strokeDasharray=\"100\"\n        strokeDashoffset={100 - value * 100}\n        className=\"transition-[stroke-dashoffset] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Attachment chip / tile\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** File name, e.g. \"q3-report.pdf\". Truncated in the middle of long names via CSS ellipsis. */\n  name: string;\n  /** Size in bytes; shown as \"2.4 MB\". */\n  size?: number;\n  /** MIME type. Picks the file-type icon and decides whether `previewUrl` renders as a thumbnail. */\n  type?: string;\n  /** Image preview URL. With an image type (or `variant=\"tile\"`) the chip renders as a thumbnail. */\n  previewUrl?: string;\n  /** Upload state. Default \"ready\". */\n  status?: AttachmentStatus;\n  /** Upload progress 0..1 while uploading. Omit for an indeterminate ring. */\n  progress?: number;\n  /** Error message for `status=\"error\"`. Shown in the tooltip and to screen readers. */\n  error?: string;\n  /** `auto` = thumbnail tile for images with a preview, chip otherwise. Default \"auto\". */\n  variant?: \"auto\" | \"chip\" | \"tile\";\n  /** Show a remove (×) button and call this when pressed. */\n  onRemove?: () => void;\n  /** Show a \"Retry\" action while `status=\"error\"`. */\n  onRetry?: () => void;\n}\n\n/**\n * One attached file: a chip with a file-type icon, name and size, or a square thumbnail for\n * images. Shows upload progress as a ring, errors with a retry action, and a remove button.\n */\nexport function Attachment({\n  name,\n  size,\n  type,\n  previewUrl,\n  status = \"ready\",\n  progress,\n  error,\n  variant = \"auto\",\n  onRemove,\n  onRetry,\n  className,\n  title,\n  ...props\n}: AttachmentProps) {\n  const kind = attachmentKind(name, type);\n  const tile = variant === \"tile\" || (variant === \"auto\" && kind === \"image\" && !!previewUrl);\n  const Icon = KIND_ICON[kind];\n  const ext = attachmentExtension(name);\n  const sizeLabel = typeof size === \"number\" ? formatBytes(size) : \"\";\n  const pct =\n    typeof progress === \"number\" ? Math.round(Math.min(1, Math.max(0, progress)) * 100) : null;\n\n  let meta: React.ReactNode;\n  if (status === \"uploading\") meta = pct === null ? \"Uploading…\" : `Uploading · ${pct}%`;\n  else if (status === \"error\") meta = error ? \"Failed\" : \"Upload failed\";\n  else meta = [ext, sizeLabel].filter(Boolean).join(\" · \") || \"File\";\n\n  const description = `${name}${status === \"uploading\" ? `, uploading${pct !== null ? ` ${pct}%` : \"\"}` : \"\"}${\n    status === \"error\" ? `, upload failed${error ? `: ${error}` : \"\"}` : \"\"\n  }`;\n\n  const removeButton = onRemove && (\n    <button\n      type=\"button\"\n      data-slot=\"attachment-remove\"\n      aria-label={`Remove ${name}`}\n      onClick={onRemove}\n      className={cn(\n        \"-top-1.5 -right-1.5 absolute z-10 flex size-5 cursor-pointer items-center justify-center rounded-full bg-kura-surface-raised text-kura-fg-muted shadow-kura-sm outline-none\",\n        \"after:-inset-1 after:absolute after:rounded-full\",\n        \"scale-90 opacity-0 transition-[opacity,transform,color] duration-(--kura-duration-fast) ease-kura-out\",\n        \"group-hover/attachment:scale-100 group-hover/attachment:opacity-100 group-focus-within/attachment:scale-100 group-focus-within/attachment:opacity-100\",\n        \"pointer-coarse:scale-100 pointer-coarse:opacity-100 hover:text-kura-fg focus-visible:shadow-kura-focus active:scale-90 motion-reduce:scale-100\",\n      )}\n    >\n      <X className=\"size-3\" strokeWidth={2.25} aria-hidden />\n    </button>\n  );\n\n  if (tile) {\n    return (\n      <div\n        data-slot=\"attachment\"\n        data-variant=\"tile\"\n        data-kind={kind}\n        data-status={status}\n        title={title ?? (status === \"error\" && error ? error : name)}\n        className={cn(\n          \"group/attachment relative isolate inline-flex size-11 shrink-0 rounded-kura-lg bg-kura-surface-sunken shadow-kura-xs\",\n          className,\n        )}\n        {...props}\n      >\n        <span className=\"relative size-full overflow-hidden rounded-[inherit]\">\n          {previewUrl ? (\n            <img\n              src={previewUrl}\n              alt={name}\n              draggable={false}\n              className={cn(\n                \"size-full object-cover transition-[filter,opacity] duration-(--kura-duration-base) ease-kura-out\",\n                status !== \"ready\" && \"opacity-70 saturate-50\",\n              )}\n            />\n          ) : (\n            <span className=\"flex size-full items-center justify-center text-kura-fg-subtle\">\n              <Icon className=\"size-4\" aria-hidden />\n              <span className=\"sr-only\">{name}</span>\n            </span>\n          )}\n          {/* inner hairline so light images keep an edge */}\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\"\n          />\n          {status === \"uploading\" && (\n            <span\n              data-slot=\"attachment-overlay\"\n              className=\"absolute inset-0 flex animate-kura-fade-in items-center justify-center bg-kura-ink/35 text-kura-fg-inverted\"\n            >\n              <AttachmentProgressRing progress={progress} className=\"size-5\" />\n            </span>\n          )}\n          {status === \"error\" && (\n            <span\n              data-slot=\"attachment-overlay\"\n              className=\"absolute inset-0 flex animate-kura-fade-in items-center justify-center bg-kura-danger-subtle/85 text-kura-danger-text\"\n            >\n              {onRetry ? (\n                <button\n                  type=\"button\"\n                  data-slot=\"attachment-retry\"\n                  onClick={onRetry}\n                  aria-label={`Retry uploading ${name}`}\n                  className=\"flex size-full cursor-pointer items-center justify-center outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\"\n                >\n                  <CircleAlert className=\"size-4\" aria-hidden />\n                </button>\n              ) : (\n                <CircleAlert className=\"size-4\" aria-hidden />\n              )}\n            </span>\n          )}\n        </span>\n        <span className=\"sr-only\" role={status === \"error\" ? \"alert\" : undefined}>\n          {status !== \"ready\" ? description : \"\"}\n        </span>\n        {removeButton}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"attachment\"\n      data-variant=\"chip\"\n      data-kind={kind}\n      data-status={status}\n      title={title ?? (status === \"error\" && error ? error : name)}\n      className={cn(\n        \"group/attachment relative inline-flex h-11 min-w-0 max-w-64 shrink-0 items-center gap-2.5 rounded-kura-lg bg-kura-surface py-1 pr-3 pl-1 font-kura-sans shadow-kura-xs\",\n        \"transition-[background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out\",\n        status === \"error\" &&\n          \"shadow-[0_0_0_1px_color-mix(in_oklch,var(--kura-danger)_35%,transparent)]\",\n        className,\n      )}\n      {...props}\n    >\n      <span\n        data-slot=\"attachment-icon\"\n        className={cn(\n          \"relative flex size-9 shrink-0 items-center justify-center rounded-kura-md transition-colors duration-(--kura-duration-base)\",\n          status === \"error\"\n            ? \"bg-kura-danger-subtle text-kura-danger-text\"\n            : \"bg-kura-hover text-kura-fg-muted shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\",\n        )}\n      >\n        {status === \"uploading\" ? (\n          <>\n            <Icon className=\"size-3.5 opacity-60\" aria-hidden />\n            <AttachmentProgressRing\n              progress={progress}\n              className=\"absolute inset-1 size-7 text-kura-fg\"\n              thickness={1.75}\n            />\n          </>\n        ) : status === \"error\" ? (\n          <CircleAlert className=\"size-4\" aria-hidden />\n        ) : (\n          <Icon className=\"size-4\" aria-hidden />\n        )}\n      </span>\n      <span className=\"flex min-w-0 flex-col gap-0.5 leading-none\">\n        <span data-slot=\"attachment-name\" className=\"truncate font-medium text-[13px] text-kura-fg\">\n          {name}\n        </span>\n        <span\n          data-slot=\"attachment-meta\"\n          className={cn(\n            \"flex items-center gap-1 text-[11px] tabular-nums\",\n            status === \"error\" ? \"text-kura-danger-text\" : \"text-kura-fg-subtle\",\n          )}\n        >\n          <span aria-hidden>{meta}</span>\n          <span className=\"sr-only\" role={status === \"error\" ? \"alert\" : undefined}>\n            {status === \"error\" ? `Upload failed${error ? `: ${error}` : \"\"}` : \"\"}\n          </span>\n          {status === \"error\" && onRetry && (\n            <>\n              <span aria-hidden>·</span>\n              <button\n                type=\"button\"\n                data-slot=\"attachment-retry\"\n                onClick={onRetry}\n                aria-label={`Retry uploading ${name}`}\n                className=\"cursor-pointer rounded-kura-xs font-medium text-kura-fg underline decoration-kura-border-strong underline-offset-2 outline-none hover:decoration-current focus-visible:shadow-kura-focus\"\n              >\n                Retry\n              </button>\n            </>\n          )}\n        </span>\n      </span>\n      {removeButton}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// List with \"+N\" overflow stack\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentListProps extends Omit<React.ComponentProps<\"ul\">, \"children\"> {\n  /** The attachments to render, in order. */\n  items: readonly AttachmentItem[];\n  /** Show a remove button on each item. */\n  onRemove?: (id: string) => void;\n  /** Show \"Retry\" on items whose status is \"error\". */\n  onRetry?: (id: string) => void;\n  /** Show at most this many, then a \"+N\" stack. Clicking the stack reveals the rest. */\n  max?: number;\n  /** Forwarded to every Attachment. Default \"auto\". */\n  variant?: \"auto\" | \"chip\" | \"tile\";\n  /** Accessible name of the list. Default \"Attachments\". */\n  label?: string;\n}\n\n/**\n * A wrapping row of attachments. Items pop in, and the row closes the gap smoothly when one is\n * removed. Overflow beyond `max` collapses into a \"+N\" stack.\n */\nexport function AttachmentList({\n  items,\n  onRemove,\n  onRetry,\n  max,\n  variant = \"auto\",\n  label = \"Attachments\",\n  className,\n  ...props\n}: AttachmentListProps) {\n  const reduced = useReducedMotion();\n  const [expanded, setExpanded] = React.useState(false);\n  const limit = !expanded && typeof max === \"number\" && items.length > max ? max : items.length;\n  const visible = items.slice(0, limit);\n  const hidden = items.length - limit;\n\n  const motionProps = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0, transition: { duration: 0.09 } },\n        transition: { duration: 0.15 },\n      }\n    : {\n        layout: \"position\" as const,\n        initial: { opacity: 0, scale: 0.92, filter: \"blur(2px)\" },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: { opacity: 0, scale: 0.92, filter: \"blur(2px)\", transition: { duration: 0.12 } },\n        transition: springs.snappy,\n      };\n\n  return (\n    <ul\n      data-slot=\"attachment-list\"\n      aria-label={label}\n      className={cn(\"flex min-w-0 flex-wrap items-center gap-2\", className)}\n      {...props}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {visible.map((item) => (\n          <motion.li key={item.id} className=\"flex\" {...motionProps}>\n            <Attachment\n              name={item.name}\n              size={item.size}\n              type={item.type}\n              previewUrl={item.url}\n              status={item.status}\n              progress={item.progress}\n              error={item.error}\n              variant={variant}\n              onRemove={onRemove ? () => onRemove(item.id) : undefined}\n              onRetry={onRetry ? () => onRetry(item.id) : undefined}\n            />\n          </motion.li>\n        ))}\n        {hidden > 0 && (\n          <motion.li key=\"__overflow\" className=\"flex\" {...motionProps}>\n            <button\n              type=\"button\"\n              data-slot=\"attachment-overflow\"\n              onClick={() => setExpanded(true)}\n              aria-label={`Show ${hidden} more ${hidden === 1 ? \"attachment\" : \"attachments\"}`}\n              title={items\n                .slice(limit)\n                .map((i) => i.name)\n                .join(\"\\n\")}\n              className={cn(\n                \"group/overflow relative isolate mr-2.5 flex h-11 min-w-11 cursor-pointer items-center justify-center rounded-kura-lg px-2.5 font-kura-sans font-medium text-[13px] text-kura-fg-muted tabular-nums outline-none\",\n                \"transition-[color,transform] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus active:scale-[0.97] motion-reduce:active:scale-100\",\n              )}\n            >\n              {/* the \"deck\": two cards peeking out behind the counter's face */}\n              <span\n                aria-hidden\n                className=\"-z-20 absolute inset-0 translate-x-[10px] scale-[0.78] rounded-[inherit] bg-kura-surface shadow-kura-xs transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/overflow:translate-x-[13px] motion-reduce:transition-none\"\n              />\n              <span\n                aria-hidden\n                className=\"-z-10 absolute inset-0 translate-x-[5px] scale-[0.89] rounded-[inherit] bg-kura-surface shadow-kura-xs transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/overflow:translate-x-[7px] motion-reduce:transition-none\"\n              />\n              <span\n                aria-hidden\n                className=\"absolute inset-0 rounded-[inherit] bg-kura-surface shadow-kura-xs\"\n              />\n              <span className=\"relative\">+{hidden}</span>\n            </button>\n          </motion.li>\n        )}\n      </AnimatePresence>\n    </ul>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Dropzone\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentDropzoneProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onDrop\"> {\n  /** Called with accepted files, and the files rejected by `accept` / `maxSize`. */\n  onFiles: (files: File[], rejected: File[]) => void;\n  /** Same syntax as `<input accept>`, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  /** Allow several files. Default true. */\n  multiple?: boolean;\n  /** Reject files larger than this many bytes. */\n  maxSize?: number;\n  disabled?: boolean;\n  /** Headline. Default \"Drop files or browse\". */\n  title?: React.ReactNode;\n  /** Hint under the headline, e.g. \"PNG, PDF or CSV up to 10 MB\". */\n  description?: React.ReactNode;\n}\n\n/**\n * A drop target and file picker in one: click or press Enter to browse, or drop files onto it.\n * Highlights only when a drag actually carries files.\n */\nexport function AttachmentDropzone({\n  onFiles,\n  accept,\n  multiple = true,\n  maxSize,\n  disabled = false,\n  title = \"Drop files or browse\",\n  description,\n  className,\n  ...props\n}: AttachmentDropzoneProps) {\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const depth = React.useRef(0);\n  const [dragging, setDragging] = React.useState(false);\n  const descId = React.useId();\n\n  const deliver = (list: File[]) => {\n    const files = multiple ? list : list.slice(0, 1);\n    const ok: File[] = [];\n    const bad: File[] = [];\n    for (const f of files) {\n      if (acceptsFile(f, accept) && (maxSize === undefined || f.size <= maxSize)) ok.push(f);\n      else bad.push(f);\n    }\n    if (ok.length || bad.length) onFiles(ok, bad);\n  };\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: drag events only; the inner <button> is the keyboard/pointer control.\n    <div\n      data-slot=\"attachment-dropzone\"\n      data-dragging={dragging || undefined}\n      data-disabled={disabled || undefined}\n      onDragEnter={(e) => {\n        if (disabled || !dragHasFiles(e.dataTransfer)) return;\n        e.preventDefault();\n        depth.current += 1;\n        setDragging(true);\n      }}\n      onDragOver={(e) => {\n        if (disabled || !dragHasFiles(e.dataTransfer)) return;\n        e.preventDefault();\n        e.dataTransfer.dropEffect = \"copy\";\n      }}\n      onDragLeave={() => {\n        depth.current = Math.max(0, depth.current - 1);\n        if (depth.current === 0) setDragging(false);\n      }}\n      onDrop={(e) => {\n        if (disabled) return;\n        e.preventDefault();\n        depth.current = 0;\n        setDragging(false);\n        deliver(filesFromDataTransfer(e.dataTransfer));\n      }}\n      className={cn(\n        \"group/dropzone relative isolate flex min-h-36 w-full rounded-kura-xl font-kura-sans\",\n        \"bg-kura-surface-sunken transition-[background-color] duration-(--kura-duration-base) ease-kura-out\",\n        \"data-dragging:bg-kura-hover\",\n        disabled && \"cursor-not-allowed opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      {/* dashed hairline drawn in SVG so the dash, radius and color are precise */}\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 size-full overflow-visible text-kura-border-strong transition-colors duration-(--kura-duration-base) group-data-dragging/dropzone:text-kura-fg-subtle\"\n      >\n        <rect\n          x=\"0.5\"\n          y=\"0.5\"\n          rx=\"15.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1\"\n          strokeDasharray=\"5 4\"\n          style={{ width: \"calc(100% - 1px)\", height: \"calc(100% - 1px)\" }}\n        />\n      </svg>\n      <button\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={description ? descId : undefined}\n        onClick={() => inputRef.current?.click()}\n        className=\"flex w-full cursor-pointer flex-col items-center justify-center gap-3 rounded-[inherit] p-6 text-center outline-none focus-visible:shadow-kura-focus disabled:cursor-not-allowed\"\n      >\n        <span\n          aria-hidden\n          className={cn(\n            \"flex size-10 items-center justify-center rounded-kura-lg bg-kura-surface text-kura-fg-muted shadow-kura-sm\",\n            \"transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/dropzone:-translate-y-0.5 group-data-dragging/dropzone:-translate-y-1 group-data-dragging/dropzone:text-kura-fg motion-reduce:translate-y-0\",\n          )}\n        >\n          <CloudUpload className=\"size-[18px]\" strokeWidth={1.75} />\n        </span>\n        <span className=\"flex flex-col gap-1\">\n          <span className=\"font-medium text-kura-fg text-sm\">\n            {dragging ? \"Release to attach\" : title}\n          </span>\n          {description && (\n            <span id={descId} className=\"text-kura-fg-subtle text-xs\">\n              {description}\n            </span>\n          )}\n        </span>\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        tabIndex={-1}\n        aria-hidden\n        className=\"hidden\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(e) => {\n          deliver(Array.from(e.currentTarget.files ?? []));\n          e.currentTarget.value = \"\";\n        }}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/attachment/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Attachment,\n  AttachmentDropzone,\n  type AttachmentDropzoneProps,\n  AttachmentList,\n  type AttachmentListProps,\n  AttachmentProgressRing,\n  type AttachmentProgressRingProps,\n  type AttachmentProps,\n} from \"./attachment\";\nexport {\n  type AttachmentItem,\n  type AttachmentKind,\n  type AttachmentStatus,\n  acceptsFile,\n  attachmentExtension,\n  attachmentKind,\n  createAttachment,\n  dragHasFiles,\n  filesFromDataTransfer,\n  formatBytes,\n} from \"./attachment-utils\";\n"
        }
      ]
    },
    {
      "slug": "audio-waveform",
      "name": "AudioWaveform",
      "category": "voice",
      "status": "beta",
      "description": "Canvas audio visualiser — live bars, mirrored history or oscilloscope line from a mic or media element, or recorded peaks with a seek scrubber.",
      "whenToUse": [
        "Show that the mic is live while dictating or talking to a voice agent (mirrored history).",
        "Visualise playback of a model's spoken reply or an <audio> element (spectrum bars or line).",
        "A recorded voice note / audio attachment with a played/unplayed split and drag-to-seek."
      ],
      "whenNotToUse": [
        "A complete dictation control with permissions, transcript and states — use voice-dictation (it renders this).",
        "An ambient “the agent is speaking” blob — use agent-orb.",
        "A non-audio progress indicator — use a progress bar or context-meter."
      ],
      "props": [
        {
          "name": "analyser",
          "type": "AudioAnalyser",
          "description": "Live analyser from useAudioAnalyser. Share one between several visuals."
        },
        {
          "name": "source",
          "type": "MediaStream | HTMLMediaElement | null",
          "description": "Mic stream or media element to analyse internally when no analyser is passed."
        },
        {
          "name": "simulate",
          "type": "boolean | \"speech\" | \"music\" | \"silence\"",
          "description": "Synthetic data for docs, previews and pre-permission states."
        },
        {
          "name": "peaks",
          "type": "readonly number[]",
          "description": "Static mode: amplitudes 0..1 of any length (resampled to the bar count). Build them with peaksFromSamples()."
        },
        {
          "name": "progress",
          "type": "number",
          "description": "Playback position 0..1 in static mode; bars after it are faded."
        },
        {
          "name": "onSeek",
          "type": "(progress: number) => void",
          "description": "Turns the waveform into a seek slider (drag, click, arrow keys). Receives 0..1."
        },
        {
          "name": "duration",
          "type": "number",
          "description": "Duration in seconds for the slider's spoken value (“0:12 of 1:04”)."
        },
        {
          "name": "variant",
          "type": "\"bars\" | \"mirrored\" | \"line\"",
          "default": "\"mirrored\"",
          "description": "bars = bottom-aligned, mirrored = symmetric around the centre line, line = oscilloscope (live) or filled envelope (static)."
        },
        {
          "name": "display",
          "type": "\"history\" | \"spectrum\"",
          "description": "Live data for bars/mirrored: history scrolls recent loudness right→left, spectrum shows frequency bands. Default: history for mirrored, spectrum for bars."
        },
        {
          "name": "barWidth",
          "type": "number",
          "default": "3",
          "description": "Bar width in CSS px."
        },
        {
          "name": "barGap",
          "type": "number",
          "default": "2",
          "description": "Gap between bars in CSS px."
        },
        {
          "name": "minBarHeight",
          "type": "number",
          "default": "2",
          "description": "Minimum bar height so silence reads as a dotted baseline."
        },
        {
          "name": "tone",
          "type": "\"default\" | \"muted\" | \"accent\"",
          "default": "\"default\"",
          "description": "Colour from tokens: ink, subtle ink, or the jade signal (only for live AI listening — once per view)."
        },
        {
          "name": "active",
          "type": "boolean",
          "default": "true",
          "description": "Pause the live animation; the last frame stays on screen."
        },
        {
          "name": "label",
          "type": "string",
          "description": "Accessible name. Default \"Audio waveform\", or \"Seek\" when onSeek is set."
        }
      ],
      "parts": [
        {
          "name": "AudioWaveform",
          "description": "Root sized by CSS (default h-10 w-full). data-mode = live | static, data-variant, data-status (analyser status), data-scrubbing.",
          "dataSlot": "audio-waveform"
        }
      ],
      "a11y": {
        "role": "img (display) or slider (with onSeek)",
        "notes": [
          "Display-only waveforms are role=img with a label; the canvas itself is aria-hidden.",
          "With onSeek it is a focusable slider with aria-valuenow (0–100) and aria-valuetext (“0:12 of 1:04” when duration is set).",
          "Colours come from tokens via currentColor, so contrast follows light/dark themes automatically.",
          "Pair live waveforms with a text status (“Listening”, “Muted”) — the visual alone is not an accessible state."
        ],
        "keyboard": [
          {
            "keys": "ArrowRight / ArrowUp",
            "action": "Seek forward 5% (Shift: 10%)"
          },
          {
            "keys": "ArrowLeft / ArrowDown",
            "action": "Seek back 5% (Shift: 10%)"
          },
          {
            "keys": "Home / End",
            "action": "Start / end"
          }
        ],
        "reducedMotion": "Live data becomes a calm level meter redrawn ~6×/s without sub-frame scrolling; static waveforms are unaffected."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "voice-dictation",
        "agent-orb",
        "attachment"
      ],
      "tags": [
        "audio",
        "waveform",
        "visualizer",
        "canvas",
        "voice",
        "microphone",
        "spectrum",
        "player"
      ],
      "examples": [
        {
          "name": "AudioWaveformMinimal",
          "title": "Simulated live input",
          "kind": "minimal",
          "code": "import { AudioWaveform } from \"@kura-ui/react/audio-waveform\";\n\nexport function AudioWaveformMinimal() {\n  return (\n    <div className=\"w-64\">\n      <AudioWaveform simulate />\n    </div>\n  );\n}\n"
        },
        {
          "name": "AudioWaveformFull",
          "title": "Player with scrub, and every variant",
          "description": "Recorded peaks with play/pause, drag-to-seek and a timer; below, bars/spectrum, mirrored/history and line.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Pause, Play } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { AudioWaveform } from \"@kura-ui/react/audio-waveform\";\n\nfunction demoPeaks(count: number, seed = 7): number[] {\n  let s = seed;\n  const rand = () => {\n    s = (s * 16807) % 2147483647;\n    return s / 2147483647;\n  };\n  return Array.from({ length: count }, (_, i) => {\n    const phrase = Math.sin((i / count) * Math.PI * 5.3) * 0.5 + 0.5;\n    const gap = i % 37 < 3 ? 0.08 : 1;\n    return Math.min(1, (0.15 + 0.85 * phrase * (0.45 + rand() * 0.55)) * gap);\n  });\n}\n\nconst DURATION = 64;\n\nexport function AudioWaveformFull() {\n  const peaks = React.useMemo(() => demoPeaks(220), []);\n  const [progress, setProgress] = React.useState(0.32);\n  const [playing, setPlaying] = React.useState(false);\n\n  React.useEffect(() => {\n    if (!playing) return;\n    const id = setInterval(() => {\n      setProgress((p) => {\n        const next = p + 0.1 / DURATION;\n        if (next >= 1) {\n          setPlaying(false);\n          return 1;\n        }\n        return next;\n      });\n    }, 100);\n    return () => clearInterval(id);\n  }, [playing]);\n\n  const t = progress * DURATION;\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-6\">\n      <div className=\"flex items-center gap-3 rounded-kura-xl bg-kura-surface p-2 pr-4 shadow-kura-sm\">\n        <IconButton\n          label={playing ? \"Pause\" : \"Play\"}\n          icon={playing ? <Pause className=\"fill-current\" /> : <Play className=\"fill-current\" />}\n          variant=\"primary\"\n          size=\"md\"\n          className=\"rounded-full\"\n          tooltip={false}\n          onClick={() => {\n            if (progress >= 1) setProgress(0);\n            setPlaying((p) => !p);\n          }}\n        />\n        <AudioWaveform\n          peaks={peaks}\n          progress={progress}\n          onSeek={setProgress}\n          duration={DURATION}\n          variant=\"mirrored\"\n          barWidth={2}\n          barGap={2}\n          minBarHeight={2}\n          label=\"Seek recording\"\n          className=\"h-9 flex-1\"\n        />\n        <span className=\"w-9 shrink-0 text-right text-kura-fg-muted text-xs tabular-nums\">\n          {Math.floor(t / 60)}:{String(Math.floor(t % 60)).padStart(2, \"0\")}\n        </span>\n      </div>\n      <div className=\"grid grid-cols-3 gap-4\">\n        <figure className=\"flex flex-col gap-2\">\n          <AudioWaveform simulate=\"music\" variant=\"bars\" display=\"spectrum\" className=\"h-12\" />\n          <figcaption className=\"text-center text-kura-fg-subtle text-xs\">\n            bars · spectrum\n          </figcaption>\n        </figure>\n        <figure className=\"flex flex-col gap-2\">\n          <AudioWaveform simulate=\"speech\" variant=\"mirrored\" display=\"history\" className=\"h-12\" />\n          <figcaption className=\"text-center text-kura-fg-subtle text-xs\">\n            mirrored · history\n          </figcaption>\n        </figure>\n        <figure className=\"flex flex-col gap-2\">\n          <AudioWaveform simulate=\"speech\" variant=\"line\" tone=\"muted\" className=\"h-12\" />\n          <figcaption className=\"text-center text-kura-fg-subtle text-xs\">line</figcaption>\n        </figure>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "AudioWaveformVoiceNote",
          "title": "Voice note in a chat bubble",
          "description": "A compact muted waveform with an icon-button play control.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Play } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { AudioWaveform } from \"@kura-ui/react/audio-waveform\";\n\nfunction demoPeaks(count: number, seed = 7): number[] {\n  let s = seed;\n  const rand = () => {\n    s = (s * 16807) % 2147483647;\n    return s / 2147483647;\n  };\n  return Array.from({ length: count }, (_, i) => {\n    const phrase = Math.sin((i / count) * Math.PI * 5.3) * 0.5 + 0.5;\n    const gap = i % 37 < 3 ? 0.08 : 1;\n    return Math.min(1, (0.15 + 0.85 * phrase * (0.45 + rand() * 0.55)) * gap);\n  });\n}\n\nexport function AudioWaveformVoiceNote() {\n  const peaks = React.useMemo(() => demoPeaks(48, 3), []);\n  const [progress, setProgress] = React.useState(0);\n  return (\n    <div className=\"flex w-full max-w-sm flex-col items-end gap-2\">\n      <div className=\"flex w-64 items-center gap-2.5 rounded-kura-2xl rounded-br-kura-sm bg-kura-surface-sunken py-2 pr-3.5 pl-2 shadow-kura-xs\">\n        <IconButton\n          label=\"Play voice note\"\n          icon={<Play className=\"fill-current\" />}\n          variant=\"secondary\"\n          size=\"sm\"\n          className=\"rounded-full\"\n          tooltip={false}\n          onClick={() => setProgress((p) => (p >= 1 ? 0 : Math.min(1, p + 0.25)))}\n        />\n        <AudioWaveform\n          peaks={peaks}\n          progress={progress}\n          onSeek={setProgress}\n          duration={14}\n          barWidth={2}\n          barGap={2}\n          tone=\"muted\"\n          label=\"Seek voice note\"\n          className=\"h-7 flex-1\"\n        />\n        <span className=\"text-[11px] text-kura-fg-subtle tabular-nums\">0:14</span>\n      </div>\n      <span className=\"pr-1 text-[11px] text-kura-fg-subtle\">Voice note · transcribed</span>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Size it with CSS (className=\"h-8 w-48\"); the canvas tracks the element size and device pixel ratio automatically.",
        "For a mic: const rec = useMediaRecorder(); <AudioWaveform source={rec.stream} />. For playback pass the <audio> element as source.",
        "Pass one `analyser` to several visuals instead of creating an analyser per component.",
        "peaksFromSamples(audioBuffer.getChannelData(0), 200) turns decoded audio into peaks.",
        "Use tone=\"accent\" only while the AI is actively listening, and only once per view."
      ],
      "title": "Audio Waveform",
      "importPath": "@kura-ui/react/audio-waveform",
      "exports": [
        "AudioWaveform",
        "AudioWaveformProps",
        "peaksFromSamples"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/audio-waveform",
      "markdownUrl": "https://kuro.abstergo.fyi/components/audio-waveform.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/audio-waveform.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/audio-waveform.json",
        "cli": "npx @kura-ui/cli add audio-waveform",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "use-audio-analyser",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "analyser": {
            "description": "Live analyser from useAudioAnalyser. Share one between several visuals.",
            "x-ts-type": "AudioAnalyser"
          },
          "source": {
            "description": "Mic stream or media element to analyse internally when no analyser is passed.",
            "x-ts-type": "MediaStream | HTMLMediaElement | null"
          },
          "simulate": {
            "description": "Synthetic data for docs, previews and pre-permission states.",
            "x-ts-type": "boolean | \"speech\" | \"music\" | \"silence\""
          },
          "peaks": {
            "description": "Static mode: amplitudes 0..1 of any length (resampled to the bar count). Build them with peaksFromSamples().",
            "x-ts-type": "readonly number[]",
            "type": "array"
          },
          "progress": {
            "description": "Playback position 0..1 in static mode; bars after it are faded.",
            "x-ts-type": "number",
            "type": "number"
          },
          "onSeek": {
            "description": "Turns the waveform into a seek slider (drag, click, arrow keys). Receives 0..1.",
            "x-ts-type": "(progress: number) => void"
          },
          "duration": {
            "description": "Duration in seconds for the slider's spoken value (“0:12 of 1:04”).",
            "x-ts-type": "number",
            "type": "number"
          },
          "variant": {
            "description": "bars = bottom-aligned, mirrored = symmetric around the centre line, line = oscilloscope (live) or filled envelope (static).",
            "x-ts-type": "\"bars\" | \"mirrored\" | \"line\"",
            "type": "string",
            "enum": [
              "bars",
              "line",
              "mirrored"
            ],
            "default": "mirrored"
          },
          "display": {
            "description": "Live data for bars/mirrored: history scrolls recent loudness right→left, spectrum shows frequency bands. Default: history for mirrored, spectrum for bars.",
            "x-ts-type": "\"history\" | \"spectrum\"",
            "type": "string",
            "enum": [
              "history",
              "spectrum"
            ]
          },
          "barWidth": {
            "description": "Bar width in CSS px.",
            "x-ts-type": "number",
            "type": "number",
            "default": 3
          },
          "barGap": {
            "description": "Gap between bars in CSS px.",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          },
          "minBarHeight": {
            "description": "Minimum bar height so silence reads as a dotted baseline.",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          },
          "tone": {
            "description": "Colour from tokens: ink, subtle ink, or the jade signal (only for live AI listening — once per view).",
            "x-ts-type": "\"default\" | \"muted\" | \"accent\"",
            "type": "string",
            "enum": [
              "accent",
              "default",
              "muted"
            ],
            "default": "default"
          },
          "active": {
            "description": "Pause the live animation; the last frame stays on screen.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "label": {
            "description": "Accessible name. Default \"Audio waveform\", or \"Seek\" when onSeek is set.",
            "x-ts-type": "string",
            "type": "string"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/audio-waveform/audio-waveform.tsx",
          "type": "registry:ui",
          "content": "// The root's role is dynamic: role=\"img\" for display, role=\"slider\" (focusable, with pointer and\n// key handlers) when `onSeek` is set — static analysis cannot see that.\n// biome-ignore-all lint/a11y/noStaticElementInteractions: handlers are only attached with role=\"slider\".\n// biome-ignore-all lint/a11y/useAriaPropsSupportedByRole: aria-label/value props match the runtime role.\n\"use client\";\n\nimport * as React from \"react\";\nimport {\n  type AudioAnalyser,\n  type AudioFrame,\n  useAudioAnalyser,\n} from \"@/hooks/kura/use-audio-analyser\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport interface AudioWaveformProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> {\n  /** A live analyser from `useAudioAnalyser` (share one between several visuals). */\n  analyser?: AudioAnalyser;\n  /** Mic stream or media element to analyse internally when no `analyser` is given. */\n  source?: MediaStream | HTMLMediaElement | null;\n  /** Generate synthetic audio data (docs, previews, before permission). */\n  simulate?: boolean | \"speech\" | \"music\" | \"silence\";\n  /**\n   * Static/recorded mode: amplitudes 0..1 (any length; resampled to the bar count). When set,\n   * live data is ignored. Use `peaksFromSamples` to build them from decoded audio.\n   */\n  peaks?: readonly number[];\n  /** Playback position 0..1 for static mode. Bars before it are drawn solid, after it faded. */\n  progress?: number;\n  /** Make the waveform a seek slider (pointer drag + arrow keys). Receives 0..1. */\n  onSeek?: (progress: number) => void;\n  /** Total duration in seconds, used for the slider's spoken value (\"0:12 of 1:04\"). */\n  duration?: number;\n  /** `bars` = bottom-aligned, `mirrored` = symmetric around the centre, `line` = oscilloscope/envelope. Default \"mirrored\". */\n  variant?: \"bars\" | \"mirrored\" | \"line\";\n  /**\n   * Live data shown by `bars`/`mirrored`: `history` scrolls recent loudness from right to left\n   * (voice memo), `spectrum` shows frequency bands. Default: history for mirrored, spectrum for bars.\n   */\n  display?: \"history\" | \"spectrum\";\n  /** Bar width in CSS px. Default 3. */\n  barWidth?: number;\n  /** Gap between bars in CSS px. Default 2. */\n  barGap?: number;\n  /** Minimum bar height in CSS px so silence still reads as a line of dots. Default 2. */\n  minBarHeight?: number;\n  /** `default` = ink, `muted` = subtle ink, `accent` = the signal colour (only for live AI listening). Default \"default\". */\n  tone?: \"default\" | \"muted\" | \"accent\";\n  /** Pause the live animation (the last frame stays). Default true. */\n  active?: boolean;\n  /** Accessible name. Default \"Audio waveform\" (or \"Seek\" when `onSeek` is set). */\n  label?: string;\n}\n\n/** Downsample raw PCM samples (-1..1) to `count` peak amplitudes 0..1, normalised to the loudest. */\nexport function peaksFromSamples(samples: ArrayLike<number>, count: number): number[] {\n  const out = new Array<number>(Math.max(0, count)).fill(0);\n  if (!samples.length || count <= 0) return out;\n  const size = samples.length / count;\n  let max = 0;\n  for (let i = 0; i < count; i++) {\n    const start = Math.floor(i * size);\n    const end = Math.max(start + 1, Math.floor((i + 1) * size));\n    let peak = 0;\n    for (let j = start; j < end && j < samples.length; j++) {\n      const v = Math.abs(samples[j] ?? 0);\n      if (v > peak) peak = v;\n    }\n    out[i] = peak;\n    if (peak > max) max = peak;\n  }\n  return max > 0 ? out.map((v) => v / max) : out;\n}\n\nfunction resample(values: ArrayLike<number>, count: number): Float32Array {\n  const out = new Float32Array(count);\n  const n = values.length;\n  if (!n) return out;\n  for (let i = 0; i < count; i++) {\n    const a = Math.floor((i * n) / count);\n    const b = Math.max(a + 1, Math.floor(((i + 1) * n) / count));\n    let peak = 0;\n    for (let j = a; j < b && j < n; j++) peak = Math.max(peak, values[j] ?? 0);\n    out[i] = peak;\n  }\n  return out;\n}\n\nfunction formatTime(s: number): string {\n  const m = Math.floor(s / 60);\n  const sec = Math.floor(s % 60);\n  return `${m}:${sec.toString().padStart(2, \"0\")}`;\n}\n\nconst TONES = {\n  default: \"text-kura-fg\",\n  muted: \"text-kura-fg-subtle\",\n  accent: \"text-kura-accent\",\n} as const;\n\nconst HISTORY_INTERVAL = 50; // ms per history sample\n\n/**\n * Canvas waveform for live audio (mic, playback, simulated) or recorded peaks, crisp at any\n * device pixel ratio. Static mode can be a seek slider with a played/unplayed split.\n */\nexport function AudioWaveform({\n  analyser: analyserProp,\n  source,\n  simulate,\n  peaks,\n  progress,\n  onSeek,\n  duration,\n  variant = \"mirrored\",\n  display,\n  barWidth = 3,\n  barGap = 2,\n  minBarHeight = 2,\n  tone = \"default\",\n  active = true,\n  label,\n  className,\n  ref,\n  onKeyDown,\n  ...props\n}: AudioWaveformProps) {\n  const isStatic = peaks !== undefined;\n  const internal = useAudioAnalyser({\n    source,\n    simulate,\n    enabled: !analyserProp && !isStatic && (!!source || !!simulate),\n  });\n  const analyser = analyserProp ?? internal;\n  const reduced = useReducedMotion();\n  const mode = display ?? (variant === \"bars\" ? \"spectrum\" : \"history\");\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const sizeRef = React.useRef({ w: 0, h: 0, dpr: 1 });\n  const colorRef = React.useRef(\"currentColor\");\n  const historyRef = React.useRef<number[]>([]);\n  const lastPush = React.useRef(0);\n  const hoverRef = React.useRef<number | null>(null);\n  const draggingRef = React.useRef(false);\n  const lastFrame = React.useRef<AudioFrame | null>(null);\n  const [scrubbing, setScrubbing] = React.useState(false);\n\n  const draw = React.useCallback(\n    (frame: AudioFrame | null, now: number) => {\n      const canvas = canvasRef.current;\n      const ctx = canvas?.getContext(\"2d\");\n      const { w, h, dpr } = sizeRef.current;\n      if (!canvas || !ctx || w === 0 || h === 0) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.fillStyle = colorRef.current;\n      ctx.strokeStyle = colorRef.current;\n\n      const step = barWidth + barGap;\n      const count = Math.max(1, Math.floor((w + barGap) / step));\n      const usable = h - 2;\n      const radius = barWidth / 2;\n      const align = variant === \"bars\" ? \"bottom\" : \"center\";\n      const playedX = typeof progress === \"number\" ? Math.min(1, Math.max(0, progress)) * w : null;\n      const hoverX = hoverRef.current;\n\n      const bar = (x: number, v: number, alpha: number) => {\n        const bh = Math.max(minBarHeight, v * usable);\n        const y = align === \"bottom\" ? h - 1 - bh : (h - bh) / 2;\n        ctx.globalAlpha = alpha;\n        ctx.beginPath();\n        if (typeof ctx.roundRect === \"function\")\n          ctx.roundRect(x, y, barWidth, bh, Math.min(radius, bh / 2));\n        else ctx.rect(x, y, barWidth, bh);\n        ctx.fill();\n      };\n\n      const alphaFor = (x: number) => {\n        if (playedX === null) return 1;\n        if (x + barWidth / 2 <= playedX) return 1;\n        if (hoverX !== null && x <= hoverX) return 0.5;\n        return 0.24;\n      };\n\n      // Static peaks.\n      if (isStatic) {\n        const values = resample(peaks ?? [], count);\n        if (variant === \"line\") {\n          drawEnvelope(ctx, values, w, h, minBarHeight);\n          if (playedX !== null) {\n            ctx.save();\n            ctx.globalCompositeOperation = \"destination-in\";\n            ctx.globalAlpha = 1;\n            ctx.fillRect(0, 0, playedX, h);\n            ctx.globalAlpha = 0.24;\n            ctx.fillRect(playedX, 0, w - playedX, h);\n            ctx.restore();\n          }\n        } else {\n          const offset = (w - (count * step - barGap)) / 2;\n          for (let i = 0; i < count; i++) {\n            const x = Math.round(offset + i * step);\n            bar(x, values[i] ?? 0, alphaFor(x));\n          }\n        }\n        if (playedX !== null && (hoverX !== null || draggingRef.current)) {\n          ctx.globalAlpha = 0.9;\n          ctx.fillRect(Math.round(hoverX ?? playedX), 0, 1, h);\n        }\n        ctx.globalAlpha = 1;\n        return;\n      }\n\n      // Live.\n      const f = frame;\n      if (variant === \"line\") {\n        ctx.globalAlpha = 1;\n        ctx.lineWidth = 1.5;\n        ctx.lineJoin = \"round\";\n        ctx.beginPath();\n        const data = f?.waveform;\n        const points = Math.min(256, Math.max(32, Math.floor(w / 3)));\n        for (let i = 0; i <= points; i++) {\n          const x = (i / points) * w;\n          const idx = data ? Math.floor((i / points) * (data.length - 1)) : 0;\n          const v = data ? (data[idx] ?? 0) : 0;\n          // taper the ends so the line breathes out of a flat baseline\n          const taper = Math.sin((i / points) * Math.PI);\n          const y = h / 2 + v * (usable / 2) * taper * 1.6;\n          if (i === 0) ctx.moveTo(x, y);\n          else ctx.lineTo(x, y);\n        }\n        ctx.stroke();\n        return;\n      }\n\n      if (mode === \"spectrum\") {\n        const values = resample(f?.bands ?? [], count);\n        const offset = (w - (count * step - barGap)) / 2;\n        for (let i = 0; i < count; i++) {\n          bar(Math.round(offset + i * step), values[i] ?? 0, 1);\n        }\n        return;\n      }\n\n      // history: newest on the right, scrolling left.\n      const hist = historyRef.current;\n      const elapsed = reduced ? 1 : Math.min(1, (now - lastPush.current) / HISTORY_INTERVAL);\n      const shift = elapsed * step;\n      const fade = Math.min(w * 0.18, 64);\n      for (let i = 0; i < count + 1; i++) {\n        const v = hist[hist.length - 1 - i] ?? 0;\n        const x = Math.round(w - barWidth - i * step - shift + step);\n        if (x + barWidth < 0) break;\n        if (x > w - barWidth) continue;\n        const edge = x < fade ? Math.max(0, x / fade) : 1;\n        bar(x, v, 0.25 + 0.75 * edge);\n      }\n      ctx.globalAlpha = 1;\n    },\n    [barWidth, barGap, minBarHeight, variant, mode, isStatic, peaks, progress, reduced],\n  );\n\n  const redraw = React.useCallback(() => {\n    draw(lastFrame.current, typeof performance !== \"undefined\" ? performance.now() : 0);\n  }, [draw]);\n\n  // Size the canvas to the element at device-pixel resolution.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const readColor = () => {\n      colorRef.current = window.getComputedStyle(root).color || \"currentColor\";\n    };\n    const resize = () => {\n      const dpr = Math.min(3, window.devicePixelRatio || 1);\n      const w = root.clientWidth;\n      const h = root.clientHeight;\n      sizeRef.current = { w, h, dpr };\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      readColor();\n      redraw();\n    };\n    resize();\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null;\n    ro?.observe(root);\n    // Theme switches change the token colour without resizing.\n    const mo =\n      typeof MutationObserver !== \"undefined\"\n        ? new MutationObserver(() => {\n            readColor();\n            redraw();\n          })\n        : null;\n    mo?.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\", \"style\"],\n    });\n    return () => {\n      ro?.disconnect();\n      mo?.disconnect();\n    };\n  }, [redraw]);\n\n  // Re-read the colour when the tone changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: tone changes the CSS colour we sample.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    colorRef.current = window.getComputedStyle(root).color || \"currentColor\";\n    redraw();\n  }, [tone, redraw]);\n\n  // Live loop.\n  React.useEffect(() => {\n    if (isStatic || !active) return;\n    let lastDraw = 0;\n    return analyser.subscribe((frame) => {\n      lastFrame.current = frame;\n      const now = frame.time;\n      if (now - lastPush.current >= HISTORY_INTERVAL) {\n        const hist = historyRef.current;\n        hist.push(Math.min(1, frame.level * 0.85 + frame.peak * 0.25));\n        if (hist.length > 600) hist.splice(0, hist.length - 600);\n        lastPush.current = now;\n      }\n      // Reduced motion: a calm meter that updates a few times per second.\n      if (reduced && now - lastDraw < 160) return;\n      lastDraw = now;\n      draw(frame, now);\n    });\n  }, [analyser, isStatic, active, draw, reduced]);\n\n  // Static redraws.\n  React.useEffect(() => {\n    if (isStatic) redraw();\n  }, [isStatic, redraw]);\n\n  const seekable = isStatic && !!onSeek;\n  const posFromEvent = (e: React.PointerEvent) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    return rect.width > 0 ? Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) : 0;\n  };\n\n  const pct =\n    typeof progress === \"number\" ? Math.round(Math.min(1, Math.max(0, progress)) * 100) : 0;\n  const valueText =\n    duration !== undefined\n      ? `${formatTime((pct / 100) * duration)} of ${formatTime(duration)}`\n      : `${pct}%`;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"audio-waveform\"\n      data-variant={variant}\n      data-mode={isStatic ? \"static\" : \"live\"}\n      data-status={isStatic ? undefined : analyser.status}\n      data-scrubbing={scrubbing || undefined}\n      role={seekable ? \"slider\" : \"img\"}\n      aria-label={label ?? (seekable ? \"Seek\" : \"Audio waveform\")}\n      aria-valuemin={seekable ? 0 : undefined}\n      aria-valuemax={seekable ? 100 : undefined}\n      aria-valuenow={seekable ? pct : undefined}\n      aria-valuetext={seekable ? valueText : undefined}\n      tabIndex={seekable ? 0 : undefined}\n      onPointerDown={\n        seekable\n          ? (e) => {\n              e.currentTarget.setPointerCapture?.(e.pointerId);\n              draggingRef.current = true;\n              setScrubbing(true);\n              onSeek?.(posFromEvent(e));\n            }\n          : undefined\n      }\n      onPointerMove={\n        seekable\n          ? (e) => {\n              const p = posFromEvent(e);\n              hoverRef.current = p * sizeRef.current.w;\n              if (draggingRef.current) onSeek?.(p);\n              else redraw();\n            }\n          : undefined\n      }\n      onPointerUp={\n        seekable\n          ? () => {\n              draggingRef.current = false;\n              setScrubbing(false);\n            }\n          : undefined\n      }\n      onPointerLeave={\n        seekable\n          ? () => {\n              hoverRef.current = null;\n              if (!draggingRef.current) redraw();\n            }\n          : undefined\n      }\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (!seekable || e.defaultPrevented) return;\n        const current = typeof progress === \"number\" ? progress : 0;\n        const delta = e.shiftKey ? 0.1 : 0.05;\n        let next: number | null = null;\n        if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = current + delta;\n        else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = current - delta;\n        else if (e.key === \"Home\") next = 0;\n        else if (e.key === \"End\") next = 1;\n        if (next !== null) {\n          e.preventDefault();\n          onSeek?.(Math.min(1, Math.max(0, next)));\n        }\n      }}\n      className={cn(\n        \"relative h-10 w-full min-w-0 select-none\",\n        TONES[tone],\n        seekable &&\n          \"cursor-pointer touch-none rounded-kura-sm outline-none focus-visible:shadow-kura-focus\",\n        className,\n      )}\n      {...props}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 size-full\" />\n    </div>\n  );\n}\n\nfunction drawEnvelope(\n  ctx: CanvasRenderingContext2D,\n  values: Float32Array,\n  w: number,\n  h: number,\n  min: number,\n) {\n  const n = values.length;\n  if (!n) return;\n  const mid = h / 2;\n  const amp = (v: number) => Math.max(min / 2, (v * (h - 2)) / 2);\n  ctx.globalAlpha = 1;\n  ctx.beginPath();\n  ctx.moveTo(0, mid);\n  for (let i = 0; i < n; i++) {\n    const x = (i / Math.max(1, n - 1)) * w;\n    ctx.lineTo(x, mid - amp(values[i] ?? 0));\n  }\n  for (let i = n - 1; i >= 0; i--) {\n    const x = (i / Math.max(1, n - 1)) * w;\n    ctx.lineTo(x, mid + amp(values[i] ?? 0));\n  }\n  ctx.closePath();\n  ctx.fill();\n}\n"
        },
        {
          "target": "components/kura/audio-waveform/index.ts",
          "type": "registry:ui",
          "content": "export { AudioWaveform, type AudioWaveformProps, peaksFromSamples } from \"./audio-waveform\";\n"
        }
      ]
    },
    {
      "slug": "aurora",
      "name": "Aurora",
      "category": "effects",
      "status": "beta",
      "description": "Soft northern-lights background: folding light curtains drawn in a shader with premultiplied alpha, so it glows on dark and washes on light.",
      "whenToUse": [
        "A sign-in, landing or 'welcome back' screen that should feel calm and a little magical.",
        "A night-themed hero or a header band above an agent dashboard.",
        "A background that must sit on top of an existing surface colour (it is transparent where there is no light)."
      ],
      "whenNotToUse": [
        "A colourful blob background filling the whole area — use mesh-gradient.",
        "Showing that an agent is busy — use border-beam or thinking-indicator.",
        "A voice agent's presence — use agent-orb.",
        "Behind dense UI or long text: the curtains move; keep content short and centred."
      ],
      "props": [
        {
          "name": "colors",
          "type": "\"signal\" | \"polar\" | \"ember\" | \"mist\" | readonly [string, string, string]",
          "default": "\"signal\"",
          "description": "A preset, or three CSS colours `[lower edge, middle, upper fringe]`. signal uses the theme accent; polar is classic green-teal-blue."
        },
        {
          "name": "intensity",
          "type": "number",
          "default": "0.8",
          "description": "Brightness of the curtains, 0–1."
        },
        {
          "name": "speed",
          "type": "number",
          "default": "1",
          "description": "Animation speed multiplier. 0 renders a still frame."
        },
        {
          "name": "stars",
          "type": "boolean",
          "default": "true",
          "description": "Twinkling stars behind the curtains. Only drawn when the background is dark."
        },
        {
          "name": "paused",
          "type": "boolean",
          "default": "false",
          "description": "Freeze the animation on the current frame."
        },
        {
          "name": "maxDpr",
          "type": "number",
          "default": "1.5",
          "description": "Device-pixel-ratio cap."
        },
        {
          "name": "children",
          "type": "React.ReactNode",
          "description": "Content rendered on top, in normal flow."
        }
      ],
      "parts": [
        {
          "name": "Aurora",
          "description": "Root container (`relative isolate overflow-hidden`). Give it a size and a background colour. `data-state` = animating | still.",
          "dataSlot": "aurora"
        },
        {
          "name": "ShaderCanvas",
          "description": "Transparent WebGL layer behind the children (aria-hidden).",
          "dataSlot": "aurora-canvas"
        },
        {
          "name": "Fallback",
          "description": "Blurred CSS radial gradients shown when WebGL2 is unavailable.",
          "dataSlot": "aurora-fallback"
        }
      ],
      "a11y": {
        "notes": [
          "Decorative and aria-hidden; children keep their semantics.",
          "The curtains sit mostly in the lower half; place text near the top or centre and check contrast in both themes."
        ],
        "keyboard": [],
        "reducedMotion": "Renders one still frame (no drift, no twinkle). Theme changes redraw it."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "shader-canvas"
        ]
      },
      "related": [
        "mesh-gradient",
        "grain",
        "particle-field",
        "shader-canvas"
      ],
      "tags": [
        "aurora",
        "northern-lights",
        "background",
        "webgl",
        "shader",
        "hero",
        "night",
        "ambient"
      ],
      "examples": [
        {
          "name": "AuroraMinimal",
          "title": "Aurora on the canvas colour",
          "description": "Give it a size and a background; the signal preset follows the theme.",
          "kind": "minimal",
          "code": "import { Aurora } from \"@kura-ui/react/aurora\";\n\nexport function AuroraMinimal() {\n  return <Aurora className=\"h-72 w-full rounded-kura-xl bg-kura-canvas shadow-kura-sm\" />;\n}\n"
        },
        {
          "name": "AuroraFull",
          "title": "Presets, intensity, stars",
          "description": "Every preset, intensity, stars and pause.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Aurora, type AuroraPreset } from \"@kura-ui/react/aurora\";\n\nconst presets: AuroraPreset[] = [\"signal\", \"polar\", \"ember\", \"mist\"];\n\nexport function AuroraFull() {\n  const [preset, setPreset] = React.useState<AuroraPreset>(\"polar\");\n  const [intensity, setIntensity] = React.useState(0.85);\n  const [stars, setStars] = React.useState(true);\n  const [paused, setPaused] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-2xl flex-col gap-4\">\n      <Aurora\n        colors={preset}\n        intensity={intensity}\n        stars={stars}\n        paused={paused}\n        className=\"aspect-[16/9] w-full rounded-kura-xl bg-kura-surface-sunken shadow-kura-md\"\n      />\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        {presets.map((p) => (\n          <Button\n            key={p}\n            size=\"sm\"\n            variant={p === preset ? \"secondary\" : \"ghost\"}\n            aria-pressed={p === preset}\n            onClick={() => setPreset(p)}\n            className=\"capitalize\"\n          >\n            {p}\n          </Button>\n        ))}\n        <span className=\"ml-auto flex gap-1.5\">\n          <Button\n            size=\"sm\"\n            variant=\"ghost\"\n            aria-pressed={stars}\n            onClick={() => setStars((s) => !s)}\n          >\n            Stars {stars ? \"on\" : \"off\"}\n          </Button>\n          <Button size=\"sm\" variant=\"ghost\" onClick={() => setPaused((p) => !p)}>\n            {paused ? \"Play\" : \"Pause\"}\n          </Button>\n        </span>\n      </div>\n      <label className=\"flex max-w-xs flex-col gap-1.5 text-kura-fg-muted text-xs\">\n        Intensity {intensity.toFixed(2)}\n        <input\n          type=\"range\"\n          min={0}\n          max={1}\n          step={0.05}\n          value={intensity}\n          onChange={(e) => setIntensity(Number(e.target.value))}\n          className=\"accent-kura-accent\"\n        />\n      </label>\n    </div>\n  );\n}\n"
        },
        {
          "name": "AuroraSignIn",
          "title": "Sign-in hero",
          "description": "Headline and Buttons over the aurora, finished with Grain.",
          "kind": "composition",
          "code": "import { Button } from \"@kura-ui/react/button\";\nimport { Grain } from \"@kura-ui/react/grain\";\nimport { Aurora } from \"@kura-ui/react/aurora\";\n\nexport function AuroraSignIn() {\n  return (\n    <Aurora\n      intensity={0.7}\n      className=\"flex min-h-96 w-full flex-col items-center justify-start gap-3 rounded-kura-2xl bg-kura-canvas px-6 pt-14 text-center shadow-kura-md\"\n    >\n      <h2 className=\"relative z-10 text-balance font-medium text-3xl text-kura-fg tracking-tight\">\n        Your agents worked through the night.\n      </h2>\n      <p className=\"relative z-10 max-w-sm text-kura-fg-muted text-sm leading-relaxed\">\n        Sign in to review 14 finished tasks and 2 waiting for approval.\n      </p>\n      <div className=\"relative z-10 mt-2 flex gap-2\">\n        <Button>Sign in</Button>\n        <Button variant=\"secondary\">Create account</Button>\n      </div>\n      <Grain opacity={0.18} />\n    </Aurora>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "The canvas is transparent: set a background on the root (`bg-kura-canvas`, `bg-kura-surface-sunken`) — the aurora is light added on top of it.",
        "Give the root a height (`h-72`, `min-h-96`, `aspect-video`).",
        "Lift children above the canvas automatically (they are in flow); lift them above a `<Grain />` overlay with `relative z-10`.",
        "On light themes the curtains render as a deeper watercolour wash; check both themes."
      ],
      "title": "Aurora",
      "importPath": "@kura-ui/react/aurora",
      "exports": [
        "Aurora",
        "AuroraProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/aurora",
      "markdownUrl": "https://kuro.abstergo.fyi/components/aurora.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/aurora.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/aurora.json",
        "cli": "npx @kura-ui/cli add aurora",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "kura-shader-color",
        "shader-canvas",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "colors": {
            "description": "A preset, or three CSS colours `[lower edge, middle, upper fringe]`. signal uses the theme accent; polar is classic green-teal-blue.",
            "x-ts-type": "\"signal\" | \"polar\" | \"ember\" | \"mist\" | readonly [string, string, string]",
            "default": "signal"
          },
          "intensity": {
            "description": "Brightness of the curtains, 0–1.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.8
          },
          "speed": {
            "description": "Animation speed multiplier. 0 renders a still frame.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "stars": {
            "description": "Twinkling stars behind the curtains. Only drawn when the background is dark.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "paused": {
            "description": "Freeze the animation on the current frame.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "maxDpr": {
            "description": "Device-pixel-ratio cap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1.5
          },
          "children": {
            "description": "Content rendered on top, in normal flow.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/aurora/aurora.glsl.ts",
          "type": "registry:ui",
          "content": "import { GLSL_COLOR } from \"@/lib/kura/shader-color\";\n\n/**\n * Aurora: three layers of light curtains. Each curtain has a bright, sharp lower edge that wanders\n * along an fbm curve, folds sideways (x is warped by noise) and fades upward through the palette.\n * Vertical \"rays\" come from noise stretched along y. Light is accumulated in linear RGB and written\n * as premultiplied alpha, so the aurora composites over any background — luminous on dark,\n * watercolour-like on light. Optional stars twinkle behind it on dark backgrounds.\n */\nexport const AURORA_FRAG = /* glsl */ `\nuniform vec3 u_low;   // lower edge colour (linear RGB)\nuniform vec3 u_mid;\nuniform vec3 u_high;  // upper fringe colour\nuniform float u_intensity;\nuniform float u_stars;\nuniform float u_light; // 1 when the background is light\n\n${GLSL_COLOR}\n\nfloat hash12(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n  p3 += dot(p3, p3.yzx + 33.33);\n  return fract((p3.x + p3.y) * p3.z);\n}\nfloat vnoise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash12(i), hash12(i + vec2(1, 0)), u.x),\n             mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), u.x), u.y);\n}\nfloat fbm(vec2 p) {\n  float v = 0.0, a = 0.5;\n  for (int i = 0; i < 4; i++) { v += a * vnoise(p); p = p * 2.03 + 17.1; a *= 0.5; }\n  return v;\n}\n\nvec3 curtain(vec2 uv, float aspect, float t, float i) {\n  float x = uv.x * aspect;\n  // Folds: warp x by slow noise so the ribbon bends like fabric.\n  float fold = fbm(vec2(x * 0.7 + i * 3.1, t * 0.1 + i * 2.0)) - 0.5;\n  float xw = x + fold * 1.1;\n  // The lower edge snakes across the sky.\n  float edge = 0.42 + 0.09 * i\n    + 0.26 * (fbm(vec2(xw * 0.9 - t * 0.05 + i * 7.3, i * 1.7)) - 0.5)\n    + 0.04 * sin(xw * 2.3 + t * 0.15 + i * 2.0);\n  float d = uv.y - edge;\n  // Ribbons break up along their length, leaving dark sky between them.\n  float seg = smoothstep(0.3, 0.56, fbm(vec2(xw * 0.45 + i * 5.3 + t * 0.03, i * 3.3)));\n  // Rays: noise stretched vertically, drifting along the ribbon; they also set each ray's height.\n  float rays = fbm(vec2(xw * 16.0 + t * 0.3 * (1.0 + i * 0.3), uv.y * 0.5 - t * 0.04));\n  rays = smoothstep(0.2, 0.8, rays);\n  float height = 0.06 + 0.16 * rays * (1.0 - i * 0.25);\n  float body = smoothstep(-0.008, 0.012, d) * exp(-max(d, 0.0) / height);\n  float rim = exp(-abs(d) * 70.0) * 0.35; // the bright, crisp lower edge\n  // On light backgrounds strong ray contrast reads as grey streaks, so soften it into a wash.\n  float rr = mix(rays, 0.55 + 0.45 * rays, u_light);\n  float a = (body * (0.45 + 0.75 * rr) + rim) * seg * (1.0 - i * 0.25);\n  vec3 c = mix(u_low, u_mid, smoothstep(0.0, 0.08, d));\n  c = mix(c, u_high, smoothstep(0.06, 0.24, d));\n  return c * a;\n}\n\nvoid main() {\n  float aspect = u_resolution.x / u_resolution.y;\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  float t = u_time;\n\n  vec3 light = vec3(0.0);\n  for (int i = 0; i < 3; i++) light += curtain(uv, aspect, t, float(i));\n  light *= u_intensity * 1.2;\n\n  // Stars on dark backgrounds, hidden behind bright curtains.\n  float stars = 0.0;\n  if (u_stars > 0.0) {\n    vec2 g = gl_FragCoord.xy / 3.0;\n    vec2 cell = floor(g);\n    float h = hash12(cell);\n    if (h > 0.992) {\n      vec2 o = vec2(hash12(cell + 3.1), hash12(cell + 7.7));\n      float dd = length(fract(g) - o);\n      float tw = 0.6 + 0.4 * sin(t * (1.0 + h * 3.0) + h * 40.0);\n      stars = smoothstep(0.5, 0.0, dd) * tw * (h - 0.992) * 110.0 * smoothstep(0.15, 0.9, uv.y);\n    }\n    stars *= u_stars * (1.0 - u_light) * (1.0 - clamp(dot(light, vec3(1.0)), 0.0, 1.0));\n  }\n  light += vec3(stars) * 0.8;\n\n  // Light → premultiplied colour. On light backgrounds the curtains become a saturated ink wash\n  // (deeper, never desaturated toward grey) with a gentler alpha curve.\n  float peak = max(light.r, max(light.g, light.b));\n  float a = clamp(1.0 - exp(-peak * mix(1.8, 1.5, u_light)), 0.0, 1.0) * mix(1.0, 0.92, u_light);\n  vec3 c = peak > 0.0 ? light / peak : vec3(0.0);\n  vec3 ink = c * 0.8;\n  c = mix(c, ink, u_light);\n  vec3 premul = linearToSrgb(c) * a;\n  premul += (ign(gl_FragCoord.xy) - 0.5) / 255.0 * a;\n  fragColor = vec4(premul, a);\n}\n`;\n"
        },
        {
          "target": "components/kura/aurora/aurora.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { useResolvedColors } from \"@/lib/kura/shader-color\";\nimport { ShaderCanvas } from \"@/components/kura/shader-canvas\";\nimport { AURORA_FRAG } from \"./aurora.glsl\";\n\nexport type AuroraPreset = \"signal\" | \"polar\" | \"ember\" | \"mist\";\n\n/** Palettes as [lower edge, middle, upper fringe]. `signal` follows the theme's accent. */\nexport const auroraPresets: Record<AuroraPreset, readonly [string, string, string]> = {\n  signal: [\n    \"var(--kura-accent)\",\n    \"color-mix(in oklab, var(--kura-accent) 55%, oklch(0.62 0.11 215))\",\n    \"oklch(0.45 0.09 245)\",\n  ],\n  polar: [\"oklch(0.86 0.19 152)\", \"oklch(0.74 0.13 190)\", \"oklch(0.48 0.12 255)\"],\n  ember: [\"oklch(0.88 0.14 88)\", \"oklch(0.72 0.16 52)\", \"oklch(0.45 0.1 30)\"],\n  mist: [\"oklch(0.92 0.01 85)\", \"oklch(0.75 0.015 220)\", \"oklch(0.5 0.02 240)\"],\n};\n\nexport interface AuroraProps extends React.ComponentProps<\"div\"> {\n  /** A preset, or three CSS colours `[lower edge, middle, upper fringe]`. Default \"signal\". */\n  colors?: AuroraPreset | readonly [string, string, string];\n  /** Brightness of the curtains, 0–1. Default 0.8. */\n  intensity?: number;\n  /** Animation speed multiplier. Default 1. */\n  speed?: number;\n  /** Twinkling stars behind the aurora (only drawn on dark backgrounds). Default true. */\n  stars?: boolean;\n  /** Freeze the animation on the current frame. Default false. */\n  paused?: boolean;\n  /** Device-pixel-ratio cap. Default 1.5. */\n  maxDpr?: number;\n}\n\n/**\n * A soft aurora (northern lights) background: folding light curtains with crisp lower edges and\n * vertical rays, drawn in a fragment shader with premultiplied alpha so it sits on any surface —\n * luminous on dark, a watercolour wash on light. Children render on top. CSS fallback without\n * WebGL; a still frame under reduced motion.\n */\nexport function Aurora({\n  colors = \"signal\",\n  intensity = 0.8,\n  speed = 1,\n  stars = true,\n  paused = false,\n  maxDpr = 1.5,\n  className,\n  children,\n  ref,\n  ...props\n}: AuroraProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const palette = typeof colors === \"string\" ? auroraPresets[colors] : colors;\n  const list = React.useMemo(() => [...palette, \"var(--kura-canvas)\"], [palette]);\n  const { colors: resolved, version } = useResolvedColors(rootRef, list);\n\n  // `version` bumps when the theme flips: a new uniforms object repaints a still frame.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: version is the repaint trigger\n  const uniforms = React.useMemo(() => {\n    const col = (i: number) => () =>\n      (resolved.current[i]?.linear ?? [0.3, 0.6, 0.5]) as [number, number, number];\n    return {\n      u_low: col(0),\n      u_mid: col(1),\n      u_high: col(2),\n      u_intensity: Math.min(1, Math.max(0, intensity)),\n      u_stars: stars ? 1 : 0,\n      u_light: () => ((resolved.current[3]?.oklab[0] ?? 1) > 0.55 ? 1 : 0),\n    };\n  }, [resolved, version, intensity, stars]);\n\n  const isStill = reducedMotion || paused || speed === 0;\n  const [low, mid, high] = palette;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"aurora\"\n      data-state={isStill ? \"still\" : \"animating\"}\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...props}\n    >\n      <ShaderCanvas\n        data-slot=\"aurora-canvas\"\n        fragmentShader={AURORA_FRAG}\n        uniforms={uniforms}\n        paused={paused || speed === 0}\n        speed={speed * 0.6}\n        maxDpr={maxDpr}\n        staticTime={12}\n        className=\"-z-10 absolute inset-0\"\n        fallback={\n          <div\n            aria-hidden\n            data-slot=\"aurora-fallback\"\n            className=\"absolute inset-0 opacity-70 blur-2xl\"\n            style={{\n              background: `radial-gradient(60% 28% at 30% 62%, ${low}, transparent 70%), radial-gradient(50% 30% at 70% 55%, ${mid}, transparent 70%), radial-gradient(70% 30% at 50% 80%, ${high}, transparent 75%)`,\n            }}\n          />\n        }\n      />\n      {children}\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/aurora/index.ts",
          "type": "registry:ui",
          "content": "export { Aurora, type AuroraProps } from \"./aurora\";\n"
        }
      ]
    },
    {
      "slug": "border-beam",
      "name": "BorderBeam",
      "category": "effects",
      "status": "beta",
      "description": "Animated light beam that travels around its parent's border — a quiet “an agent is working in here” signal for composers, cards and panels.",
      "whenToUse": [
        "A composer, card or panel whose content an agent is currently editing or generating.",
        "A running tool call or background task row that should read as live without a spinner.",
        "Highlighting the one container on screen that is waiting for or producing AI output."
      ],
      "whenNotToUse": [
        "Small inline 'thinking' feedback — use thinking-indicator or shimmer-text.",
        "Filling a background with colour — use mesh-gradient or aurora.",
        "Focus or selection states — use the focus ring (shadow-kura-focus), not a beam.",
        "Several beams on one screen at once: it is a signal; more than one or two becomes noise."
      ],
      "props": [
        {
          "name": "active",
          "type": "boolean",
          "default": "true",
          "description": "Show the beam. Toggling fades it in/out over 480ms and pauses the animation while hidden — keep it mounted."
        },
        {
          "name": "duration",
          "type": "number",
          "default": "6",
          "description": "Seconds per lap."
        },
        {
          "name": "size",
          "type": "number",
          "default": "0.25",
          "description": "Beam length as a share of the lap, 0.05–0.9."
        },
        {
          "name": "width",
          "type": "number",
          "default": "1",
          "description": "Ring thickness in px."
        },
        {
          "name": "colors",
          "type": "readonly string[]",
          "description": "CSS colours from tail to head. Default: transparent → jade at 45% → jade → accent-text. Tokens and color-mix() work."
        },
        {
          "name": "glow",
          "type": "boolean",
          "default": "false",
          "description": "Add a blurred copy of the beam for a soft glow (not clipped by the parent unless it has overflow-hidden)."
        },
        {
          "name": "reverse",
          "type": "boolean",
          "default": "false",
          "description": "Travel counter-clockwise."
        },
        {
          "name": "offset",
          "type": "number",
          "default": "0",
          "description": "Starting position as a share of the lap, 0–1. Use 0.5 on a second beam to run two beams opposite each other."
        }
      ],
      "parts": [
        {
          "name": "BorderBeam",
          "description": "Absolutely positioned overlay (`inset-0`, `rounded-[inherit]`, pointer-events none). `data-active` mirrors `active`.",
          "dataSlot": "border-beam"
        },
        {
          "name": "Glow",
          "description": "Blurred wrapper around a thicker ring; only rendered with `glow`.",
          "dataSlot": "border-beam-glow"
        },
        {
          "name": "Ring",
          "description": "The masked conic gradient ring that carries the animation.",
          "dataSlot": "border-beam-ring"
        }
      ],
      "a11y": {
        "notes": [
          "Purely decorative (aria-hidden). Pair it with real text — a role=status label such as “Editing 3 files…” — so the busy state is not conveyed by motion alone.",
          "It never intercepts pointer events, so the container stays fully interactive."
        ],
        "keyboard": [],
        "reducedMotion": "The beam stops travelling and becomes a static, softly tinted ring (head colour at 45%) so the container still reads as active."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "thinking-indicator",
        "shimmer-text",
        "composer",
        "tool-call",
        "mesh-gradient"
      ],
      "tags": [
        "border",
        "beam",
        "glow",
        "loading",
        "busy",
        "agent",
        "conic-gradient",
        "css"
      ],
      "examples": [
        {
          "name": "BorderBeamMinimal",
          "title": "Card with a beam",
          "description": "Drop it inside any relative, rounded element.",
          "kind": "minimal",
          "code": "import { BorderBeam } from \"@kura-ui/react/border-beam\";\n\nexport function BorderBeamMinimal() {\n  return (\n    <div className=\"relative w-full max-w-sm rounded-kura-xl bg-kura-surface p-5 text-sm shadow-kura-sm\">\n      <p className=\"font-medium text-kura-fg\">Indexing workspace</p>\n      <p className=\"text-kura-fg-muted\">The beam marks a container the agent is working in.</p>\n      <BorderBeam />\n    </div>\n  );\n}\n"
        },
        {
          "name": "BorderBeamFull",
          "title": "Composer with two beams",
          "description": "Glow, width, size, speed, a second opposite beam via offset, and toggling active.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { BorderBeam } from \"@kura-ui/react/border-beam\";\n\nexport function BorderBeamFull() {\n  const [active, setActive] = React.useState(true);\n  const [glow, setGlow] = React.useState(true);\n  const [fast, setFast] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-md flex-col items-center gap-5\">\n      <div className=\"relative flex h-40 w-full flex-col justify-between rounded-kura-2xl bg-kura-surface p-4 shadow-kura-sm\">\n        <p className=\"text-kura-fg-muted text-sm\">Ask the agent to change something…</p>\n        <div className=\"flex items-center justify-between text-kura-fg-subtle text-xs\">\n          <span>{active ? \"Agent is editing 3 files\" : \"Idle\"}</span>\n          <span className=\"font-kura-mono\">⌘↵</span>\n        </div>\n        <BorderBeam active={active} glow={glow} duration={fast ? 3 : 7} size={0.3} width={1.5} />\n        <BorderBeam active={active} duration={fast ? 3 : 7} size={0.18} offset={0.5} />\n      </div>\n      <div className=\"flex flex-wrap justify-center gap-1.5\">\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          aria-pressed={active}\n          onClick={() => setActive((v) => !v)}\n        >\n          {active ? \"Stop\" : \"Start\"}\n        </Button>\n        <Button size=\"sm\" variant=\"ghost\" aria-pressed={glow} onClick={() => setGlow((v) => !v)}>\n          Glow {glow ? \"on\" : \"off\"}\n        </Button>\n        <Button size=\"sm\" variant=\"ghost\" aria-pressed={fast} onClick={() => setFast((v) => !v)}>\n          {fast ? \"3s lap\" : \"7s lap\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "BorderBeamAgentTask",
          "title": "Running task row",
          "description": "ShimmerText status, a Stop Button and a beam that fades out when stopped.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { FileCode2, Square } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\nimport { BorderBeam } from \"@kura-ui/react/border-beam\";\n\nexport function BorderBeamAgentTask() {\n  const [running, setRunning] = React.useState(true);\n  return (\n    <div className=\"relative flex w-full max-w-md items-center gap-3 rounded-kura-lg bg-kura-surface p-3 shadow-kura-sm\">\n      <span className=\"flex size-9 shrink-0 items-center justify-center rounded-kura-md bg-kura-accent-subtle text-kura-accent-text\">\n        <FileCode2 className=\"size-4\" />\n      </span>\n      <div className=\"min-w-0 flex-1 text-sm\" role=\"status\" aria-live=\"polite\">\n        <p className=\"truncate font-medium text-kura-fg\">auth/session.ts</p>\n        {running ? (\n          <ShimmerText className=\"text-xs\">Refactoring token refresh…</ShimmerText>\n        ) : (\n          <span className=\"text-kura-fg-muted text-xs\">Stopped by you</span>\n        )}\n      </div>\n      <Button size=\"sm\" variant=\"secondary\" onClick={() => setRunning((r) => !r)}>\n        {running ? <Square className=\"size-3 fill-current\" /> : null}\n        {running ? \"Stop\" : \"Resume\"}\n      </Button>\n      <BorderBeam active={running} glow duration={5} />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "The parent must be `relative` (or otherwise positioned) and should carry the border radius; the beam uses `rounded-[inherit]`.",
        "Render it as the last child so it paints above the content edge; it does not take space.",
        "Pure CSS: no 'use client' needed. Styles are injected once via a React 19 hoisted <style>.",
        "Keep it mounted and flip `active` instead of conditionally rendering, so it fades rather than pops.",
        "On wide, short elements the beam speeds up along the short sides (it is an angle sweep); prefer size 0.2–0.35 there."
      ],
      "title": "Border Beam",
      "importPath": "@kura-ui/react/border-beam",
      "exports": [
        "BorderBeam",
        "BorderBeamProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/border-beam",
      "markdownUrl": "https://kuro.abstergo.fyi/components/border-beam.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/border-beam.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/border-beam.json",
        "cli": "npx @kura-ui/cli add border-beam",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "active": {
            "description": "Show the beam. Toggling fades it in/out over 480ms and pauses the animation while hidden — keep it mounted.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "duration": {
            "description": "Seconds per lap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 6
          },
          "size": {
            "description": "Beam length as a share of the lap, 0.05–0.9.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.25
          },
          "width": {
            "description": "Ring thickness in px.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "colors": {
            "description": "CSS colours from tail to head. Default: transparent → jade at 45% → jade → accent-text. Tokens and color-mix() work.",
            "x-ts-type": "readonly string[]",
            "type": "array"
          },
          "glow": {
            "description": "Add a blurred copy of the beam for a soft glow (not clipped by the parent unless it has overflow-hidden).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "reverse": {
            "description": "Travel counter-clockwise.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "offset": {
            "description": "Starting position as a share of the lap, 0–1. Use 0.5 on a second beam to run two beams opposite each other.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/border-beam/border-beam.tsx",
          "type": "registry:ui",
          "content": "import type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/*\n * The beam is one conic gradient whose start angle is a registered custom property, so the\n * browser can interpolate it in a keyframe. A content-box/border-box mask cuts the gradient down\n * to a ring exactly `width` px thick that inherits the parent's border radius.\n */\nconst CSS = `\n@property --kura-beam-angle { syntax: \"<angle>\"; inherits: false; initial-value: 0deg; }\n@keyframes kura-beam-spin { to { --kura-beam-angle: 360deg; } }\n[data-slot=\"border-beam-ring\"] {\n  padding: var(--kura-beam-width);\n  background: conic-gradient(from var(--kura-beam-angle), var(--kura-beam-stops));\n  -webkit-mask: linear-gradient(black, black) content-box, linear-gradient(black, black);\n  -webkit-mask-composite: xor;\n  mask: linear-gradient(black, black) content-box exclude, linear-gradient(black, black);\n  animation: kura-beam-spin var(--kura-beam-duration) linear infinite;\n  animation-delay: var(--kura-beam-delay);\n  animation-direction: var(--kura-beam-direction);\n  animation-play-state: var(--kura-beam-play);\n}\n@media (prefers-reduced-motion: reduce) {\n  [data-slot=\"border-beam-ring\"] {\n    animation: none;\n    background: var(--kura-beam-static);\n  }\n}\n`;\n\nexport interface BorderBeamProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Show the beam. Toggling fades it in/out (and pauses the animation when hidden). Default true. */\n  active?: boolean;\n  /** Seconds per lap. Default 6. */\n  duration?: number;\n  /** Beam length as a share of the lap, 0.05–0.9. Default 0.25. */\n  size?: number;\n  /** Ring thickness in px. Default 1. */\n  width?: number;\n  /** CSS colours from tail to head, e.g. `[\"transparent\", \"var(--kura-accent)\"]`. Default: a jade tail fading in to a bright head. */\n  colors?: readonly string[];\n  /** Add a soft blurred glow around the beam. Default false. */\n  glow?: boolean;\n  /** Travel counter-clockwise. Default false. */\n  reverse?: boolean;\n  /** Start position as a share of the lap, 0–1. Use 0.5 on a second beam to run them opposite. Default 0. */\n  offset?: number;\n}\n\nconst DEFAULT_COLORS = [\n  \"transparent\",\n  \"color-mix(in oklab, var(--kura-accent) 45%, transparent)\",\n  \"var(--kura-accent)\",\n  \"var(--kura-accent-text)\",\n];\n\nfunction stops(colors: readonly string[], size: number): string {\n  const len = Math.min(0.9, Math.max(0.05, size)) * 360;\n  const start = 360 - len;\n  const head = 356;\n  const list = colors.length > 1 ? colors : [\"transparent\", colors[0] ?? \"var(--kura-accent)\"];\n  const step = (head - start) / (list.length - 1);\n  const parts = list.map((c, i) => `${c} ${(start + i * step).toFixed(2)}deg`);\n  return `transparent 0deg, transparent ${start.toFixed(2)}deg, ${parts.join(\", \")}, transparent 360deg`;\n}\n\n/**\n * An animated light beam that travels around its parent's border — the \"an agent is working in\n * here\" signal for composers, cards and panels. Place it inside a `relative` element with a\n * border radius; it inherits the radius and never affects layout. Pure CSS (server-safe).\n */\nexport function BorderBeam({\n  active = true,\n  duration = 6,\n  size = 0.25,\n  width = 1,\n  colors = DEFAULT_COLORS,\n  glow = false,\n  reverse = false,\n  offset = 0,\n  className,\n  style,\n  ...props\n}: BorderBeamProps) {\n  const vars = {\n    \"--kura-beam-duration\": `${duration}s`,\n    \"--kura-beam-width\": `${width}px`,\n    \"--kura-beam-stops\": stops(colors, size),\n    \"--kura-beam-delay\": `${-Math.min(1, Math.max(0, offset)) * duration}s`,\n    \"--kura-beam-direction\": reverse ? \"reverse\" : \"normal\",\n    \"--kura-beam-play\": active ? \"running\" : \"paused\",\n    \"--kura-beam-static\": `color-mix(in oklab, ${colors[colors.length - 1] ?? \"var(--kura-accent)\"} 45%, transparent)`,\n  } as React.CSSProperties;\n  return (\n    <div\n      aria-hidden\n      data-slot=\"border-beam\"\n      data-active={active}\n      className={cn(\n        \"pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-(--kura-duration-slower) ease-kura-out\",\n        active ? \"opacity-100\" : \"opacity-0\",\n        className,\n      )}\n      style={{ ...vars, ...style }}\n      {...props}\n    >\n      <style href=\"kura-border-beam\" precedence=\"kura\">\n        {CSS}\n      </style>\n      {glow && (\n        <div\n          data-slot=\"border-beam-glow\"\n          className=\"absolute inset-0 rounded-[inherit] opacity-55 blur-[5px] motion-reduce:opacity-30\"\n        >\n          <div\n            data-slot=\"border-beam-ring\"\n            className=\"absolute inset-0 rounded-[inherit]\"\n            style={{ \"--kura-beam-width\": `${width + 2}px` } as React.CSSProperties}\n          />\n        </div>\n      )}\n      <div data-slot=\"border-beam-ring\" className=\"absolute inset-0 rounded-[inherit]\" />\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/border-beam/index.ts",
          "type": "registry:ui",
          "content": "export { BorderBeam, type BorderBeamProps } from \"./border-beam\";\n"
        }
      ]
    },
    {
      "slug": "button",
      "name": "Button",
      "category": "primitives",
      "status": "stable",
      "description": "The Kura button: six variants, four sizes, loading state, asChild, tactile press and a signal focus ring.",
      "whenToUse": [
        "Any clickable action: submit, confirm, cancel, open.",
        "Links that should look like buttons (use asChild with your router's Link)."
      ],
      "whenNotToUse": [
        "Icon-only actions — use icon-button (it enforces an accessible label and adds a tooltip).",
        "Approve/deny decisions for agent actions — use approval-prompt."
      ],
      "props": [
        {
          "name": "variant",
          "type": "\"primary\" | \"secondary\" | \"ghost\" | \"outline\" | \"accent\" | \"danger\"",
          "default": "\"primary\"",
          "description": "primary = ink call to action; accent = the AI signal color (max one per view); danger = destructive."
        },
        {
          "name": "size",
          "type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
          "default": "\"md\"",
          "description": "Height 24 / 28 / 32 / 40px."
        },
        {
          "name": "loading",
          "type": "boolean",
          "default": "false",
          "description": "Shows a spinner, sets aria-busy and disables the button while keeping its width."
        },
        {
          "name": "asChild",
          "type": "boolean",
          "default": "false",
          "description": "Render the single child element (e.g. <a> or <Link>) with button styles."
        }
      ],
      "parts": [
        {
          "name": "buttonVariants",
          "description": "Function returning the class string, for non-button elements."
        }
      ],
      "a11y": {
        "notes": [
          "Defaults to type=\"button\" so it never submits forms by accident; pass type=\"submit\" explicitly.",
          "Disabled and loading buttons use the native disabled attribute.",
          "Focus ring: 1px canvas gap + 2px signal ring, only on :focus-visible."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Activate"
          },
          {
            "keys": "Tab",
            "action": "Move focus"
          }
        ],
        "reducedMotion": "The 0.97 press scale is disabled; color transitions remain."
      },
      "dependencies": {
        "npm": [
          "radix-ui"
        ],
        "kura": [
          "spinner"
        ]
      },
      "related": [
        "icon-button",
        "kbd-shortcut",
        "tooltip"
      ],
      "tags": [
        "button",
        "action",
        "cta",
        "submit",
        "link",
        "loading"
      ],
      "examples": [
        {
          "name": "ButtonMinimal",
          "title": "Primary",
          "kind": "minimal",
          "code": "import { Button } from \"@kura-ui/react/button\";\n\nexport function ButtonMinimal() {\n  return <Button>Continue</Button>;\n}\n"
        },
        {
          "name": "ButtonFull",
          "title": "Variants, sizes, loading",
          "kind": "full",
          "code": "import { Sparkles, Trash2 } from \"lucide-react\";\nimport { Button } from \"@kura-ui/react/button\";\n\nexport function ButtonFull() {\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <Button variant=\"primary\">Primary</Button>\n        <Button variant=\"secondary\">Secondary</Button>\n        <Button variant=\"outline\">Outline</Button>\n        <Button variant=\"ghost\">Ghost</Button>\n        <Button variant=\"accent\">\n          <Sparkles /> Run agent\n        </Button>\n        <Button variant=\"danger\">\n          <Trash2 /> Delete\n        </Button>\n      </div>\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <Button size=\"xs\" variant=\"secondary\">\n          XS\n        </Button>\n        <Button size=\"sm\" variant=\"secondary\">\n          Small\n        </Button>\n        <Button size=\"md\" variant=\"secondary\">\n          Medium\n        </Button>\n        <Button size=\"lg\" variant=\"secondary\">\n          Large\n        </Button>\n        <Button loading>Saving</Button>\n        <Button disabled variant=\"secondary\">\n          Disabled\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ButtonAsLink",
          "title": "As a link (asChild)",
          "kind": "composition",
          "code": "import { ArrowUpRight } from \"lucide-react\";\nimport { Button } from \"@kura-ui/react/button\";\n\nexport function ButtonAsLink() {\n  return (\n    <Button asChild variant=\"outline\">\n      <a href=\"https://github.com/Absterrg0/Kura-UI\">\n        View source <ArrowUpRight />\n      </a>\n    </Button>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Use `accent` only for the single most AI-specific action in view (e.g. “Run agent”). Default to `primary`.",
        "Icons inside are auto-sized; do not add size classes unless you need a non-default size."
      ],
      "title": "Button",
      "importPath": "@kura-ui/react/button",
      "exports": [
        "Button",
        "ButtonProps",
        "ButtonSize",
        "ButtonVariant",
        "buttonVariants"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/button",
      "markdownUrl": "https://kuro.abstergo.fyi/components/button.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/button.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/button.json",
        "cli": "npx @kura-ui/cli add button",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "spinner",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "variant": {
            "description": "primary = ink call to action; accent = the AI signal color (max one per view); danger = destructive.",
            "x-ts-type": "\"primary\" | \"secondary\" | \"ghost\" | \"outline\" | \"accent\" | \"danger\"",
            "type": "string",
            "enum": [
              "accent",
              "danger",
              "ghost",
              "outline",
              "primary",
              "secondary"
            ],
            "default": "primary"
          },
          "size": {
            "description": "Height 24 / 28 / 32 / 40px.",
            "x-ts-type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
            "type": "string",
            "enum": [
              "lg",
              "md",
              "sm",
              "xs"
            ],
            "default": "md"
          },
          "loading": {
            "description": "Shows a spinner, sets aria-busy and disables the button while keeping its width.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "asChild": {
            "description": "Render the single child element (e.g. <a> or <Link>) with button styles.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/button/button.tsx",
          "type": "registry:ui",
          "content": "import { Slot } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Spinner } from \"@/components/kura/spinner\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\" | \"accent\" | \"danger\";\nexport type ButtonSize = \"xs\" | \"sm\" | \"md\" | \"lg\";\n\nexport interface ButtonProps extends React.ComponentProps<\"button\"> {\n  /** `primary` = ink (default call to action); `accent` = the AI signal colour, use at most once per view. */\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  /** Shows a spinner, sets `aria-busy` and disables interaction while keeping the width stable. */\n  loading?: boolean;\n  /** Render the child element instead of a <button> (e.g. a Next.js <Link>). */\n  asChild?: boolean;\n}\n\nconst base =\n  \"relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-1.5 whitespace-nowrap font-medium font-kura-sans outline-none \" +\n  \"transition-[background-color,color,box-shadow,transform,opacity] duration-(--kura-duration-fast) ease-kura-out \" +\n  \"focus-visible:shadow-kura-focus active:not-disabled:scale-[0.97] motion-reduce:active:not-disabled:scale-100 \" +\n  \"disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress \" +\n  \"[&_svg]:pointer-events-none [&_svg]:shrink-0\";\n\nconst variants: Record<ButtonVariant, string> = {\n  primary: \"bg-kura-ink text-kura-fg-inverted shadow-kura-xs hover:not-disabled:bg-kura-ink-hover\",\n  secondary:\n    \"bg-kura-surface text-kura-fg shadow-kura-sm hover:not-disabled:bg-kura-surface-sunken\",\n  outline:\n    \"bg-transparent text-kura-fg shadow-[inset_0_0_0_1px_var(--kura-border-strong)] hover:not-disabled:bg-kura-hover\",\n  ghost:\n    \"bg-transparent text-kura-fg-muted hover:not-disabled:bg-kura-hover hover:not-disabled:text-kura-fg\",\n  accent:\n    \"bg-kura-accent text-kura-accent-fg shadow-kura-xs hover:not-disabled:bg-kura-accent-hover\",\n  danger: \"bg-kura-danger text-white shadow-kura-xs hover:not-disabled:opacity-90\",\n};\n\nconst sizes: Record<ButtonSize, string> = {\n  xs: \"h-6 rounded-kura-sm px-2 text-xs [&_svg:not([class*='size-'])]:size-3\",\n  sm: \"h-7 rounded-kura-sm px-2.5 text-[13px] [&_svg:not([class*='size-'])]:size-3.5\",\n  md: \"h-8 rounded-kura-md px-3 text-sm [&_svg:not([class*='size-'])]:size-4\",\n  lg: \"h-10 rounded-kura-lg px-4 text-[15px] [&_svg:not([class*='size-'])]:size-4\",\n};\n\n/** Class string for a button look, for elements that cannot use <Button> directly. */\nexport function buttonVariants({\n  variant = \"primary\",\n  size = \"md\",\n  className,\n}: {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  className?: string;\n} = {}): string {\n  return cn(base, variants[variant], sizes[size], className);\n}\n\n/**\n * The Kura button. Press feedback is a 0.97 scale on :active (disabled under reduced motion),\n * focus is a 2px signal ring outside a 1px canvas gap.\n */\nexport function Button({\n  variant = \"primary\",\n  size = \"md\",\n  loading = false,\n  asChild = false,\n  disabled,\n  className,\n  children,\n  type,\n  ...props\n}: ButtonProps) {\n  const Comp = asChild ? Slot.Root : \"button\";\n  return (\n    <Comp\n      data-slot=\"button\"\n      data-variant={variant}\n      data-size={size}\n      type={asChild ? type : (type ?? \"button\")}\n      disabled={disabled || loading}\n      aria-busy={loading || undefined}\n      className={buttonVariants({ variant, size, className })}\n      {...props}\n    >\n      {asChild ? (\n        children\n      ) : (\n        <>\n          {loading && (\n            <span className=\"absolute inset-0 flex items-center justify-center\">\n              <Spinner size={size === \"lg\" ? \"md\" : \"sm\"} label={null} />\n            </span>\n          )}\n          {loading ? (\n            <span className=\"invisible inline-flex items-center gap-1.5\">{children}</span>\n          ) : (\n            children\n          )}\n        </>\n      )}\n    </Comp>\n  );\n}\n"
        },
        {
          "target": "components/kura/button/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Button,\n  type ButtonProps,\n  type ButtonSize,\n  type ButtonVariant,\n  buttonVariants,\n} from \"./button\";\n"
        }
      ]
    },
    {
      "slug": "citation",
      "name": "Citation",
      "category": "conversation",
      "status": "beta",
      "description": "Inline numbered citation pill that links to its source and previews favicon, title, domain and snippet on hover/focus, plus a Sources strip or list.",
      "whenToUse": [
        "Attribute sentences of a model's answer to retrieved sources (RAG, web search, file search) with inline [1] [2] pills.",
        "Preview a source without leaving the answer: hover or keyboard-focus the pill to see title, domain and the snippet used.",
        "List all sources behind an answer under it, as a compact chip strip or a numbered list (`Sources`)."
      ],
      "whenNotToUse": [
        "Plain links in prose with no source metadata — use an <a>.",
        "Footnotes in long-form documents that must print — use real footnotes.",
        "Showing the search tool call itself (query, results JSON) — use tool-call."
      ],
      "props": [
        {
          "name": "index",
          "type": "number",
          "required": true,
          "description": "1-based citation number shown in the pill and the card corner."
        },
        {
          "name": "source",
          "type": "CitationSource",
          "required": true,
          "description": "{ title, url, snippet?, favicon?, siteName?, date? }. The domain is derived from `url`; without `favicon` a monogram tile is shown (no third-party favicon service is called)."
        },
        {
          "name": "display",
          "type": "\"number\" | \"domain\"",
          "default": "\"number\"",
          "description": "Pill shows the number, or a favicon + site name (Perplexity style)."
        },
        {
          "name": "openDelay",
          "type": "number",
          "default": "250",
          "description": "Hover delay in ms before the preview opens (keyboard focus opens immediately)."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Preview open (controlled)."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the preview opens or closes."
        }
      ],
      "parts": [
        {
          "name": "Citation",
          "description": "The pill: a real <a target=\"_blank\"> to the source, `data-state=\"open\" | \"closed\"`. Sized in em so it scales with the surrounding text.",
          "dataSlot": "citation"
        },
        {
          "name": "Preview",
          "description": "The hover card (Kura Popover content, origin-aware pop-in, opens above the pill).",
          "dataSlot": "citation-preview"
        },
        {
          "name": "CitationCard",
          "description": "Preview body: favicon, site · date, index, title link with an ↗ nudge, 3-line snippet. Reusable on its own.",
          "dataSlot": "citation-card",
          "props": [
            {
              "name": "source",
              "type": "CitationSource",
              "required": true,
              "description": "Source to show."
            },
            {
              "name": "index",
              "type": "number",
              "description": "Number shown in the corner."
            }
          ]
        },
        {
          "name": "Favicon",
          "description": "16px favicon or monogram tile (aria-hidden).",
          "dataSlot": "citation-favicon"
        },
        {
          "name": "Sources",
          "description": "Sources behind an answer. `data-variant`.",
          "dataSlot": "citation-sources",
          "props": [
            {
              "name": "sources",
              "type": "readonly CitationSource[]",
              "required": true,
              "description": "In citation order (index = position + 1)."
            },
            {
              "name": "variant",
              "type": "\"strip\" | \"list\"",
              "default": "\"strip\"",
              "description": "strip = horizontally scrolling chips; list = numbered rows with snippets."
            },
            {
              "name": "label",
              "type": "React.ReactNode",
              "default": "\"Sources\"",
              "description": "Heading (with a count). null hides it."
            }
          ]
        },
        {
          "name": "Source",
          "description": "One <li> in Sources.",
          "dataSlot": "citation-source"
        }
      ],
      "a11y": {
        "notes": [
          "Each pill is a link named “Source 2: <title> (<domain>)”, so it makes sense out of context and in a links list.",
          "The preview opens on hover (with delay) and on keyboard focus, without moving focus; Escape or blur closes it. It never contains information that is not also reachable by following the link.",
          "Sources is an ordered list labelled by its heading.",
          "Links open in a new tab (`target=\"_blank\" rel=\"noreferrer\"`)."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Focus a pill — its preview opens"
          },
          {
            "keys": "Enter",
            "action": "Open the source in a new tab"
          },
          {
            "keys": "Escape",
            "action": "Close the preview"
          }
        ],
        "reducedMotion": "The preview only fades (no scale/translate) and the ↗ nudge on hover is removed."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "popover"
        ]
      },
      "related": [
        "message",
        "streaming-text",
        "tool-call"
      ],
      "tags": [
        "citation",
        "sources",
        "rag",
        "references",
        "footnote",
        "hover-card",
        "search",
        "attribution"
      ],
      "examples": [
        {
          "name": "CitationMinimal",
          "title": "One citation",
          "description": "A numbered pill at the end of a sentence.",
          "kind": "minimal",
          "code": "import { Citation, type CitationSource } from \"@kura-ui/react/citation\";\n\nconst SOURCES: CitationSource[] = [\n  {\n    title: \"useOptimistic – React\",\n    url: \"https://react.dev/reference/react/useOptimistic\",\n    siteName: \"React\",\n    snippet:\n      \"useOptimistic is a React Hook that lets you optimistically update the UI while an async action is underway.\",\n  },\n  {\n    title: \"Server Actions and Mutations\",\n    url: \"https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations\",\n    siteName: \"Next.js\",\n    date: \"Jun 2026\",\n    snippet:\n      \"Server Actions are asynchronous functions that are executed on the server. They can be called in Server and Client Components to handle form submissions and data mutations.\",\n  },\n  {\n    title: \"Optimistic UI patterns, revisited\",\n    url: \"https://www.example.com/blog/optimistic-ui\",\n    snippet:\n      \"The trick is not the optimism itself but the rollback: show the failure where the user is looking, and restore the previous value without a jump.\",\n  },\n];\n\nexport function CitationMinimal() {\n  return (\n    <p className=\"max-w-md text-[15px] text-kura-fg leading-relaxed\">\n      React exposes a dedicated hook for optimistic updates\n      <Citation index={1} source={SOURCES[0]!} />.\n    </p>\n  );\n}\n"
        },
        {
          "name": "CitationFull",
          "title": "Citations with a sources strip",
          "description": "Number and domain pills, a custom open delay, and the Sources strip below.",
          "kind": "full",
          "code": "import { Citation, type CitationSource, Sources } from \"@kura-ui/react/citation\";\n\nconst SOURCES: CitationSource[] = [\n  {\n    title: \"useOptimistic – React\",\n    url: \"https://react.dev/reference/react/useOptimistic\",\n    siteName: \"React\",\n    snippet:\n      \"useOptimistic is a React Hook that lets you optimistically update the UI while an async action is underway.\",\n  },\n  {\n    title: \"Server Actions and Mutations\",\n    url: \"https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations\",\n    siteName: \"Next.js\",\n    date: \"Jun 2026\",\n    snippet:\n      \"Server Actions are asynchronous functions that are executed on the server. They can be called in Server and Client Components to handle form submissions and data mutations.\",\n  },\n  {\n    title: \"Optimistic UI patterns, revisited\",\n    url: \"https://www.example.com/blog/optimistic-ui\",\n    snippet:\n      \"The trick is not the optimism itself but the rollback: show the failure where the user is looking, and restore the previous value without a jump.\",\n  },\n];\n\nexport function CitationFull() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-5\">\n      <p className=\"text-[15px] text-kura-fg leading-relaxed\">\n        Render the new count immediately with{\" \"}\n        <code className=\"font-kura-mono text-sm\">useOptimistic</code>\n        <Citation index={1} source={SOURCES[0]!} openDelay={150} />, run the mutation in a Server\n        Action\n        <Citation index={2} source={SOURCES[1]!} display=\"domain\" />, and restore the previous value\n        quietly if it fails\n        <Citation index={3} source={SOURCES[2]!} />.\n      </p>\n      <Sources sources={SOURCES} variant=\"strip\" />\n    </div>\n  );\n}\n"
        },
        {
          "name": "CitationSourcesList",
          "title": "Sources list",
          "description": "Numbered rows with site, date and snippet.",
          "kind": "variant",
          "code": "import { type CitationSource, Sources } from \"@kura-ui/react/citation\";\n\nconst SOURCES: CitationSource[] = [\n  {\n    title: \"useOptimistic – React\",\n    url: \"https://react.dev/reference/react/useOptimistic\",\n    siteName: \"React\",\n    snippet:\n      \"useOptimistic is a React Hook that lets you optimistically update the UI while an async action is underway.\",\n  },\n  {\n    title: \"Server Actions and Mutations\",\n    url: \"https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations\",\n    siteName: \"Next.js\",\n    date: \"Jun 2026\",\n    snippet:\n      \"Server Actions are asynchronous functions that are executed on the server. They can be called in Server and Client Components to handle form submissions and data mutations.\",\n  },\n  {\n    title: \"Optimistic UI patterns, revisited\",\n    url: \"https://www.example.com/blog/optimistic-ui\",\n    snippet:\n      \"The trick is not the optimism itself but the rollback: show the failure where the user is looking, and restore the previous value without a jump.\",\n  },\n];\n\nexport function CitationSourcesList() {\n  return (\n    <Sources sources={SOURCES} variant=\"list\" label=\"Further reading\" className=\"w-full max-w-lg\" />\n  );\n}\n"
        },
        {
          "name": "CitationInMessage",
          "title": "In an assistant message",
          "description": "Pills inside a Message with a label-less strip.",
          "kind": "composition",
          "code": "import { Message, MessageAvatar } from \"@kura-ui/react/message\";\nimport { Citation, type CitationSource, Sources } from \"@kura-ui/react/citation\";\n\nconst SOURCES: CitationSource[] = [\n  {\n    title: \"useOptimistic – React\",\n    url: \"https://react.dev/reference/react/useOptimistic\",\n    siteName: \"React\",\n    snippet:\n      \"useOptimistic is a React Hook that lets you optimistically update the UI while an async action is underway.\",\n  },\n  {\n    title: \"Server Actions and Mutations\",\n    url: \"https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations\",\n    siteName: \"Next.js\",\n    date: \"Jun 2026\",\n    snippet:\n      \"Server Actions are asynchronous functions that are executed on the server. They can be called in Server and Client Components to handle form submissions and data mutations.\",\n  },\n  {\n    title: \"Optimistic UI patterns, revisited\",\n    url: \"https://www.example.com/blog/optimistic-ui\",\n    snippet:\n      \"The trick is not the optimism itself but the rollback: show the failure where the user is looking, and restore the previous value without a jump.\",\n  },\n];\n\nexport function CitationInMessage() {\n  return (\n    <Message\n      from=\"assistant\"\n      name=\"Kura\"\n      avatar={<MessageAvatar name=\"Kura\" />}\n      className=\"max-w-xl\"\n    >\n      <p>\n        Yes — React 19 ships <code>useOptimistic</code> for exactly this\n        <Citation index={1} source={SOURCES[0]!} />. Pair it with a Server Action so the request and\n        the rollback live in one place\n        <Citation index={2} source={SOURCES[1]!} />.\n      </p>\n      <Sources sources={SOURCES.slice(0, 2)} label={null} />\n    </Message>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Place the pill directly after the punctuation-free end of the claim, inside the same text node's parent (it is inline and em-sized).",
        "Reuse the same index for repeated citations of the same source; Sources numbering is by array position.",
        "Pass favicons you already have (e.g. from your search API); the component never fetches favicons itself.",
        "Import Sources from the same entry: `import { Citation, Sources } from \"@kura-ui/react/citation\"`."
      ],
      "title": "Citation",
      "importPath": "@kura-ui/react/citation",
      "exports": [
        "Citation",
        "CitationCard",
        "CitationCardProps",
        "CitationProps",
        "CitationSource",
        "Sources",
        "SourcesProps",
        "sourceDomain"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/citation",
      "markdownUrl": "https://kuro.abstergo.fyi/components/citation.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/citation.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/citation.json",
        "cli": "npx @kura-ui/cli add citation",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "popover",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "index": {
            "description": "1-based citation number shown in the pill and the card corner.",
            "x-ts-type": "number",
            "type": "number"
          },
          "source": {
            "description": "{ title, url, snippet?, favicon?, siteName?, date? }. The domain is derived from `url`; without `favicon` a monogram tile is shown (no third-party favicon service is called).",
            "x-ts-type": "CitationSource"
          },
          "display": {
            "description": "Pill shows the number, or a favicon + site name (Perplexity style).",
            "x-ts-type": "\"number\" | \"domain\"",
            "type": "string",
            "enum": [
              "domain",
              "number"
            ],
            "default": "number"
          },
          "openDelay": {
            "description": "Hover delay in ms before the preview opens (keyboard focus opens immediately).",
            "x-ts-type": "number",
            "type": "number",
            "default": 250
          },
          "open": {
            "description": "Preview open (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onOpenChange": {
            "description": "Called when the preview opens or closes.",
            "x-ts-type": "(open: boolean) => void"
          }
        },
        "required": [
          "index",
          "source"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/citation/citation.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ArrowUpRight } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Popover, PopoverAnchor, PopoverContent } from \"@/components/kura/popover\";\n\nexport interface CitationSource {\n  /** Page title. */\n  title: string;\n  /** Canonical URL; the domain is derived from it. */\n  url: string;\n  /** Short excerpt the model relied on. */\n  snippet?: string;\n  /** Favicon URL. Without one, a monogram tile is shown (no third-party favicon service is called). */\n  favicon?: string;\n  /** Publisher name shown instead of the domain, e.g. \"React Docs\". */\n  siteName?: string;\n  /** Publication date as display text, e.g. \"Mar 2025\". */\n  date?: string;\n}\n\nfunction isFocusVisible(el: Element): boolean {\n  try {\n    return el.matches(\":focus-visible\");\n  } catch {\n    return true;\n  }\n}\n\nexport function sourceDomain(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nfunction Favicon({ source, className }: { source: CitationSource; className?: string }) {\n  const [broken, setBroken] = React.useState(false);\n  const domain = sourceDomain(source.url);\n  return (\n    <span\n      aria-hidden\n      data-slot=\"citation-favicon\"\n      className={cn(\n        \"grid size-4 shrink-0 place-items-center overflow-hidden rounded-kura-xs bg-kura-surface-raised font-semibold text-[9px] text-kura-fg-muted uppercase shadow-[0_0_0_1px_var(--kura-border-subtle)]\",\n        className,\n      )}\n    >\n      {source.favicon && !broken ? (\n        <img\n          src={source.favicon}\n          alt=\"\"\n          className=\"size-full object-cover\"\n          onError={() => setBroken(true)}\n        />\n      ) : (\n        (source.siteName ?? domain).charAt(0)\n      )}\n    </span>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Card\n// ------------------------------------------------------------------------------------------------\n\nexport interface CitationCardProps extends React.ComponentProps<\"div\"> {\n  /** The source to preview. */\n  source: CitationSource;\n  /** Citation number shown in the corner. */\n  index?: number;\n}\n\n/** The preview body: favicon, site, title (a link), snippet. Used in the hover card; reusable in sidebars. */\nexport function CitationCard({ source, index, className, ...props }: CitationCardProps) {\n  const domain = sourceDomain(source.url);\n  return (\n    <div\n      data-slot=\"citation-card\"\n      className={cn(\"flex flex-col gap-1.5 font-kura-sans\", className)}\n      {...props}\n    >\n      <div className=\"flex items-center gap-2 text-kura-fg-subtle text-xs\">\n        <Favicon source={source} />\n        <span className=\"min-w-0 flex-1 truncate\">\n          {source.siteName ?? domain}\n          {source.date && <span className=\"text-kura-fg-subtle\"> · {source.date}</span>}\n        </span>\n        {index != null && <span className=\"font-kura-mono tabular-nums\">{index}</span>}\n      </div>\n      <a\n        href={source.url}\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"group/title -mx-1 line-clamp-2 rounded-kura-xs px-1 font-medium text-kura-fg text-sm leading-snug outline-none hover:underline hover:decoration-kura-border-strong hover:underline-offset-2 focus-visible:shadow-kura-focus\"\n      >\n        {source.title}\n        <ArrowUpRight\n          aria-hidden\n          className=\"ml-0.5 inline size-3.5 align-[-2px] text-kura-fg-subtle transition-transform duration-(--kura-duration-fast) ease-kura-out group-hover/title:translate-x-px group-hover/title:-translate-y-px motion-reduce:transition-none\"\n        />\n      </a>\n      {source.snippet && (\n        <p className=\"line-clamp-3 text-[13px] text-kura-fg-muted leading-relaxed\">\n          {source.snippet}\n        </p>\n      )}\n    </div>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Inline citation\n// ------------------------------------------------------------------------------------------------\n\nexport interface CitationProps extends Omit<React.ComponentProps<\"a\">, \"href\" | \"children\"> {\n  /** 1-based citation number shown in the pill. */\n  index: number;\n  /** The cited source. */\n  source: CitationSource;\n  /** Pill label: the number (default) or the source's domain/site name. */\n  display?: \"number\" | \"domain\";\n  /** Hover delay before the preview opens, in ms. Default 250. */\n  openDelay?: number;\n  /** Preview open (controlled). */\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n/**\n * Inline numbered citation pill. It is a real link to the source; hovering or keyboard-focusing\n * it opens a preview card with favicon, title, domain and snippet.\n */\nexport function Citation({\n  index,\n  source,\n  display = \"number\",\n  openDelay = 250,\n  open: openProp,\n  onOpenChange,\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: CitationProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: false,\n    onChange: onOpenChange,\n  });\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const schedule = (next: boolean, delay: number) => {\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setOpen(next), delay);\n  };\n  const domain = sourceDomain(source.url);\n  const label = display === \"domain\" ? (source.siteName ?? domain) : String(index);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverAnchor asChild>\n        <a\n          href={source.url}\n          target=\"_blank\"\n          rel=\"noreferrer\"\n          data-slot=\"citation\"\n          data-state={open ? \"open\" : \"closed\"}\n          aria-label={`Source ${index}: ${source.title} (${domain})`}\n          onPointerEnter={(e) => {\n            onPointerEnter?.(e);\n            if (e.pointerType !== \"touch\") schedule(true, openDelay);\n          }}\n          onPointerLeave={(e) => {\n            onPointerLeave?.(e);\n            schedule(false, 150);\n          }}\n          onFocus={(e) => {\n            onFocus?.(e);\n            if (isFocusVisible(e.currentTarget)) schedule(true, 0);\n          }}\n          onBlur={(e) => {\n            onBlur?.(e);\n            schedule(false, 100);\n          }}\n          className={cn(\n            \"relative -top-[0.1em] mr-px ml-1 inline-flex h-[1.3em] min-w-[1.3em] items-center justify-center gap-1 rounded-full bg-kura-hover px-[0.4em] align-middle font-kura-sans font-medium text-[0.72em] text-kura-fg-muted tabular-nums leading-none no-underline outline-none\",\n            \"transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-active hover:text-kura-fg focus-visible:shadow-kura-focus data-[state=open]:bg-kura-active data-[state=open]:text-kura-fg\",\n            className,\n          )}\n          {...props}\n        >\n          {display === \"domain\" && (\n            <Favicon source={source} className=\"-ml-0.5 size-[1.15em] rounded-full\" />\n          )}\n          {label}\n        </a>\n      </PopoverAnchor>\n      <PopoverContent\n        side=\"top\"\n        align=\"start\"\n        sideOffset={6}\n        data-slot=\"citation-preview\"\n        className=\"w-80 p-3\"\n        onOpenAutoFocus={(e) => e.preventDefault()}\n        onCloseAutoFocus={(e) => e.preventDefault()}\n        onPointerEnter={() => clearTimeout(timer.current)}\n        onPointerLeave={() => schedule(false, 150)}\n      >\n        <CitationCard source={source} index={index} />\n      </PopoverContent>\n    </Popover>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Sources\n// ------------------------------------------------------------------------------------------------\n\nexport interface SourcesProps extends React.ComponentProps<\"div\"> {\n  /** Sources in citation order (index = position + 1). */\n  sources: readonly CitationSource[];\n  /** `strip` = a horizontally scrolling row of chips; `list` = a numbered list with titles and snippets. Default \"strip\". */\n  variant?: \"strip\" | \"list\";\n  /** Heading text. Default “Sources”. Pass null to hide it. */\n  label?: React.ReactNode;\n}\n\n/** The sources behind an answer, as a compact chip strip or a numbered list. */\nexport function Sources({\n  sources,\n  variant = \"strip\",\n  label = \"Sources\",\n  className,\n  ...props\n}: SourcesProps) {\n  const id = React.useId();\n  return (\n    <div\n      data-slot=\"citation-sources\"\n      data-variant={variant}\n      className={cn(\"flex min-w-0 flex-col gap-2 font-kura-sans\", className)}\n      {...props}\n    >\n      {label != null && (\n        <div id={id} className=\"flex items-center gap-2 font-medium text-kura-fg-muted text-xs\">\n          {label}\n          <span className=\"font-kura-mono text-kura-fg-subtle tabular-nums\">{sources.length}</span>\n        </div>\n      )}\n      <ol\n        aria-labelledby={label != null ? id : undefined}\n        aria-label={label == null ? \"Sources\" : undefined}\n        className={cn(\n          variant === \"strip\"\n            ? \"-mx-1 -my-1 flex snap-x gap-2 overflow-x-auto px-1 py-1 [mask-image:linear-gradient(to_right,black_calc(100%-2rem),transparent)] [scrollbar-width:none]\"\n            : \"flex flex-col gap-1\",\n        )}\n      >\n        {sources.map((source, i) => {\n          const domain = sourceDomain(source.url);\n          return (\n            <li key={source.url + i} data-slot=\"citation-source\" className=\"shrink-0 snap-start\">\n              {variant === \"strip\" ? (\n                <a\n                  href={source.url}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"flex w-52 flex-col gap-1.5 rounded-kura-lg bg-kura-surface p-2.5 shadow-kura-xs outline-none transition-[background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-surface-sunken focus-visible:shadow-kura-focus\"\n                >\n                  <span className=\"line-clamp-2 min-h-[2lh] text-[13px] text-kura-fg leading-snug\">\n                    {source.title}\n                  </span>\n                  <span className=\"flex items-center gap-1.5 text-kura-fg-subtle text-xs\">\n                    <Favicon source={source} className=\"size-3.5\" />\n                    <span className=\"min-w-0 flex-1 truncate\">{source.siteName ?? domain}</span>\n                    <span className=\"font-kura-mono tabular-nums\">{i + 1}</span>\n                  </span>\n                </a>\n              ) : (\n                <a\n                  href={source.url}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"-mx-2 flex gap-3 rounded-kura-md px-2 py-2 outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-kura-focus\"\n                >\n                  <span className=\"w-4 shrink-0 pt-px text-right font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n                    {i + 1}\n                  </span>\n                  <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                    <span className=\"truncate font-medium text-kura-fg text-sm\">\n                      {source.title}\n                    </span>\n                    <span className=\"flex items-center gap-1.5 text-kura-fg-subtle text-xs\">\n                      <Favicon source={source} className=\"size-3.5\" />\n                      <span className=\"truncate\">{source.siteName ?? domain}</span>\n                      {source.date && <span>· {source.date}</span>}\n                    </span>\n                    {source.snippet && (\n                      <span className=\"line-clamp-2 pt-0.5 text-[13px] text-kura-fg-muted leading-relaxed\">\n                        {source.snippet}\n                      </span>\n                    )}\n                  </span>\n                </a>\n              )}\n            </li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/citation/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Citation,\n  CitationCard,\n  type CitationCardProps,\n  type CitationProps,\n  type CitationSource,\n  Sources,\n  type SourcesProps,\n  sourceDomain,\n} from \"./citation\";\n"
        }
      ]
    },
    {
      "slug": "code-block",
      "name": "CodeBlock",
      "category": "conversation",
      "status": "beta",
      "description": "Syntax-highlighted code with a filename header, copy, line numbers, highlighted lines, diffs, wrap, collapsible height and streaming-safe rendering.",
      "whenToUse": [
        "Show code a model wrote in a chat answer, including while it is still streaming.",
        "Show a file or snippet with a filename header and a copy button (docs, READMEs, agent output).",
        "Show an agent's proposed edit as a unified diff with +/- lines.",
        "Long files that should start collapsed to a fixed height with a “Show N more lines” control.",
        "Terminal commands (language=\"bash\") with an extra header action such as Run."
      ],
      "whenNotToUse": [
        "Inline code inside a sentence — use a plain <code> element.",
        "Tool arguments/results JSON inside an agent step — use tool-call, which pretty-prints JSON for you.",
        "Prose output from a model — use streaming-text or message.",
        "Editable code — use a real editor (CodeMirror/Monaco); CodeBlock is read-only."
      ],
      "props": [
        {
          "name": "code",
          "type": "string",
          "required": true,
          "description": "The source to display. For diffs pass unified-diff lines prefixed with `+`, `-` or a space (`@@`, `+++`, `---` lines render as meta)."
        },
        {
          "name": "language",
          "type": "string",
          "default": "\"text\"",
          "description": "Language id: ts/tsx/js/jsx, json, bash/sh, python, css, html/xml, yaml, sql, go, rust, java, … (see `supportedLanguages`). Unknown ids render as plain text."
        },
        {
          "name": "filename",
          "type": "string",
          "description": "File name shown in the header, e.g. \"app/page.tsx\"."
        },
        {
          "name": "lineNumbers",
          "type": "boolean",
          "default": "false",
          "description": "Show a tabular line-number gutter (not selectable, aria-hidden)."
        },
        {
          "name": "highlightLines",
          "type": "readonly number[]",
          "description": "1-based line numbers to emphasise with a tinted row and a left rule."
        },
        {
          "name": "diff",
          "type": "boolean",
          "description": "Render `+`/`-` prefixed lines as additions/removals (green/red row, sign gutter, screen-reader “added:/removed:”). Defaults to true when `language` is \"diff\"; otherwise the content after the sign is highlighted with `language`."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "default": "false",
          "description": "Code is still arriving. Only complete lines are tokenized (memoized), the unfinished last line renders plain until its newline lands, and a block caret marks the live edge — no flicker from half-open strings or comments."
        },
        {
          "name": "wrap",
          "type": "boolean",
          "description": "Soft-wrap long lines (controlled)."
        },
        {
          "name": "defaultWrap",
          "type": "boolean",
          "default": "false",
          "description": "Initial wrap state when uncontrolled."
        },
        {
          "name": "onWrapChange",
          "type": "(wrap: boolean) => void",
          "description": "Called when the wrap toggle is pressed."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "description": "Collapse code taller than this many px, with a bottom fade and a “Show N more lines” pill; the header gets an expand/collapse button."
        },
        {
          "name": "expanded",
          "type": "boolean",
          "description": "Expanded past `maxHeight` (controlled)."
        },
        {
          "name": "defaultExpanded",
          "type": "boolean",
          "default": "false",
          "description": "Initial expanded state when uncontrolled."
        },
        {
          "name": "onExpandedChange",
          "type": "(expanded: boolean) => void",
          "description": "Called when the code is expanded or collapsed."
        },
        {
          "name": "copyable",
          "type": "boolean",
          "default": "true",
          "description": "Show the copy button (icon cross-fades to a check for 1.6s on success)."
        },
        {
          "name": "onCopy",
          "type": "(code: string) => void",
          "description": "Called with the copied text after a successful copy."
        },
        {
          "name": "header",
          "type": "boolean",
          "default": "true",
          "description": "Show the header row (filename/language and actions). Without it, no wrap or copy button is shown."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra header actions rendered before wrap/copy, e.g. a ghost xs Button “Run” or “Apply”."
        }
      ],
      "parts": [
        {
          "name": "CodeBlock",
          "description": "Root. `data-language`, `data-wrap`, `data-streaming`, `data-state=\"collapsed\" | \"expanded\"` (only when collapsible).",
          "dataSlot": "code-block"
        },
        {
          "name": "Header",
          "description": "Filename/language and actions.",
          "dataSlot": "code-block-header"
        },
        {
          "name": "Title",
          "description": "Filename, or the language when no filename is given.",
          "dataSlot": "code-block-title"
        },
        {
          "name": "Language",
          "description": "Language badge, shown next to a filename.",
          "dataSlot": "code-block-language"
        },
        {
          "name": "Body",
          "description": "Height-limited wrapper (transitions max-height when expanding).",
          "dataSlot": "code-block-body"
        },
        {
          "name": "Code",
          "description": "The <code> element holding the lines.",
          "dataSlot": "code-block-code"
        },
        {
          "name": "Line",
          "description": "`data-line`, `data-highlighted`, `data-diff=\"add\" | \"remove\" | \"context\" | \"meta\"`.",
          "dataSlot": "code-block-line"
        },
        {
          "name": "LineNumber",
          "description": "Gutter number (aria-hidden).",
          "dataSlot": "code-block-line-number"
        },
        {
          "name": "Caret",
          "description": "Streaming caret (aria-hidden).",
          "dataSlot": "code-block-caret"
        },
        {
          "name": "Expand",
          "description": "Bottom fade + “Show N more lines” pill while collapsed.",
          "dataSlot": "code-block-expand"
        }
      ],
      "a11y": {
        "role": "region",
        "notes": [
          "The scroll area is a focusable `role=\"region\"` labelled “<filename or language> code”, so keyboard users can scroll wide code with the arrow keys.",
          "Copy, wrap and expand are labelled icon buttons with tooltips; wrap exposes `aria-pressed`, expand exposes `aria-expanded`.",
          "A polite status region announces “Copied to clipboard”.",
          "Line numbers, diff signs and the caret are aria-hidden; removed/added lines get visually hidden “removed:”/“added:” prefixes.",
          "Syntax colours derive from --kura-fg-muted, keeping its ≥ 4.5:1 contrast in both themes."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Moves through header actions, then the scrollable code region"
          },
          {
            "keys": "← / →",
            "action": "Scrolls wide code horizontally when the region is focused"
          },
          {
            "keys": "Enter / Space",
            "action": "Activates copy, wrap or expand"
          }
        ],
        "reducedMotion": "The copy icon swap becomes a pure cross-fade (no scale/blur), the streaming caret stops pulsing and expanding is instant."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "icon-button"
        ]
      },
      "related": [
        "tool-call",
        "message",
        "streaming-text",
        "approval-prompt"
      ],
      "tags": [
        "code",
        "syntax-highlighting",
        "diff",
        "copy",
        "streaming",
        "snippet",
        "pre",
        "markdown"
      ],
      "examples": [
        {
          "name": "CodeBlockMinimal",
          "title": "Snippet",
          "description": "Code + language. Header shows the language; copy and wrap are built in.",
          "kind": "minimal",
          "code": "import { CodeBlock } from \"@kura-ui/react/code-block\";\n\nconst SNIPPET = `import { StreamingText } from \"@kura-ui/react/streaming-text\";\n\nexport function Answer({ stream }: { stream: AsyncIterable<string> }) {\n  // Bursty tokens are evened out into a calm cadence.\n  return <StreamingText stream={stream} speed={90} className=\"max-w-prose\" />;\n}`;\n\nexport function CodeBlockMinimal() {\n  return <CodeBlock code={SNIPPET} language=\"tsx\" className=\"w-full max-w-xl\" />;\n}\n"
        },
        {
          "name": "CodeBlockFull",
          "title": "File with every option",
          "description": "Filename, line numbers, highlighted lines, controlled wrap, collapsed to 300px, onCopy.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\n\nconst ROUTE = `import { anthropic } from \"@ai-sdk/anthropic\";\nimport { streamText } from \"ai\";\n\nexport const maxDuration = 30;\n\nexport async function POST(req: Request) {\n  const { messages } = await req.json();\n\n  const result = streamText({\n    model: anthropic(\"claude-opus-5-5\"),\n    system: \"You are a calm, precise assistant.\",\n    messages,\n    temperature: 0.4,\n  });\n\n  return result.toUIMessageStreamResponse();\n}\n\n// Tokens arrive in bursts; the client smooths them with <StreamingText />.\nexport const runtime = \"edge\";\nconst RETRIES = 3;\nconst TIMEOUT_MS = 12_000;`;\n\nexport function CodeBlockFull() {\n  const [wrap, setWrap] = React.useState(false);\n  const [copies, setCopies] = React.useState(0);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-2\">\n      <CodeBlock\n        code={ROUTE}\n        language=\"ts\"\n        filename=\"app/api/chat/route.ts\"\n        lineNumbers\n        highlightLines={[9, 10, 11, 12, 13, 14]}\n        wrap={wrap}\n        onWrapChange={setWrap}\n        maxHeight={300}\n        defaultExpanded={false}\n        copyable\n        onCopy={() => setCopies((c) => c + 1)}\n        header\n      />\n      <p className=\"font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n        wrap: {String(wrap)} · copied {copies}×\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "CodeBlockDiff",
          "title": "Diff",
          "description": "A proposed edit as a unified diff, highlighted as TSX.",
          "kind": "variant",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\n\nconst DIFF = `@@ -4,7 +4,8 @@ export function Answer() {\n   const [open, setOpen] = React.useState(false);\n-  const items = data.filter((d) => d.visible);\n+  const items = React.useMemo(() => data.filter((d) => d.visible), [data]);\n+  const reduced = useReducedMotion();\n   return (\n-    <List items={items} animate />\n+    <List items={items} animate={!reduced} />\n   );`;\n\nexport function CodeBlockDiff() {\n  return (\n    <CodeBlock\n      code={DIFF}\n      language=\"tsx\"\n      diff\n      filename=\"components/answer.tsx\"\n      lineNumbers\n      className=\"w-full max-w-xl\"\n    />\n  );\n}\n"
        },
        {
          "name": "CodeBlockStreaming",
          "title": "Streaming",
          "description": "Code arriving a few characters at a time — the open last line never flickers.",
          "kind": "state",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\n\nconst SNIPPET = `import { StreamingText } from \"@kura-ui/react/streaming-text\";\n\nexport function Answer({ stream }: { stream: AsyncIterable<string> }) {\n  // Bursty tokens are evened out into a calm cadence.\n  return <StreamingText stream={stream} speed={90} className=\"max-w-prose\" />;\n}`;\n\nexport function CodeBlockStreaming() {\n  const [run, setRun] = React.useState(0);\n  const [length, setLength] = React.useState(0);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` restarts the fake stream\n  React.useEffect(() => {\n    setLength(0);\n    let n = 0;\n    const id = setInterval(() => {\n      n = Math.min(SNIPPET.length, n + 2 + Math.floor(Math.random() * 7));\n      setLength(n);\n      if (n >= SNIPPET.length) clearInterval(id);\n    }, 45);\n    return () => clearInterval(id);\n  }, [run]);\n  const done = length >= SNIPPET.length;\n  return (\n    <div className=\"flex w-full max-w-xl flex-col items-end gap-2\">\n      <CodeBlock\n        code={SNIPPET.slice(0, length)}\n        language=\"tsx\"\n        filename=\"answer.tsx\"\n        streaming={!done}\n        lineNumbers\n        className=\"w-full\"\n      />\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => setRun((r) => r + 1)}>\n        <RotateCcw /> Replay\n      </Button>\n    </div>\n  );\n}\n"
        },
        {
          "name": "CodeBlockComposition",
          "title": "Command with a Run action",
          "description": "A bash block with a header Button, followed by a header-less output block.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Play } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\n\nexport function CodeBlockComposition() {\n  const [ran, setRan] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-2\">\n      <CodeBlock\n        code={\"pnpm dlx @kura-ui/cli add code-block\\npnpm --filter web dev --port 3000\"}\n        language=\"bash\"\n        actions={\n          <Button variant=\"ghost\" size=\"xs\" onClick={() => setRan(true)}>\n            <Play /> Run\n          </Button>\n        }\n      />\n      {ran && (\n        <CodeBlock\n          code={\"✓ added code-block (4 files)\\n▲ ready on http://localhost:3000\"}\n          header={false}\n          copyable={false}\n          className=\"animate-kura-rise-in\"\n        />\n      )}\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Highlighting is built in, synchronous and dependency-free (no shiki/prism needed); unknown languages render as plain text.",
        "While a model is still writing code, pass `streaming` and the growing string as `code`; set it to false when the fence closes.",
        "For diffs keep the leading `+`/`-`/space on every line and set `diff` (or language=\"diff\" for no inner highlighting).",
        "Theme the palette with CSS variables on any ancestor: --kura-syntax-keyword, -string, -constant, -function, -type, -tag, -attr, -property.",
        "`highlightCode(code, language)` is exported for custom renderers; it returns tokens grouped by line."
      ],
      "title": "Code Block",
      "importPath": "@kura-ui/react/code-block",
      "exports": [
        "CodeBlock",
        "CodeBlockProps",
        "CodeToken",
        "CodeTokenType",
        "highlightCode",
        "supportedLanguages"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/code-block",
      "markdownUrl": "https://kuro.abstergo.fyi/components/code-block.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/code-block.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/code-block.json",
        "cli": "npx @kura-ui/cli add code-block",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "icon-button",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "code": {
            "description": "The source to display. For diffs pass unified-diff lines prefixed with `+`, `-` or a space (`@@`, `+++`, `---` lines render as meta).",
            "x-ts-type": "string",
            "type": "string"
          },
          "language": {
            "description": "Language id: ts/tsx/js/jsx, json, bash/sh, python, css, html/xml, yaml, sql, go, rust, java, … (see `supportedLanguages`). Unknown ids render as plain text.",
            "x-ts-type": "string",
            "type": "string",
            "default": "text"
          },
          "filename": {
            "description": "File name shown in the header, e.g. \"app/page.tsx\".",
            "x-ts-type": "string",
            "type": "string"
          },
          "lineNumbers": {
            "description": "Show a tabular line-number gutter (not selectable, aria-hidden).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "highlightLines": {
            "description": "1-based line numbers to emphasise with a tinted row and a left rule.",
            "x-ts-type": "readonly number[]",
            "type": "array"
          },
          "diff": {
            "description": "Render `+`/`-` prefixed lines as additions/removals (green/red row, sign gutter, screen-reader “added:/removed:”). Defaults to true when `language` is \"diff\"; otherwise the content after the sign is highlighted with `language`.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "streaming": {
            "description": "Code is still arriving. Only complete lines are tokenized (memoized), the unfinished last line renders plain until its newline lands, and a block caret marks the live edge — no flicker from half-open strings or comments.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "wrap": {
            "description": "Soft-wrap long lines (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultWrap": {
            "description": "Initial wrap state when uncontrolled.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onWrapChange": {
            "description": "Called when the wrap toggle is pressed.",
            "x-ts-type": "(wrap: boolean) => void"
          },
          "maxHeight": {
            "description": "Collapse code taller than this many px, with a bottom fade and a “Show N more lines” pill; the header gets an expand/collapse button.",
            "x-ts-type": "number",
            "type": "number"
          },
          "expanded": {
            "description": "Expanded past `maxHeight` (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultExpanded": {
            "description": "Initial expanded state when uncontrolled.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onExpandedChange": {
            "description": "Called when the code is expanded or collapsed.",
            "x-ts-type": "(expanded: boolean) => void"
          },
          "copyable": {
            "description": "Show the copy button (icon cross-fades to a check for 1.6s on success).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "onCopy": {
            "description": "Called with the copied text after a successful copy.",
            "x-ts-type": "(code: string) => void"
          },
          "header": {
            "description": "Show the header row (filename/language and actions). Without it, no wrap or copy button is shown.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "actions": {
            "description": "Extra header actions rendered before wrap/copy, e.g. a ghost xs Button “Run” or “Apply”.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "code"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/code-block/code-block.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Check, ChevronsDownUp, ChevronsUpDown, Copy, FileCode2, WrapText } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { type CodeToken, type CodeTokenType, highlightCode } from \"./highlight\";\n\nexport interface CodeBlockProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onCopy\"> {\n  /** The source to show. For diffs, pass unified-diff lines prefixed with `+`, `-` or a space. */\n  code: string;\n  /** Language id for highlighting and the header label, e.g. \"tsx\", \"python\", \"bash\", \"json\". */\n  language?: string;\n  /** File name shown in the header, e.g. \"app/page.tsx\". */\n  filename?: string;\n  /** Show a line-number gutter. Default false. */\n  lineNumbers?: boolean;\n  /** 1-based line numbers to emphasise, e.g. `[3, 4, 5]`. */\n  highlightLines?: readonly number[];\n  /** Treat `+` / `-` prefixed lines as additions and removals. Default true when language is \"diff\". */\n  diff?: boolean;\n  /** Code is still arriving: the unfinished last line stays unhighlighted until its newline lands, and a caret marks the live edge. */\n  streaming?: boolean;\n  /** Soft-wrap long lines (controlled). */\n  wrap?: boolean;\n  /** Initial wrap state when uncontrolled. Default false. */\n  defaultWrap?: boolean;\n  onWrapChange?: (wrap: boolean) => void;\n  /** Collapse taller code to this height in px, with a “Show all N lines” control. */\n  maxHeight?: number;\n  /** Expanded past `maxHeight` (controlled). */\n  expanded?: boolean;\n  /** Initial expanded state when uncontrolled. Default false. */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Show the copy button. Default true. */\n  copyable?: boolean;\n  /** Called with the copied text after a successful copy. */\n  onCopy?: (code: string) => void;\n  /** Show the filename/language header with its actions. Default true. */\n  header?: boolean;\n  /** Extra header actions, rendered before wrap and copy (e.g. an “Apply” button). */\n  actions?: React.ReactNode;\n}\n\ntype LineKind = \"context\" | \"add\" | \"remove\" | \"meta\";\n\ninterface Line {\n  tokens: CodeToken[];\n  kind: LineKind;\n  /** Unfinished trailing line while streaming. */\n  pending?: boolean;\n}\n\nconst tokenClass: Record<CodeTokenType, string | undefined> = {\n  plain: undefined,\n  keyword: \"text-(--kura-code-keyword)\",\n  string: \"text-(--kura-code-string)\",\n  comment: \"text-kura-fg-subtle italic\",\n  number: \"text-(--kura-code-constant)\",\n  constant: \"text-(--kura-code-constant)\",\n  function: \"text-(--kura-code-function)\",\n  type: \"text-(--kura-code-type)\",\n  tag: \"text-(--kura-code-tag)\",\n  attr: \"text-(--kura-code-attr)\",\n  property: \"text-(--kura-code-property)\",\n  variable: \"text-(--kura-code-constant)\",\n  punctuation: \"text-kura-fg-subtle\",\n  operator: \"text-kura-fg-muted\",\n};\n\n/*\n * Syntax colours are derived from --kura-fg-muted (so they keep its ≥ 4.5:1 contrast and flip\n * with the theme) by swapping in a hue. Override any of them with --kura-syntax-* on a parent.\n */\nconst syntax = (name: string, chroma: number, hue: number) =>\n  `var(--kura-syntax-${name}, oklch(from var(--kura-fg-muted) l ${chroma} ${hue}))`;\nconst syntaxVars = {\n  \"--kura-code-keyword\": syntax(\"keyword\", 0.13, 350),\n  \"--kura-code-string\": syntax(\"string\", 0.1, 150),\n  \"--kura-code-constant\": syntax(\"constant\", 0.11, 55),\n  \"--kura-code-function\": syntax(\"function\", 0.11, 255),\n  \"--kura-code-type\": syntax(\"type\", 0.09, 210),\n  \"--kura-code-tag\": syntax(\"tag\", 0.12, 25),\n  \"--kura-code-attr\": syntax(\"attr\", 0.1, 290),\n  \"--kura-code-property\": \"var(--kura-syntax-property, var(--kura-fg))\",\n} as React.CSSProperties;\n\nconst LINE_HEIGHT = 22;\n\nfunction useLines(code: string, language: string, diff: boolean, streaming: boolean): Line[] {\n  // While streaming, only complete lines are tokenized; the tail is plain text. Memoized on the\n  // stable prefix so new characters on the last line never re-colour earlier lines.\n  const cut = streaming ? code.lastIndexOf(\"\\n\") + 1 : code.length;\n  const stable = code.slice(0, cut);\n  const tail = code.slice(cut);\n\n  const stableLines = React.useMemo(() => {\n    if (!stable) return [] as Line[];\n    const body = streaming ? stable.slice(0, -1) : stable;\n    if (!diff) {\n      return highlightCode(body, language).map((tokens) => ({\n        tokens,\n        kind: \"context\" as LineKind,\n      }));\n    }\n    const raw = body.split(\"\\n\");\n    const kinds: LineKind[] = raw.map((l) =>\n      l.startsWith(\"+++\") || l.startsWith(\"---\") || l.startsWith(\"@@\")\n        ? \"meta\"\n        : l.startsWith(\"+\")\n          ? \"add\"\n          : l.startsWith(\"-\")\n            ? \"remove\"\n            : \"context\",\n    );\n    const lang = language === \"diff\" ? \"text\" : language;\n    const stripped = raw\n      .map((l, i) => (kinds[i] === \"meta\" ? \"\" : /^[+\\- ]/.test(l) ? l.slice(1) : l))\n      .join(\"\\n\");\n    const tokens = highlightCode(stripped, lang);\n    return raw.map((l, i) => ({\n      kind: kinds[i]!,\n      tokens: kinds[i] === \"meta\" ? [{ type: \"comment\" as const, text: l }] : (tokens[i] ?? []),\n    }));\n  }, [stable, language, diff, streaming]);\n\n  if (!streaming) return stableLines;\n  const pendingKind: LineKind = diff\n    ? tail.startsWith(\"+\")\n      ? \"add\"\n      : tail.startsWith(\"-\")\n        ? \"remove\"\n        : \"context\"\n    : \"context\";\n  const pendingText = diff && /^[+\\- ]/.test(tail) ? tail.slice(1) : tail;\n  return [\n    ...stableLines,\n    { tokens: [{ type: \"plain\", text: pendingText }], kind: pendingKind, pending: true },\n  ];\n}\n\n/**\n * Syntax-highlighted code with a filename/language header, copy with confirmation, line\n * numbers, highlighted lines, unified-diff rendering, soft-wrap toggle and a collapsible max\n * height. Highlighting is synchronous and dependency-free, and `streaming` keeps partially\n * received code from flickering.\n */\nexport function CodeBlock({\n  code,\n  language = \"text\",\n  filename,\n  lineNumbers = false,\n  highlightLines,\n  diff: diffProp,\n  streaming = false,\n  wrap: wrapProp,\n  defaultWrap = false,\n  onWrapChange,\n  maxHeight,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  copyable = true,\n  onCopy,\n  header = true,\n  actions,\n  className,\n  style,\n  ...props\n}: CodeBlockProps) {\n  const diff = diffProp ?? language === \"diff\";\n  const [wrap, setWrap] = useControllableState({\n    value: wrapProp,\n    defaultValue: defaultWrap,\n    onChange: onWrapChange,\n  });\n  const [expanded, setExpanded] = useControllableState({\n    value: expandedProp,\n    defaultValue: defaultExpanded,\n    onChange: onExpandedChange,\n  });\n  const lines = useLines(code, language, diff, streaming);\n  const highlighted = React.useMemo(() => new Set(highlightLines ?? []), [highlightLines]);\n\n  // Collapse: measure the natural height so expanding can transition to an exact max-height.\n  const contentRef = React.useRef<HTMLElement>(null);\n  const [fullHeight, setFullHeight] = React.useState<number | null>(null);\n  React.useLayoutEffect(() => {\n    const el = contentRef.current;\n    if (!el || maxHeight == null) return;\n    const measure = () => setFullHeight(el.scrollHeight);\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [maxHeight]);\n  const estimated = lines.length * LINE_HEIGHT + 24;\n  const natural = fullHeight || estimated;\n  const collapsible = maxHeight != null && natural > maxHeight + LINE_HEIGHT;\n  const collapsed = collapsible && !expanded;\n  const hiddenLines = collapsible\n    ? Math.max(0, lines.length - Math.floor((maxHeight - 12) / LINE_HEIGHT))\n    : 0;\n\n  // Copy with confirmation.\n  const [copied, setCopied] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(code);\n      setCopied(true);\n      onCopy?.(code);\n      clearTimeout(timer.current);\n      timer.current = setTimeout(() => setCopied(false), 1600);\n    } catch {\n      // Clipboard can be unavailable (insecure context); the button simply does nothing.\n    }\n  };\n\n  const digits = Math.max(2, String(lines.length).length);\n  const label = filename ?? (language !== \"text\" ? language : \"Code\");\n\n  return (\n    <div\n      data-slot=\"code-block\"\n      data-language={language}\n      data-streaming={streaming || undefined}\n      data-wrap={wrap}\n      data-state={collapsible ? (expanded ? \"expanded\" : \"collapsed\") : undefined}\n      className={cn(\n        \"group/code relative flex flex-col overflow-hidden rounded-kura-xl bg-kura-surface-sunken font-kura-mono text-[13px] text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      style={{ ...syntaxVars, ...style }}\n      {...props}\n    >\n      {header && (\n        <div\n          data-slot=\"code-block-header\"\n          className=\"flex h-10 shrink-0 items-center gap-2 bg-kura-surface pr-1.5 pl-3.5 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n        >\n          <span className=\"flex min-w-0 flex-1 items-center gap-2 font-kura-sans text-[13px] text-kura-fg-muted\">\n            {filename && (\n              <FileCode2 aria-hidden className=\"size-3.5 shrink-0 text-kura-fg-subtle\" />\n            )}\n            <span data-slot=\"code-block-title\" className=\"truncate\">\n              {filename ?? (language !== \"text\" ? language : \"\")}\n            </span>\n          </span>\n          {filename && language !== \"text\" && (\n            <span\n              data-slot=\"code-block-language\"\n              className=\"select-none px-1 text-[11px] text-kura-fg-subtle uppercase tracking-wide\"\n            >\n              {language}\n            </span>\n          )}\n          <span className=\"flex items-center gap-0.5\">\n            {actions}\n            {collapsible && (\n              <IconButton\n                size=\"sm\"\n                label={expanded ? \"Collapse code\" : \"Expand code\"}\n                aria-expanded={expanded}\n                icon={expanded ? <ChevronsDownUp /> : <ChevronsUpDown />}\n                onClick={() => setExpanded(!expanded)}\n              />\n            )}\n            <IconButton\n              size=\"sm\"\n              label={wrap ? \"Disable line wrap\" : \"Wrap lines\"}\n              aria-pressed={wrap}\n              icon={<WrapText />}\n              className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n              onClick={() => setWrap(!wrap)}\n            />\n            {copyable && (\n              <IconButton\n                size=\"sm\"\n                label={copied ? \"Copied\" : \"Copy code\"}\n                data-copied={copied}\n                onClick={copy}\n                icon={\n                  <span className=\"relative grid size-3.5 place-items-center [&>svg]:col-start-1 [&>svg]:row-start-1\">\n                    <Copy\n                      className={cn(\n                        \"size-3.5 transition-[opacity,scale,filter] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                        copied &&\n                          \"scale-75 opacity-0 blur-[2px] motion-reduce:scale-100 motion-reduce:blur-none\",\n                      )}\n                    />\n                    <Check\n                      className={cn(\n                        \"size-3.5 text-kura-success-text transition-[opacity,scale,filter] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                        !copied &&\n                          \"scale-75 opacity-0 blur-[2px] motion-reduce:scale-100 motion-reduce:blur-none\",\n                      )}\n                    />\n                  </span>\n                }\n              />\n            )}\n          </span>\n          <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n            {copied ? \"Copied to clipboard\" : \"\"}\n          </span>\n        </div>\n      )}\n\n      <div\n        data-slot=\"code-block-body\"\n        className={cn(\n          \"relative\",\n          maxHeight != null &&\n            \"overflow-hidden transition-[max-height] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\",\n        )}\n        style={\n          maxHeight != null\n            ? { maxHeight: collapsed ? maxHeight : collapsible ? natural : undefined }\n            : undefined\n        }\n      >\n        <section\n          ref={contentRef}\n          aria-label={`${label} code`}\n          // biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable code must be keyboard-scrollable\n          tabIndex={0}\n          className={cn(\n            \"overflow-x-auto py-3 leading-[22px] outline-none [scrollbar-width:thin]\",\n            \"focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n          )}\n        >\n          <code\n            data-slot=\"code-block-code\"\n            className={cn(\"block\", wrap ? \"w-full\" : \"w-max min-w-full\")}\n          >\n            {lines.map((line, index) => {\n              const n = index + 1;\n              const isHighlighted = highlighted.has(n);\n              const last = index === lines.length - 1;\n              return (\n                <span\n                  key={index}\n                  data-slot=\"code-block-line\"\n                  data-line={n}\n                  data-highlighted={isHighlighted || undefined}\n                  data-diff={diff ? line.kind : undefined}\n                  className={cn(\n                    \"flex px-4\",\n                    isHighlighted &&\n                      \"bg-kura-hover shadow-[inset_2px_0_0_var(--kura-border-strong)]\",\n                    line.kind === \"add\" &&\n                      \"bg-kura-success/10 shadow-[inset_2px_0_0_var(--kura-success)]\",\n                    line.kind === \"remove\" &&\n                      \"bg-kura-danger/10 shadow-[inset_2px_0_0_var(--kura-danger)]\",\n                  )}\n                >\n                  {lineNumbers && (\n                    <span\n                      aria-hidden\n                      data-slot=\"code-block-line-number\"\n                      className=\"mr-4 shrink-0 select-none text-right text-kura-fg-subtle tabular-nums opacity-70\"\n                      style={{ width: `${digits}ch` }}\n                    >\n                      {n}\n                    </span>\n                  )}\n                  {diff && (\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"mr-3 w-[1ch] shrink-0 select-none\",\n                        line.kind === \"add\" && \"text-kura-success-text\",\n                        line.kind === \"remove\" && \"text-kura-danger-text\",\n                      )}\n                    >\n                      {line.kind === \"add\" ? \"+\" : line.kind === \"remove\" ? \"−\" : \" \"}\n                    </span>\n                  )}\n                  {diff && (line.kind === \"add\" || line.kind === \"remove\") && (\n                    <span className=\"sr-only\">{line.kind === \"add\" ? \"added: \" : \"removed: \"}</span>\n                  )}\n                  <span\n                    className={cn(\n                      \"min-w-0 flex-1\",\n                      wrap ? \"whitespace-pre-wrap break-words\" : \"whitespace-pre\",\n                      line.kind === \"remove\" && \"opacity-75\",\n                      line.pending && \"text-kura-fg-muted\",\n                    )}\n                  >\n                    {line.tokens.length === 0 && !line.pending\n                      ? \"​\"\n                      : line.tokens.map((t, i) => (\n                          <span key={i} className={tokenClass[t.type]}>\n                            {t.text}\n                          </span>\n                        ))}\n                    {streaming && last && (\n                      <span\n                        aria-hidden\n                        data-slot=\"code-block-caret\"\n                        className=\"ml-px inline-block h-[1.15em] w-[0.55em] translate-y-[0.2em] rounded-[2px] bg-kura-accent/70 animate-kura-pulse motion-reduce:animate-none\"\n                      />\n                    )}\n                  </span>\n                </span>\n              );\n            })}\n          </code>\n        </section>\n\n        {collapsed && (\n          <div\n            data-slot=\"code-block-expand\"\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 flex h-20 items-end justify-center bg-linear-to-t from-kura-surface-sunken via-kura-surface-sunken/80 to-transparent pb-3\"\n          >\n            <button\n              type=\"button\"\n              aria-expanded={false}\n              onClick={() => setExpanded(true)}\n              className={cn(\n                \"pointer-events-auto inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full bg-kura-surface-raised px-3 font-kura-sans text-kura-fg-muted text-xs shadow-kura-md outline-none\",\n                \"transition-[color,transform,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus active:scale-[0.97] motion-reduce:active:scale-100\",\n              )}\n            >\n              <ChevronsUpDown aria-hidden className=\"size-3\" />\n              Show {hiddenLines} more {hiddenLines === 1 ? \"line\" : \"lines\"}\n            </button>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/code-block/highlight.ts",
          "type": "registry:ui",
          "content": "/**\n * A small, synchronous, zero-dependency syntax tokenizer. It is not a full grammar engine like\n * shiki; it is a careful set of sticky regexes per language family that gets the common cases\n * right, runs in microseconds, and — being synchronous — never flashes unhighlighted code.\n */\n\nexport type CodeTokenType =\n  | \"plain\"\n  | \"keyword\"\n  | \"string\"\n  | \"comment\"\n  | \"number\"\n  | \"constant\"\n  | \"function\"\n  | \"type\"\n  | \"tag\"\n  | \"attr\"\n  | \"property\"\n  | \"variable\"\n  | \"punctuation\"\n  | \"operator\";\n\nexport interface CodeToken {\n  type: CodeTokenType;\n  text: string;\n}\n\ntype Rule = readonly [CodeTokenType, RegExp];\n\nconst kw = (words: string, flags = \"y\") => new RegExp(`\\\\b(?:${words})\\\\b`, flags);\n\nconst DQ = /\"(?:\\\\.|[^\"\\\\\\n])*\"?/y;\nconst SQ = /'(?:\\\\.|[^'\\\\\\n])*'?/y;\nconst NUM = /\\b(?:0[xXbBoO][\\da-fA-F_]+|\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?n?)\\b/y;\nconst IDENT = /[A-Za-z_$][\\w$]*/y;\nconst CALL = /[A-Za-z_$][\\w$]*(?=\\s*\\()/y;\nconst TYPE = /\\b[A-Z][\\w$]*\\b/y;\nconst PROP = /(?<=\\.)[A-Za-z_$][\\w$]*/y;\nconst PUNCT = /[{}()[\\];,.]/y;\nconst OP = /=>|[=+\\-*/%!&|^~?:<>]+/y;\n\nconst JS_KEYWORDS =\n  \"abstract|as|async|await|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|infer|instanceof|interface|is|keyof|let|namespace|new|of|private|protected|public|readonly|return|satisfies|set|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield\";\n\nconst js: Rule[] = [\n  [\"comment\", /\\/\\/[^\\n]*/y],\n  [\"comment\", /\\/\\*[\\s\\S]*?(?:\\*\\/|$)/y],\n  [\"string\", /`(?:\\\\[\\s\\S]|[^\\\\`])*`?/y],\n  [\"string\", DQ],\n  [\"string\", SQ],\n  [\"tag\", /(?<=<\\/?)[A-Za-z][\\w.]*/y],\n  [\"attr\", /[A-Za-z_$][\\w$-]*(?==(?!=|>))/y],\n  [\"constant\", kw(\"true|false|null|undefined|NaN|Infinity\")],\n  [\"keyword\", kw(JS_KEYWORDS)],\n  [\"number\", NUM],\n  [\"function\", CALL],\n  [\"type\", TYPE],\n  [\"property\", PROP],\n  [\"plain\", IDENT],\n  [\"punctuation\", PUNCT],\n  [\"operator\", OP],\n];\n\nconst json: Rule[] = [\n  [\"property\", /\"(?:\\\\.|[^\"\\\\\\n])*\"(?=\\s*:)/y],\n  [\"string\", DQ],\n  [\"number\", /-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?/y],\n  [\"constant\", kw(\"true|false|null\")],\n  [\"punctuation\", /[{}[\\],:]/y],\n];\n\nconst bash: Rule[] = [\n  [\"comment\", /(?<=^|\\s)#[^\\n]*/my],\n  [\"string\", /\"(?:\\\\[\\s\\S]|[^\"\\\\])*\"?/y],\n  [\"string\", /'[^']*'?/y],\n  [\"variable\", /\\$(?:\\{[^}\\n]*\\}|\\w+|[@#?$!*0-9])/y],\n  [\n    \"keyword\",\n    kw(\"if|then|else|elif|fi|for|while|until|do|done|in|case|esac|function|export|local|return\"),\n  ],\n  [\"function\", /(?<=(?:^|[|;&(]|&&|\\|\\|)[ \\t]*(?:sudo[ \\t]+)?)[A-Za-z_.][\\w./-]*/my],\n  [\"attr\", /(?<=\\s)--?[A-Za-z][\\w-]*/y],\n  [\"number\", /\\b\\d+\\b/y],\n  [\"operator\", /[|&;<>]+|=/y],\n];\n\nconst python: Rule[] = [\n  [\"comment\", /#[^\\n]*/y],\n  [\n    \"string\",\n    /[rbfuRBFU]{0,2}(?:\"\"\"[\\s\\S]*?(?:\"\"\"|$)|'''[\\s\\S]*?(?:'''|$)|\"(?:\\\\.|[^\"\\\\\\n])*\"?|'(?:\\\\.|[^'\\\\\\n])*'?)/y,\n  ],\n  [\"attr\", /@[\\w.]+/y],\n  [\"constant\", kw(\"True|False|None\")],\n  [\n    \"keyword\",\n    kw(\n      \"and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield|match|case|self\",\n    ),\n  ],\n  [\"number\", NUM],\n  [\"function\", /(?<=\\bdef\\s+)\\w+/y],\n  [\"function\", CALL],\n  [\"type\", TYPE],\n  [\"property\", PROP],\n  [\"plain\", IDENT],\n  [\"punctuation\", PUNCT],\n  [\"operator\", OP],\n];\n\nconst css: Rule[] = [\n  [\"comment\", /\\/\\*[\\s\\S]*?(?:\\*\\/|$)/y],\n  [\"keyword\", /@[\\w-]+/y],\n  [\"string\", DQ],\n  [\"string\", SQ],\n  [\"variable\", /--[\\w-]+/y],\n  [\"number\", /#[\\da-fA-F]{3,8}\\b/y],\n  [\"property\", /(?<=[{;\\s])[a-z-]+(?=\\s*:[^:])/y],\n  [\"function\", /[\\w-]+(?=\\()/y],\n  [\"number\", /-?(?:\\d*\\.)?\\d+(?:px|rem|em|%|ms|s|vh|vw|dvh|deg|fr|ch)?\\b%?/y],\n  [\"tag\", /[.#][\\w-]+|&/y],\n  [\"constant\", /!important/y],\n  [\"plain\", /[\\w-]+/y],\n  [\"punctuation\", /[{}();,:]/y],\n];\n\nconst markup: Rule[] = [\n  [\"comment\", /<!--[\\s\\S]*?(?:-->|$)/y],\n  [\"tag\", /(?<=<\\/?)[\\w:-]+/y],\n  [\"attr\", /[\\w:@.-]+(?==)/y],\n  [\"string\", DQ],\n  [\"string\", SQ],\n  [\"punctuation\", /<\\/?|\\/?>|=/y],\n];\n\nconst yaml: Rule[] = [\n  [\"comment\", /(?<=^|\\s)#[^\\n]*/my],\n  [\"property\", /(?<=^[ \\t]*(?:- )?)[\\w.$-]+(?=\\s*:(?:\\s|$))/my],\n  [\"string\", DQ],\n  [\"string\", SQ],\n  [\"constant\", kw(\"true|false|null|yes|no|on|off\")],\n  [\"number\", /-?\\b\\d+(?:\\.\\d+)?\\b/y],\n  [\"punctuation\", /[-:[\\]{},|>]/y],\n];\n\nconst sql: Rule[] = [\n  [\"comment\", /--[^\\n]*/y],\n  [\"comment\", /\\/\\*[\\s\\S]*?(?:\\*\\/|$)/y],\n  [\"string\", SQ],\n  [\"string\", DQ],\n  [\n    \"keyword\",\n    kw(\n      \"select|from|where|and|or|not|insert|into|values|update|set|delete|create|table|index|drop|alter|add|join|left|right|inner|outer|on|group|by|order|having|limit|offset|as|distinct|union|all|case|when|then|else|end|in|is|null|primary|key|references|default|returning|with|asc|desc|exists|like\",\n      \"iy\",\n    ),\n  ],\n  [\"number\", NUM],\n  [\"function\", CALL],\n  [\"plain\", /\\w+/y],\n  [\"punctuation\", /[(),;.*]/y],\n  [\"operator\", /[=<>!+\\-/%]+/y],\n];\n\nfunction cLike(keywords: string, extra: Rule[] = []): Rule[] {\n  return [\n    [\"comment\", /\\/\\/[^\\n]*/y],\n    [\"comment\", /\\/\\*[\\s\\S]*?(?:\\*\\/|$)/y],\n    ...extra,\n    [\"string\", /`[^`]*`?/y],\n    [\"string\", DQ],\n    [\"string\", SQ],\n    [\"constant\", kw(\"true|false|nil|null|None|Some|Ok|Err|iota\")],\n    [\"keyword\", kw(keywords)],\n    [\"number\", NUM],\n    [\"function\", /[A-Za-z_][\\w]*!?(?=\\s*\\()/y],\n    [\"type\", TYPE],\n    [\"property\", PROP],\n    [\"plain\", IDENT],\n    [\"punctuation\", PUNCT],\n    [\"operator\", OP],\n  ];\n}\n\nconst go = cLike(\n  \"break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var|string|int|int64|float64|bool|error|byte|rune|any\",\n);\nconst rust = cLike(\n  \"as|async|await|break|const|continue|crate|dyn|else|enum|extern|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|type|unsafe|use|where|while|i32|i64|u8|u32|u64|usize|f64|bool|str|String|Vec|Option|Result\",\n  [[\"attr\", /#!?\\[[^\\]\\n]*\\]/y]],\n);\nconst generic = cLike(\n  \"abstract|break|case|catch|class|const|continue|default|do|else|enum|extends|final|finally|for|if|implements|import|interface|new|package|private|protected|public|return|static|struct|switch|this|throw|throws|try|void|while|fun|val|var|let|func|def|end\",\n);\n\nconst LANGUAGES: Record<string, Rule[]> = {\n  js,\n  javascript: js,\n  jsx: js,\n  mjs: js,\n  cjs: js,\n  ts: js,\n  typescript: js,\n  tsx: js,\n  json,\n  jsonc: js,\n  json5: js,\n  bash,\n  sh: bash,\n  shell: bash,\n  zsh: bash,\n  console: bash,\n  python: python,\n  py: python,\n  css,\n  scss: css,\n  html: markup,\n  xml: markup,\n  svg: markup,\n  vue: markup,\n  yaml,\n  yml: yaml,\n  toml: yaml,\n  sql,\n  go,\n  rust,\n  rs: rust,\n  java: generic,\n  kotlin: generic,\n  swift: generic,\n  c: generic,\n  cpp: generic,\n  csharp: generic,\n  cs: generic,\n  php: generic,\n  ruby: generic,\n};\n\n/** Languages with a dedicated tokenizer (aliases included). Anything else renders as plain text. */\nexport const supportedLanguages = Object.keys(LANGUAGES);\n\n/** Tokenize `code` and split the result into lines (tokens never span a newline). */\nexport function highlightCode(code: string, language = \"text\"): CodeToken[][] {\n  const rules = LANGUAGES[language.toLowerCase()];\n  const lines: CodeToken[][] = [[]];\n  const push = (type: CodeTokenType, text: string) => {\n    const parts = text.split(\"\\n\");\n    parts.forEach((part, i) => {\n      if (i > 0) lines.push([]);\n      if (!part) return;\n      const line = lines[lines.length - 1]!;\n      const last = line[line.length - 1];\n      if (last && last.type === type) last.text += part;\n      else line.push({ type, text: part });\n    });\n  };\n  if (!rules) {\n    push(\"plain\", code);\n    return lines;\n  }\n  let i = 0;\n  outer: while (i < code.length) {\n    for (const [type, re] of rules) {\n      re.lastIndex = i;\n      const m = re.exec(code);\n      if (m && m[0].length > 0) {\n        push(type, m[0]);\n        i += m[0].length;\n        continue outer;\n      }\n    }\n    push(\"plain\", code[i]!);\n    i++;\n  }\n  return lines;\n}\n"
        },
        {
          "target": "components/kura/code-block/index.ts",
          "type": "registry:ui",
          "content": "export { CodeBlock, type CodeBlockProps } from \"./code-block\";\nexport { type CodeToken, type CodeTokenType, highlightCode, supportedLanguages } from \"./highlight\";\n"
        }
      ]
    },
    {
      "slug": "command-palette",
      "name": "CommandPalette",
      "category": "input",
      "status": "beta",
      "description": "⌘K command palette for agent actions: fuzzy search, groups, recents, nested pages with async loading, and shortcut hints.",
      "whenToUse": [
        "A global launcher for agent actions: run/stop an agent, switch agents, add context, change settings.",
        "Navigating many commands or entities (files, threads, workflows) by typing.",
        "Multi-step choices as nested pages (“Change theme…” → Light / Dark), including lists fetched on demand."
      ],
      "whenNotToUse": [
        "Commands typed inline inside a prompt — use slash-command-menu.",
        "Choosing a model — use model-picker.",
        "A handful of starter prompts in an empty chat — use prompt-suggestions."
      ],
      "props": [
        {
          "name": "items",
          "type": "readonly CommandPaletteItem[]",
          "required": true,
          "description": "Items: { id, label, description?, icon?, group?, keywords?, shortcut?, onSelect?, children?, loadChildren?, placeholder?, disabled? }. children/loadChildren open a nested page."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Controlled open state."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "default": "false",
          "description": "Initial open state."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Open state changes."
        },
        {
          "name": "hotkey",
          "type": "readonly KeyName[] | null",
          "default": "[\"mod\", \"k\"]",
          "description": "Global toggle shortcut (works in inputs). null disables it."
        },
        {
          "name": "trigger",
          "type": "React.ReactElement",
          "description": "Element rendered as the dialog trigger, e.g. <CommandPaletteTrigger />."
        },
        {
          "name": "recent",
          "type": "string[]",
          "description": "Controlled recent item ids (most recent first), shown as “Recent” when the query is empty."
        },
        {
          "name": "defaultRecent",
          "type": "string[]",
          "description": "Initial recents when uncontrolled."
        },
        {
          "name": "onRecentChange",
          "type": "(ids: string[]) => void",
          "description": "Called when an item is used (persist it to localStorage if you like)."
        },
        {
          "name": "recentLimit",
          "type": "number",
          "default": "4",
          "description": "Max recents shown."
        },
        {
          "name": "onSelect",
          "type": "(item: CommandPaletteItem) => void",
          "description": "Called after any leaf item runs (in addition to item.onSelect)."
        },
        {
          "name": "onQueryChange",
          "type": "(query: string) => void",
          "description": "Every query change — fetch server results into `items` and toggle `loading`."
        },
        {
          "name": "loading",
          "type": "boolean",
          "default": "false",
          "description": "Show skeleton rows and aria-busy (root page). Nested pages with loadChildren handle this themselves."
        },
        {
          "name": "placeholder",
          "type": "string",
          "default": "\"Search or run a command…\"",
          "description": "Search placeholder on the root page."
        },
        {
          "name": "emptyMessage",
          "type": "string",
          "default": "\"No results\"",
          "description": "Shown when nothing matches."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Command palette\"",
          "description": "Accessible dialog title (visually hidden)."
        }
      ],
      "parts": [
        {
          "name": "CommandPalette",
          "description": "Modal dialog surface (translucent overlay, origin-top pop). data-page = current nested page id.",
          "dataSlot": "command-palette"
        },
        {
          "name": "CommandPaletteSearch",
          "description": "Search row with breadcrumbs for nested pages.",
          "dataSlot": "command-palette-search"
        },
        {
          "name": "CommandPaletteGroup",
          "description": "Group heading (Recent first).",
          "dataSlot": "command-palette-group"
        },
        {
          "name": "CommandPaletteItem",
          "description": "role=option row: icon, label, description, shortcut or chevron. data-active, data-disabled.",
          "dataSlot": "command-palette-item"
        },
        {
          "name": "CommandPaletteLoading",
          "description": "Skeleton rows while loading.",
          "dataSlot": "command-palette-loading"
        },
        {
          "name": "CommandPaletteFooter",
          "description": "Result count and the Enter action (Run / Open).",
          "dataSlot": "command-palette-footer"
        },
        {
          "name": "CommandPaletteTrigger",
          "description": "Search-field-styled button with the shortcut; pass it as `trigger`.",
          "dataSlot": "command-palette-trigger",
          "props": [
            {
              "name": "placeholder",
              "type": "string",
              "default": "\"Search or ask…\"",
              "description": "Button text."
            },
            {
              "name": "shortcut",
              "type": "readonly KeyName[]",
              "default": "[\"mod\", \"k\"]",
              "description": "Shortcut keys shown on the right."
            }
          ]
        }
      ],
      "a11y": {
        "role": "dialog > combobox + listbox",
        "notes": [
          "A modal Radix dialog with a visually hidden title; focus moves to the search input on open and returns to the trigger on close.",
          "The input is a combobox using aria-activedescendant, so screen readers read the highlighted option while focus stays in the field.",
          "The listbox is aria-busy while loading; the footer's result count is a polite live region.",
          "Nested pages are announced through the input's label (“Search Change theme…”) and shown as breadcrumbs."
        ],
        "keyboard": [
          {
            "keys": "Mod+K",
            "action": "Toggle the palette (configurable)"
          },
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Move the highlight (wraps, skips disabled)"
          },
          {
            "keys": "Home / End",
            "action": "First / last item (empty query)"
          },
          {
            "keys": "Enter",
            "action": "Run the item or open its page"
          },
          {
            "keys": "Backspace (empty query) / Escape",
            "action": "Go back one page"
          },
          {
            "keys": "Escape (root page)",
            "action": "Close"
          }
        ],
        "reducedMotion": "The dialog and overlay fade without scale; skeleton rows stop pulsing."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "radix-ui"
        ],
        "kura": [
          "kbd"
        ]
      },
      "related": [
        "slash-command-menu",
        "model-picker",
        "kbd-shortcut",
        "prompt-suggestions"
      ],
      "tags": [
        "command-palette",
        "cmdk",
        "search",
        "launcher",
        "keyboard",
        "actions",
        "menu",
        "agent"
      ],
      "examples": [
        {
          "name": "CommandPaletteMinimal",
          "title": "With a trigger",
          "kind": "minimal",
          "code": "import { FileSearch, MessageSquarePlus, Rocket } from \"lucide-react\";\nimport { CommandPalette, CommandPaletteTrigger } from \"@kura-ui/react/command-palette\";\n\nconst files = [\n  \"src/app/page.tsx\",\n  \"src/app/layout.tsx\",\n  \"src/lib/auth.ts\",\n  \"src/lib/billing/invoice.ts\",\n  \"src/components/chat/composer.tsx\",\n  \"src/components/chat/message-list.tsx\",\n  \"README.md\",\n];\n\nexport function CommandPaletteMinimal() {\n  return (\n    <div className=\"w-72\">\n      <CommandPalette\n        hotkey={null}\n        trigger={<CommandPaletteTrigger />}\n        items={[\n          { id: \"new\", label: \"New chat\", icon: <MessageSquarePlus /> },\n          { id: \"search\", label: \"Search files\", icon: <FileSearch /> },\n          { id: \"deploy\", label: \"Deploy preview\", icon: <Rocket /> },\n        ]}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "CommandPaletteFull",
          "title": "Agent actions, recents and nested pages",
          "description": "Controlled open state, ⌘K, recents, a nested “Switch agent…” page and an async “Run workflow…” page.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Bot, Database, FolderGit2, KeyRound, Monitor, Moon, Palette, Play, Square, Sun, Workflow } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\nimport { CommandPalette, type CommandPaletteItem } from \"@kura-ui/react/command-palette\";\n\nfunction buildItems(log: (s: string) => void): CommandPaletteItem[] {\n  return [\n    {\n      id: \"run-agent\",\n      label: \"Run agent\",\n      description: \"on the current task\",\n      icon: <Play />,\n      group: \"Agent\",\n      shortcut: [\"mod\", \"enter\"],\n      onSelect: () => log(\"Agent started\"),\n    },\n    {\n      id: \"stop-agent\",\n      label: \"Stop agent\",\n      icon: <Square />,\n      group: \"Agent\",\n      shortcut: [\"mod\", \".\"],\n      onSelect: () => log(\"Agent stopped\"),\n    },\n    {\n      id: \"switch-agent\",\n      label: \"Switch agent…\",\n      icon: <Bot />,\n      group: \"Agent\",\n      placeholder: \"Pick an agent…\",\n      children: [\n        {\n          id: \"agent-research\",\n          label: \"Researcher\",\n          description: \"web + citations\",\n          onSelect: () => log(\"Researcher\"),\n        },\n        {\n          id: \"agent-coder\",\n          label: \"Coder\",\n          description: \"repo + terminal\",\n          onSelect: () => log(\"Coder\"),\n        },\n        {\n          id: \"agent-analyst\",\n          label: \"Analyst\",\n          description: \"SQL + charts\",\n          onSelect: () => log(\"Analyst\"),\n        },\n      ],\n    },\n    {\n      id: \"workflows\",\n      label: \"Run workflow…\",\n      icon: <Workflow />,\n      group: \"Agent\",\n      loadChildren: async () => {\n        await wait(1200);\n        return [\n          { id: \"wf-triage\", label: \"Triage new issues\", onSelect: () => log(\"Triage running\") },\n          { id: \"wf-digest\", label: \"Weekly digest\", onSelect: () => log(\"Digest running\") },\n          { id: \"wf-migrate\", label: \"Migrate schema\", disabled: true },\n        ];\n      },\n    },\n    {\n      id: \"context-repo\",\n      label: \"Add repository\",\n      icon: <FolderGit2 />,\n      group: \"Context\",\n      keywords: [\"github\", \"git\"],\n      onSelect: () => log(\"Repository added\"),\n    },\n    {\n      id: \"context-db\",\n      label: \"Connect database\",\n      icon: <Database />,\n      group: \"Context\",\n      keywords: [\"postgres\", \"sql\"],\n      onSelect: () => log(\"Database connected\"),\n    },\n    {\n      id: \"theme\",\n      label: \"Change theme…\",\n      icon: <Palette />,\n      group: \"Settings\",\n      children: [\n        { id: \"theme-light\", label: \"Light\", icon: <Sun />, onSelect: () => log(\"Light theme\") },\n        { id: \"theme-dark\", label: \"Dark\", icon: <Moon />, onSelect: () => log(\"Dark theme\") },\n        {\n          id: \"theme-system\",\n          label: \"System\",\n          icon: <Monitor />,\n          onSelect: () => log(\"System theme\"),\n        },\n      ],\n    },\n    {\n      id: \"keys\",\n      label: \"API keys\",\n      icon: <KeyRound />,\n      group: \"Settings\",\n      shortcut: [\"mod\", \",\"],\n      onSelect: () => log(\"API keys\"),\n    },\n  ];\n}\n\nconst wait = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\nexport function CommandPaletteFull() {\n  const [open, setOpen] = React.useState(false);\n  const [last, setLast] = React.useState(\"Nothing run yet\");\n  const [recent, setRecent] = React.useState<string[]>([\"context-repo\", \"run-agent\"]);\n  const items = React.useMemo(() => buildItems(setLast), []);\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>\n        Open palette\n      </Button>\n      <CommandPalette\n        items={items}\n        open={open}\n        onOpenChange={setOpen}\n        hotkey={[\"mod\", \"k\"]}\n        recent={recent}\n        onRecentChange={setRecent}\n        recentLimit={3}\n        placeholder=\"Run an agent, add context, change settings…\"\n        emptyMessage=\"No commands match\"\n        label=\"Agent commands\"\n      />\n      <KbdShortcut keys={[\"mod\", \"k\"]} label=\"or press\" />\n      <p className=\"text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n        {last}\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "CommandPaletteAsyncSearch",
          "title": "Server-side search",
          "description": "onQueryChange fetches results into `items` while `loading` shows skeleton rows.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { FileSearch } from \"lucide-react\";\nimport * as React from \"react\";\nimport { CommandPalette, type CommandPaletteItem, CommandPaletteTrigger } from \"@kura-ui/react/command-palette\";\n\nconst files = [\n  \"src/app/page.tsx\",\n  \"src/app/layout.tsx\",\n  \"src/lib/auth.ts\",\n  \"src/lib/billing/invoice.ts\",\n  \"src/components/chat/composer.tsx\",\n  \"src/components/chat/message-list.tsx\",\n  \"README.md\",\n];\n\nexport function CommandPaletteAsyncSearch() {\n  const [results, setResults] = React.useState<CommandPaletteItem[]>([]);\n  const [loading, setLoading] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const search = (q: string) => {\n    if (timer.current) clearTimeout(timer.current);\n    if (!q.trim()) {\n      setResults([]);\n      setLoading(false);\n      return;\n    }\n    setLoading(true);\n    timer.current = setTimeout(() => {\n      setResults(\n        files\n          .filter((f) => f.toLowerCase().includes(q.toLowerCase().split(\" \")[0] ?? \"\"))\n          .map((f) => ({\n            id: f,\n            label: f.split(\"/\").pop() ?? f,\n            description: f,\n            icon: <FileSearch />,\n            group: \"Files\",\n          })),\n      );\n      setLoading(false);\n    }, 500);\n  };\n\n  return (\n    <div className=\"w-80\">\n      <CommandPalette\n        hotkey={null}\n        trigger={\n          <CommandPaletteTrigger placeholder=\"Search the codebase…\" shortcut={[\"mod\", \"p\"]} />\n        }\n        items={results}\n        loading={loading}\n        onQueryChange={search}\n        placeholder=\"Type a file name…\"\n        emptyMessage=\"Type to search 7 files\"\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Mount one palette near the app root; it portals itself. Only one instance should bind ⌘K — pass hotkey={null} to others.",
        "Use `children` for static sub-pages and `loadChildren` for fetched ones (the loading state is automatic).",
        "Persist recents with recent/onRecentChange (e.g. localStorage); recents resolve ids anywhere in the item tree.",
        "Shortcuts on items are display-only; bind them with useHotkey so they also work when the palette is closed.",
        "scoreCommand(text, query) is exported if you need the same fuzzy ranking elsewhere."
      ],
      "title": "Command Palette",
      "importPath": "@kura-ui/react/command-palette",
      "exports": [
        "CommandPalette",
        "CommandPaletteItem",
        "CommandPaletteProps",
        "CommandPaletteTrigger",
        "CommandPaletteTriggerProps",
        "scoreCommand"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/command-palette",
      "markdownUrl": "https://kuro.abstergo.fyi/components/command-palette.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/command-palette.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/command-palette.json",
        "cli": "npx @kura-ui/cli add command-palette",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kbd",
        "kura-keys",
        "use-controllable-state",
        "use-hotkey",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "items": {
            "description": "Items: { id, label, description?, icon?, group?, keywords?, shortcut?, onSelect?, children?, loadChildren?, placeholder?, disabled? }. children/loadChildren open a nested page.",
            "x-ts-type": "readonly CommandPaletteItem[]",
            "type": "array"
          },
          "open": {
            "description": "Controlled open state.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultOpen": {
            "description": "Initial open state.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onOpenChange": {
            "description": "Open state changes.",
            "x-ts-type": "(open: boolean) => void"
          },
          "hotkey": {
            "description": "Global toggle shortcut (works in inputs). null disables it.",
            "x-ts-type": "readonly KeyName[] | null",
            "default": [
              "mod",
              "k"
            ]
          },
          "trigger": {
            "description": "Element rendered as the dialog trigger, e.g. <CommandPaletteTrigger />.",
            "x-ts-type": "React.ReactElement"
          },
          "recent": {
            "description": "Controlled recent item ids (most recent first), shown as “Recent” when the query is empty.",
            "x-ts-type": "string[]",
            "type": "array"
          },
          "defaultRecent": {
            "description": "Initial recents when uncontrolled.",
            "x-ts-type": "string[]",
            "type": "array"
          },
          "onRecentChange": {
            "description": "Called when an item is used (persist it to localStorage if you like).",
            "x-ts-type": "(ids: string[]) => void"
          },
          "recentLimit": {
            "description": "Max recents shown.",
            "x-ts-type": "number",
            "type": "number",
            "default": 4
          },
          "onSelect": {
            "description": "Called after any leaf item runs (in addition to item.onSelect).",
            "x-ts-type": "(item: CommandPaletteItem) => void"
          },
          "onQueryChange": {
            "description": "Every query change — fetch server results into `items` and toggle `loading`.",
            "x-ts-type": "(query: string) => void"
          },
          "loading": {
            "description": "Show skeleton rows and aria-busy (root page). Nested pages with loadChildren handle this themselves.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "placeholder": {
            "description": "Search placeholder on the root page.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Search or run a command…"
          },
          "emptyMessage": {
            "description": "Shown when nothing matches.",
            "x-ts-type": "string",
            "type": "string",
            "default": "No results"
          },
          "label": {
            "description": "Accessible dialog title (visually hidden).",
            "x-ts-type": "string",
            "type": "string",
            "default": "Command palette"
          }
        },
        "required": [
          "items"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/command-palette/command-palette.tsx",
          "type": "registry:ui",
          "content": "// ARIA combobox + listbox: the search input keeps focus and points at the highlighted option with\n// aria-activedescendant, so options are intentionally not focusable and have no own key handlers.\n// biome-ignore-all lint/a11y/useSemanticElements: rich rows cannot be native <option>/<fieldset>.\n// biome-ignore-all lint/a11y/useFocusableInteractive: focus stays in the search input (activedescendant).\n// biome-ignore-all lint/a11y/useKeyWithClickEvents: keyboard selection is handled by the input.\n\"use client\";\n\nimport { ChevronRight, CornerDownLeft, Search } from \"lucide-react\";\nimport { Dialog as DialogPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Kbd } from \"@/components/kura/kbd\";\n\nexport interface CommandPaletteItem {\n  /** Stable id (also used for recents). */\n  id: string;\n  label: string;\n  /** Muted text after the label. */\n  description?: string;\n  /** 16px icon. */\n  icon?: React.ReactNode;\n  /** Group heading. Groups keep first-seen order. */\n  group?: string;\n  /** Extra search terms. */\n  keywords?: readonly string[];\n  /** Shortcut shown on the right (display only — bind it with useHotkey). */\n  shortcut?: readonly KeyName[];\n  /** Run the action. The palette closes afterwards unless the item opens a page. */\n  onSelect?: () => void;\n  /** Open a nested page with these items. */\n  children?: readonly CommandPaletteItem[];\n  /** Open a nested page whose items load asynchronously (a loading state shows meanwhile). */\n  loadChildren?: () => Promise<readonly CommandPaletteItem[]>;\n  /** Search placeholder for the nested page. */\n  placeholder?: string;\n  disabled?: boolean;\n}\n\nexport interface CommandPaletteProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> {\n  items: readonly CommandPaletteItem[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Global shortcut that toggles the palette. Default [\"mod\", \"k\"]. null disables it. */\n  hotkey?: readonly KeyName[] | null;\n  /** Element that opens the palette (rendered as the dialog trigger), e.g. <CommandPaletteTrigger />. */\n  trigger?: React.ReactElement;\n  /** Controlled recent item ids, most recent first. Shown as “Recent” when the query is empty. */\n  recent?: string[];\n  /** Initial recent ids when uncontrolled. */\n  defaultRecent?: string[];\n  onRecentChange?: (ids: string[]) => void;\n  /** Max recents shown. Default 4. */\n  recentLimit?: number;\n  /** Called after any item is selected (in addition to the item's onSelect). */\n  onSelect?: (item: CommandPaletteItem) => void;\n  /** Called on every query change, e.g. to fetch server results into `items`. */\n  onQueryChange?: (query: string) => void;\n  /** Show loading rows (e.g. while async results arrive). */\n  loading?: boolean;\n  /** Default \"Search or run a command…\". */\n  placeholder?: string;\n  /** Default \"No results\". */\n  emptyMessage?: string;\n  /** Accessible title of the dialog. Default \"Command palette\". */\n  label?: string;\n}\n\ninterface Page {\n  id: string;\n  title: string;\n  items: readonly CommandPaletteItem[];\n  placeholder?: string;\n  loading: boolean;\n}\n\n/** Fuzzy score: contiguous and word-start matches rank higher. 0 = no match. */\nexport function scoreCommand(text: string, query: string): number {\n  const t = text.toLowerCase();\n  const q = query.toLowerCase().trim();\n  if (!q) return 1;\n  if (t === q) return 100;\n  if (t.startsWith(q)) return 80;\n  const idx = t.indexOf(q);\n  if (idx > 0 && /[\\s\\-_/.]/.test(t[idx - 1] ?? \"\")) return 60;\n  if (idx >= 0) return 40;\n  // subsequence\n  let ti = 0;\n  let score = 0;\n  let streak = 0;\n  for (const ch of q) {\n    if (ch === \" \") continue;\n    const found = t.indexOf(ch, ti);\n    if (found === -1) return 0;\n    streak = found === ti ? streak + 1 : 0;\n    score += 1 + streak + (found === 0 || /[\\s\\-_/.]/.test(t[found - 1] ?? \"\") ? 2 : 0);\n    ti = found + 1;\n  }\n  return Math.min(35, score);\n}\n\nfunction itemScore(item: CommandPaletteItem, query: string): number {\n  const label = scoreCommand(item.label, query);\n  const extra = Math.max(\n    scoreCommand(item.description ?? \"\", query) * 0.5,\n    ...(item.keywords ?? []).map((k) => scoreCommand(k, query) * 0.7),\n    item.group ? scoreCommand(item.group, query) * 0.3 : 0,\n  );\n  return Math.max(label, query ? extra : 0);\n}\n\nconst nested = (i: CommandPaletteItem) => !!(i.children || i.loadChildren);\n\n/**\n * ⌘K command palette for agent actions: fuzzy search, groups, recents, nested pages (with async\n * loading) and shortcut hints. Opens with the hotkey or an optional trigger.\n */\nexport function CommandPalette({\n  items,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  hotkey = [\"mod\", \"k\"],\n  trigger,\n  recent: recentProp,\n  defaultRecent,\n  onRecentChange,\n  recentLimit = 4,\n  onSelect,\n  onQueryChange,\n  loading = false,\n  placeholder = \"Search or run a command…\",\n  emptyMessage = \"No results\",\n  label = \"Command palette\",\n  className,\n  ...props\n}: CommandPaletteProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  const [recent, setRecent] = useControllableState<string[]>({\n    value: recentProp,\n    defaultValue: defaultRecent ?? [],\n    onChange: onRecentChange,\n  });\n  const [query, setQueryState] = React.useState(\"\");\n  const [pages, setPages] = React.useState<Page[]>([]);\n  const [active, setActive] = React.useState<string | null>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const pointerOrigin = React.useRef<{ x: number; y: number } | null>(null);\n  const baseId = React.useId();\n  const listId = `${baseId}-list`;\n  const optionId = React.useCallback(\n    (key: string) => `${baseId}-o-${key.replace(/[^a-zA-Z0-9_-]/g, \"_\")}`,\n    [baseId],\n  );\n\n  const setQuery = (q: string) => {\n    setQueryState(q);\n    // A new query re-ranks the list: highlight its best match again.\n    setActive(null);\n    onQueryChange?.(q);\n  };\n\n  useHotkey(hotkey, () => setOpen((o) => !o), { enabled: hotkey !== null, allowInInputs: true });\n\n  // Reset when closed.\n  React.useEffect(() => {\n    if (open) return;\n    setQueryState(\"\");\n    setPages([]);\n    setActive(null);\n    pointerOrigin.current = null;\n  }, [open]);\n\n  const page = pages[pages.length - 1];\n  const source = page ? page.items : items;\n  const isLoading = page ? page.loading : loading;\n\n  // Flatten the whole tree for recents lookup.\n  const byId = React.useMemo(() => {\n    const map = new Map<string, CommandPaletteItem>();\n    const walk = (list: readonly CommandPaletteItem[]) => {\n      for (const i of list) {\n        map.set(i.id, i);\n        if (i.children) walk(i.children);\n      }\n    };\n    walk(items);\n    return map;\n  }, [items]);\n\n  type Row = { key: string; item: CommandPaletteItem; group: string };\n  const rows = React.useMemo<Row[]>(() => {\n    const q = query.trim();\n    const out: Row[] = [];\n    if (!q && !page && recent.length > 0) {\n      for (const id of recent.slice(0, recentLimit)) {\n        const item = byId.get(id);\n        if (item) out.push({ key: `recent:${id}`, item, group: \"Recent\" });\n      }\n    }\n    const scored = source\n      .map((item, index) => ({ item, index, s: itemScore(item, q) }))\n      .filter((r) => r.s > 0);\n    if (q) scored.sort((a, b) => b.s - a.s || a.index - b.index);\n    // Keep groups together, ordered by their best item.\n    const order: string[] = [];\n    const grouped = new Map<string, CommandPaletteItem[]>();\n    for (const { item } of scored) {\n      const g = item.group ?? \"\";\n      if (!grouped.has(g)) {\n        grouped.set(g, []);\n        order.push(g);\n      }\n      grouped.get(g)?.push(item);\n    }\n    for (const g of order)\n      for (const item of grouped.get(g) ?? []) out.push({ key: item.id, item, group: g });\n    return out;\n  }, [query, page, recent, recentLimit, byId, source]);\n\n  const enabledRows = React.useMemo(() => rows.filter((r) => !r.item.disabled), [rows]);\n  // Derived synchronously so Enter right after typing always hits the best match, even before\n  // the effect below has re-synced `active`.\n  const current =\n    active && enabledRows.some((r) => r.key === active) ? active : (enabledRows[0]?.key ?? null);\n\n  React.useEffect(() => {\n    setActive((current) =>\n      current && enabledRows.some((r) => r.key === current)\n        ? current\n        : (enabledRows[0]?.key ?? null),\n    );\n  }, [enabledRows]);\n\n  React.useEffect(() => {\n    if (!active) return;\n    document.getElementById(optionId(active))?.scrollIntoView?.({ block: \"nearest\" });\n  }, [active, optionId]);\n\n  const pushPage = (item: CommandPaletteItem) => {\n    const next: Page = {\n      id: item.id,\n      title: item.label,\n      items: item.children ?? [],\n      placeholder: item.placeholder,\n      loading: !item.children && !!item.loadChildren,\n    };\n    setPages((p) => [...p, next]);\n    setQueryState(\"\");\n    setActive(null);\n    if (!item.children && item.loadChildren) {\n      item\n        .loadChildren()\n        .then((loaded) =>\n          setPages((p) =>\n            p.map((pg) => (pg === next ? { ...pg, items: loaded, loading: false } : pg)),\n          ),\n        )\n        .catch(() =>\n          setPages((p) => p.map((pg) => (pg === next ? { ...pg, items: [], loading: false } : pg))),\n        );\n    }\n    inputRef.current?.focus();\n  };\n\n  const popPage = () => {\n    setPages((p) => p.slice(0, -1));\n    setQueryState(\"\");\n    setActive(null);\n  };\n\n  const choose = (item: CommandPaletteItem) => {\n    if (item.disabled) return;\n    setRecent((list) => [item.id, ...list.filter((id) => id !== item.id)].slice(0, 20));\n    if (nested(item)) {\n      pushPage(item);\n      return;\n    }\n    item.onSelect?.();\n    onSelect?.(item);\n    setOpen(false);\n  };\n\n  const move = (delta: number | \"first\" | \"last\") => {\n    if (!enabledRows.length) return;\n    const i = enabledRows.findIndex((r) => r.key === current);\n    const next =\n      delta === \"first\"\n        ? 0\n        : delta === \"last\"\n          ? enabledRows.length - 1\n          : i === -1\n            ? 0\n            : (i + delta + enabledRows.length) % enabledRows.length;\n    setActive(enabledRows[next]?.key ?? null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      move(1);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      move(-1);\n    } else if (e.key === \"Home\" && !query) {\n      e.preventDefault();\n      move(\"first\");\n    } else if (e.key === \"End\" && !query) {\n      e.preventDefault();\n      move(\"last\");\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const row = rows.find((r) => r.key === current);\n      if (row) choose(row.item);\n    } else if (e.key === \"Backspace\" && !query && pages.length > 0) {\n      e.preventDefault();\n      popPage();\n    } else if (e.key === \"Escape\" && pages.length > 0) {\n      e.preventDefault();\n      e.stopPropagation();\n      popPage();\n    }\n  };\n\n  let lastGroup: string | null = null;\n  const activeRow = rows.find((r) => r.key === current);\n\n  return (\n    <DialogPrimitive.Root open={open} onOpenChange={setOpen}>\n      {trigger && <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>}\n      <DialogPrimitive.Portal>\n        <DialogPrimitive.Overlay\n          data-slot=\"command-palette-overlay\"\n          className=\"fixed inset-0 z-50 bg-kura-ink/15 backdrop-blur-[2px] data-[state=closed]:animate-kura-fade-out data-[state=open]:animate-kura-fade-in\"\n        />\n        <DialogPrimitive.Content\n          data-slot=\"command-palette\"\n          data-page={page?.id}\n          aria-describedby={undefined}\n          onOpenAutoFocus={(e) => {\n            e.preventDefault();\n            inputRef.current?.focus();\n          }}\n          className={cn(\n            \"-translate-x-1/2 fixed top-[min(20vh,160px)] left-1/2 z-50 flex max-h-[min(520px,calc(100vh-120px))] w-[min(640px,calc(100vw-32px))] flex-col overflow-hidden\",\n            \"rounded-kura-xl bg-kura-overlay font-kura-sans text-kura-fg shadow-kura-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n            \"origin-top data-[state=closed]:animate-kura-pop-out data-[state=open]:animate-kura-pop-in\",\n            className,\n          )}\n          {...props}\n        >\n          <DialogPrimitive.Title className=\"sr-only\">{label}</DialogPrimitive.Title>\n          <div\n            data-slot=\"command-palette-search\"\n            className=\"flex h-13 shrink-0 items-center gap-2.5 px-4 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n          >\n            <Search aria-hidden className=\"size-4 shrink-0 text-kura-fg-subtle\" />\n            {pages.length > 0 && (\n              <span\n                data-slot=\"command-palette-breadcrumbs\"\n                className=\"flex shrink-0 items-center gap-1\"\n              >\n                {pages.map((p) => (\n                  <span\n                    key={p.id}\n                    className=\"rounded-kura-sm bg-kura-active px-1.5 py-0.5 font-medium text-kura-fg-muted text-xs\"\n                  >\n                    {p.title}\n                  </span>\n                ))}\n              </span>\n            )}\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={current ? optionId(current) : undefined}\n              aria-label={page ? `Search ${page.title}` : \"Search commands\"}\n              value={query}\n              onChange={(e) => setQuery(e.currentTarget.value)}\n              onKeyDown={onKeyDown}\n              placeholder={\n                page?.placeholder ??\n                (page\n                  ? `Search ${page.title.replace(/(…|\\.\\.\\.)$/, \"\").toLowerCase()}…`\n                  : placeholder)\n              }\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[15px] text-kura-fg outline-none placeholder:text-kura-fg-subtle\"\n            />\n            <Kbd\n              keys={[\"esc\"]}\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"shrink-0 text-kura-fg-subtle\"\n            />\n          </div>\n\n          <div\n            id={listId}\n            role=\"listbox\"\n            aria-label={page ? page.title : \"Commands\"}\n            aria-busy={isLoading || undefined}\n            className=\"min-h-0 flex-1 scroll-py-2 overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]\"\n          >\n            {rows.map((row) => {\n              const heading = row.group !== lastGroup ? row.group : null;\n              lastGroup = row.group;\n              const isActive = row.key === current;\n              const { item } = row;\n              return (\n                <React.Fragment key={row.key}>\n                  {heading !== null && heading !== \"\" && (\n                    <div\n                      role=\"presentation\"\n                      data-slot=\"command-palette-group\"\n                      className=\"select-none px-2.5 pt-3 pb-1.5 font-medium text-[11px] text-kura-fg-subtle first:pt-1\"\n                    >\n                      {heading}\n                    </div>\n                  )}\n                  <div\n                    id={optionId(row.key)}\n                    role=\"option\"\n                    aria-selected={isActive}\n                    aria-disabled={item.disabled || undefined}\n                    data-slot=\"command-palette-item\"\n                    data-active={isActive || undefined}\n                    data-disabled={item.disabled || undefined}\n                    onPointerMove={(e) => {\n                      const o = pointerOrigin.current;\n                      if (!o) {\n                        pointerOrigin.current = { x: e.clientX, y: e.clientY };\n                        return;\n                      }\n                      if (o.x === e.clientX && o.y === e.clientY) return;\n                      if (!item.disabled && !isActive) setActive(row.key);\n                    }}\n                    onClick={() => choose(item)}\n                    className=\"flex h-10 cursor-pointer select-none items-center gap-3 rounded-kura-md px-2.5 text-sm data-active:bg-kura-hover data-disabled:cursor-not-allowed data-disabled:opacity-45\"\n                  >\n                    <span\n                      aria-hidden\n                      className=\"flex size-4 shrink-0 items-center justify-center text-kura-fg-muted [&_svg]:size-4\"\n                    >\n                      {item.icon}\n                    </span>\n                    <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                      <span className=\"truncate text-kura-fg\">{item.label}</span>\n                      {item.description && (\n                        <span className=\"truncate text-[13px] text-kura-fg-subtle\">\n                          {item.description}\n                        </span>\n                      )}\n                    </span>\n                    {item.shortcut && (\n                      <Kbd keys={item.shortcut} variant=\"ghost\" size=\"sm\" className=\"shrink-0\" />\n                    )}\n                    {nested(item) && (\n                      <ChevronRight aria-hidden className=\"size-4 shrink-0 text-kura-fg-subtle\" />\n                    )}\n                  </div>\n                </React.Fragment>\n              );\n            })}\n            {isLoading && (\n              <div\n                data-slot=\"command-palette-loading\"\n                aria-hidden\n                className=\"flex flex-col gap-1 pt-1\"\n              >\n                {[0.62, 0.48, 0.55].map((w, i) => (\n                  <div key={i} className=\"flex h-10 items-center gap-3 px-2.5\">\n                    <span className=\"size-4 animate-kura-pulse rounded-kura-xs bg-kura-active motion-reduce:animate-none\" />\n                    <span\n                      className=\"h-2.5 animate-kura-pulse rounded-full bg-kura-active motion-reduce:animate-none\"\n                      style={{ width: `${w * 100}%`, animationDelay: `${i * 120}ms` }}\n                    />\n                  </div>\n                ))}\n              </div>\n            )}\n            {!isLoading && rows.length === 0 && (\n              <div className=\"px-3 py-10 text-center text-kura-fg-subtle text-sm\">\n                {emptyMessage}\n              </div>\n            )}\n          </div>\n\n          <div\n            data-slot=\"command-palette-footer\"\n            className=\"flex h-10 shrink-0 items-center justify-between gap-3 px-4 text-kura-fg-subtle text-xs shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n          >\n            <span className=\"truncate\" aria-live=\"polite\">\n              {isLoading\n                ? \"Loading…\"\n                : pages.length > 0\n                  ? \"⌫ to go back\"\n                  : `${enabledRows.length} ${enabledRows.length === 1 ? \"result\" : \"results\"}`}\n            </span>\n            <span className=\"flex shrink-0 items-center gap-1.5\">\n              {activeRow && nested(activeRow.item) ? \"Open\" : \"Run\"}\n              <span\n                aria-hidden\n                className=\"inline-flex size-[18px] items-center justify-center rounded-kura-xs bg-kura-hover\"\n              >\n                <CornerDownLeft className=\"size-3\" />\n              </span>\n            </span>\n          </div>\n        </DialogPrimitive.Content>\n      </DialogPrimitive.Portal>\n    </DialogPrimitive.Root>\n  );\n}\n\nexport interface CommandPaletteTriggerProps\n  extends Omit<React.ComponentProps<\"button\">, \"children\"> {\n  /** Default \"Search or ask…\". */\n  placeholder?: string;\n  /** Shortcut shown on the right. Default [\"mod\", \"k\"]. */\n  shortcut?: readonly KeyName[];\n}\n\n/** A search-field-looking button that opens the palette. Pass it as `trigger`. */\nexport function CommandPaletteTrigger({\n  placeholder = \"Search or ask…\",\n  shortcut = [\"mod\", \"k\"],\n  className,\n  ...props\n}: CommandPaletteTriggerProps) {\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"command-palette-trigger\"\n      className={cn(\n        \"inline-flex h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-kura-lg bg-kura-surface pr-1.5 pl-3 font-kura-sans text-kura-fg-subtle text-sm shadow-kura-sm outline-none\",\n        \"transition-[box-shadow,color] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg-muted focus-visible:shadow-kura-focus\",\n        className,\n      )}\n      {...props}\n    >\n      <Search aria-hidden className=\"size-4 shrink-0\" />\n      <span className=\"flex-1 truncate text-left\">{placeholder}</span>\n      <Kbd keys={shortcut} size=\"sm\" />\n    </button>\n  );\n}\n"
        },
        {
          "target": "components/kura/command-palette/index.ts",
          "type": "registry:ui",
          "content": "export {\n  CommandPalette,\n  type CommandPaletteItem,\n  type CommandPaletteProps,\n  CommandPaletteTrigger,\n  type CommandPaletteTriggerProps,\n  scoreCommand,\n} from \"./command-palette\";\n"
        }
      ]
    },
    {
      "slug": "composer",
      "name": "Composer",
      "category": "input",
      "status": "beta",
      "description": "Prompt input for chat and agents: auto-growing textarea, attachments, toolbar slot, send→stop button, paste/drop files and a length hint.",
      "whenToUse": [
        "The message box of a chat UI or agent console (“Ask anything…”).",
        "Any prompt field that should send on Enter, accept pasted or dropped files, and stop a streaming response.",
        "A prompt input that hosts a model picker, tool toggles, voice input or a slash-command menu in its toolbar."
      ],
      "whenNotToUse": [
        "A single-line search or filter field — use a plain input or command-palette.",
        "Showing attachments outside an input (sent messages, file lists) — use attachment.",
        "Picking a model on its own — use model-picker.",
        "Voice-only input — use voice-dictation (it can also feed a composer)."
      ],
      "props": [
        {
          "name": "value",
          "type": "string",
          "description": "Controlled text."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "default": "\"\"",
          "description": "Initial text when uncontrolled."
        },
        {
          "name": "onValueChange",
          "type": "(value: string) => void",
          "description": "Called on every text change."
        },
        {
          "name": "attachments",
          "type": "AttachmentItem[]",
          "description": "Controlled attachments ({ id, name, size?, type?, url?, status?, progress?, error?, file? })."
        },
        {
          "name": "defaultAttachments",
          "type": "AttachmentItem[]",
          "description": "Initial attachments when uncontrolled."
        },
        {
          "name": "onAttachmentsChange",
          "type": "(attachments: AttachmentItem[]) => void",
          "description": "Called when files are added or removed."
        },
        {
          "name": "onFilesAdded",
          "type": "(items: AttachmentItem[]) => void",
          "description": "Called with newly accepted files (paste, drop or picker) — start uploads here and update `attachments` with status/progress."
        },
        {
          "name": "onFilesRejected",
          "type": "(files: File[]) => void",
          "description": "Called with files rejected by accept, maxFileSize or maxFiles."
        },
        {
          "name": "onSubmit",
          "type": "(message: ComposerMessage) => void",
          "description": "Send handler: receives { text (trimmed), attachments }. Not called while empty, over maxLength, uploading or busy."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "description": "Called by the stop button and by Escape while status is submitted or streaming."
        },
        {
          "name": "status",
          "type": "\"idle\" | \"submitted\" | \"streaming\" | \"error\"",
          "default": "\"idle\"",
          "description": "Request lifecycle. submitted = waiting for the first token (stop button with a travelling arc), streaming = model writing (stop button)."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "default": "false",
          "description": "Disable typing, attaching and sending (60% opacity)."
        },
        {
          "name": "maxLength",
          "type": "number",
          "description": "Character limit. The hint turns amber at 90% and red above; sending is blocked when over."
        },
        {
          "name": "submitKey",
          "type": "\"enter\" | \"mod+enter\"",
          "default": "\"enter\"",
          "description": "enter = Enter sends and Shift+Enter adds a newline; mod+enter = ⌘/Ctrl+Enter sends and Enter adds a newline."
        },
        {
          "name": "accept",
          "type": "string | false",
          "default": "\"\"",
          "description": "Allowed files, like <input accept> (\"image/*,.pdf\"). \"\" accepts any file; false disables attachments entirely."
        },
        {
          "name": "maxFiles",
          "type": "number",
          "description": "Maximum number of attachments."
        },
        {
          "name": "maxFileSize",
          "type": "number",
          "description": "Maximum size of one file in bytes."
        },
        {
          "name": "clearOnSubmit",
          "type": "boolean",
          "default": "true",
          "description": "Clear text and attachments after a submit."
        },
        {
          "name": "placeholder",
          "type": "string",
          "description": "Placeholder for the default layout (only used when Composer has no children)."
        }
      ],
      "parts": [
        {
          "name": "Composer",
          "description": "Root and drop target. data-status, data-focused, data-dragging, data-disabled; aria-busy while submitted/streaming. With no children it renders the complete default layout.",
          "dataSlot": "composer"
        },
        {
          "name": "ComposerAttachments",
          "description": "The attachment row (renders nothing when empty). Uses attachment's AttachmentList.",
          "dataSlot": "composer-attachments",
          "props": [
            {
              "name": "max",
              "type": "number",
              "description": "Collapse into a +N stack after this many."
            }
          ]
        },
        {
          "name": "ComposerInput",
          "description": "Auto-growing <textarea>. Enter/Shift+Enter, IME-safe, paste files, Escape stops. Accepts all textarea props.",
          "dataSlot": "composer-input",
          "props": [
            {
              "name": "minRows",
              "type": "number",
              "default": "1",
              "description": "Rows when empty."
            },
            {
              "name": "maxRows",
              "type": "number",
              "default": "8",
              "description": "Rows before it scrolls instead of growing."
            }
          ]
        },
        {
          "name": "ComposerToolbar",
          "description": "Bottom row; holds ComposerTools (left) and ComposerActions (right).",
          "dataSlot": "composer-toolbar"
        },
        {
          "name": "ComposerTools",
          "description": "Left group: attach button, model-picker, tool toggles.",
          "dataSlot": "composer-tools"
        },
        {
          "name": "ComposerActions",
          "description": "Right group: hint, voice-dictation, submit.",
          "dataSlot": "composer-actions"
        },
        {
          "name": "ComposerAttachButton",
          "description": "Paperclip icon button that opens the file picker.",
          "dataSlot": "composer-attach",
          "props": [
            {
              "name": "label",
              "type": "string",
              "default": "\"Attach files\"",
              "description": "Accessible label and tooltip."
            },
            {
              "name": "icon",
              "type": "React.ReactNode",
              "description": "Replace the paperclip."
            }
          ]
        },
        {
          "name": "ComposerHint",
          "description": "Character count with a mini gauge (with maxLength) or a ~token estimate. data-level = ok | near | over.",
          "dataSlot": "composer-hint",
          "props": [
            {
              "name": "mode",
              "type": "\"characters\" | \"tokens\"",
              "description": "Default: characters when maxLength is set, tokens otherwise."
            },
            {
              "name": "showAt",
              "type": "number",
              "default": "0",
              "description": "Only show once text reaches this fraction of maxLength (e.g. 0.8)."
            },
            {
              "name": "countTokens",
              "type": "(text: string) => number",
              "description": "Custom tokenizer for mode=tokens (default ≈ chars/4)."
            }
          ]
        },
        {
          "name": "ComposerSubmit",
          "description": "Send button (ink when ready, quiet when empty) that becomes a stop button while busy; data-mode = send | stop.",
          "dataSlot": "composer-submit",
          "props": [
            {
              "name": "sendLabel",
              "type": "string",
              "default": "\"Send message\"",
              "description": "Accessible label while idle."
            },
            {
              "name": "stopLabel",
              "type": "string",
              "default": "\"Stop generating\"",
              "description": "Accessible label while busy."
            },
            {
              "name": "tooltip",
              "type": "boolean",
              "default": "true",
              "description": "Tooltip with shortcut."
            }
          ]
        }
      ],
      "a11y": {
        "notes": [
          "The textarea is labelled “Message” by default (override with aria-label or aria-labelledby) and has enterkeyhint=send.",
          "Over the character limit the textarea gets aria-invalid and the hint tells screen readers the limit was exceeded (announced once, not per keystroke).",
          "The submit button swaps its accessible name between “Send message” and “Stop generating”; the root sets aria-busy while a response is pending.",
          "The focus glow is on the whole composer (focus-within), so the textarea's own outline is replaced, never just removed.",
          "Attachments are a labelled list with “Remove <file>” buttons; removing returns focus to the textarea."
        ],
        "keyboard": [
          {
            "keys": "Enter",
            "action": "Send (submitKey=enter). Ignored while an IME is composing."
          },
          {
            "keys": "Shift+Enter",
            "action": "Insert a newline"
          },
          {
            "keys": "Mod+Enter",
            "action": "Send (both submitKey modes)"
          },
          {
            "keys": "Escape",
            "action": "Stop the response while submitted/streaming"
          },
          {
            "keys": "Mod+V",
            "action": "Paste text, or attach pasted files/images"
          }
        ],
        "reducedMotion": "The send/stop icon swap and attachment enter/exit become plain fades (no scale or blur); the pending arc stops travelling and pulses instead."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "motion"
        ],
        "kura": [
          "attachment",
          "icon-button",
          "tooltip"
        ]
      },
      "related": [
        "attachment",
        "model-picker",
        "slash-command-menu",
        "voice-dictation",
        "prompt-suggestions"
      ],
      "tags": [
        "prompt",
        "chat",
        "input",
        "textarea",
        "composer",
        "send",
        "attachments",
        "streaming"
      ],
      "examples": [
        {
          "name": "ComposerMinimal",
          "title": "Default layout",
          "description": "No children: textarea, attach button, hint and send button.",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport { Composer } from \"@kura-ui/react/composer\";\n\nexport function ComposerMinimal() {\n  return (\n    <div className=\"w-full max-w-xl\">\n      <Composer placeholder=\"Ask anything…\" onSubmit={(m) => console.log(m.text)} />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ComposerFull",
          "title": "Controlled, with model picker and tools",
          "description": "Controlled text, simulated submitted → streaming lifecycle with stop, file limits, a character limit and tool toggles.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Globe, Lightbulb } from \"lucide-react\";\nimport * as React from \"react\";\nimport { ModelPicker, type ModelPickerModel } from \"@kura-ui/react/model-picker\";\nimport {\n  Composer,\n  ComposerActions,\n  ComposerAttachButton,\n  ComposerAttachments,\n  ComposerHint,\n  ComposerInput,\n  type ComposerMessage,\n  type ComposerStatus,\n  ComposerSubmit,\n  ComposerToolbar,\n  ComposerTools,\n} from \"@kura-ui/react/composer\";\n\nconst models: ModelPickerModel[] = [\n  {\n    id: \"opus\",\n    name: \"Claude Opus 5.5\",\n    provider: \"Anthropic\",\n    description: \"Most capable for complex, multi-step work.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 1,\n    cost: 3,\n  },\n  {\n    id: \"sonnet\",\n    name: \"Claude Sonnet 5.5\",\n    provider: \"Anthropic\",\n    description: \"Balanced speed and intelligence.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"haiku\",\n    name: \"Claude Haiku 4.5\",\n    provider: \"Anthropic\",\n    description: \"Fastest, for quick answers.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 200_000,\n    speed: 3,\n    cost: 1,\n  },\n];\n\nfunction ToolToggle({\n  pressed,\n  onPressedChange,\n  icon,\n  children,\n}: {\n  pressed: boolean;\n  onPressedChange: (pressed: boolean) => void;\n  icon: React.ReactNode;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={pressed}\n      onClick={() => onPressedChange(!pressed)}\n      className=\"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-kura-lg px-2.5 font-medium text-[13px] text-kura-fg-muted outline-none transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover hover:text-kura-fg focus-visible:shadow-kura-focus aria-pressed:bg-kura-active aria-pressed:text-kura-fg [&_svg]:size-3.5\"\n    >\n      {icon}\n      {children}\n    </button>\n  );\n}\n\nexport function ComposerFull() {\n  const [value, setValue] = React.useState(\"\");\n  const [status, setStatus] = React.useState<ComposerStatus>(\"idle\");\n  const [model, setModel] = React.useState(\"sonnet\");\n  const [search, setSearch] = React.useState(true);\n  const [think, setThink] = React.useState(false);\n  const [last, setLast] = React.useState<ComposerMessage | null>(null);\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n\n  const clear = React.useCallback(() => {\n    for (const t of timers.current) clearTimeout(t);\n    timers.current = [];\n  }, []);\n  React.useEffect(() => clear, [clear]);\n\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-3\">\n      <Composer\n        value={value}\n        onValueChange={setValue}\n        status={status}\n        maxLength={2000}\n        accept=\"image/*,.pdf,.csv,.md,.txt,.ts,.tsx\"\n        maxFiles={6}\n        maxFileSize={20 * 1024 * 1024}\n        submitKey=\"enter\"\n        clearOnSubmit\n        onSubmit={(message) => {\n          setLast(message);\n          setStatus(\"submitted\");\n          timers.current.push(setTimeout(() => setStatus(\"streaming\"), 1200));\n          timers.current.push(setTimeout(() => setStatus(\"idle\"), 4200));\n        }}\n        onStop={() => {\n          clear();\n          setStatus(\"idle\");\n        }}\n      >\n        <ComposerAttachments max={4} />\n        <ComposerInput placeholder=\"Message Kura — paste or drop files\" minRows={2} maxRows={10} />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n            <ModelPicker models={models} value={model} onValueChange={setModel} side=\"top\" />\n            <ToolToggle pressed={search} onPressedChange={setSearch} icon={<Globe />}>\n              Search\n            </ToolToggle>\n            <ToolToggle pressed={think} onPressedChange={setThink} icon={<Lightbulb />}>\n              Think\n            </ToolToggle>\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerHint />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <p className=\"min-h-4 px-1 text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n        {status === \"submitted\"\n          ? \"Sending…\"\n          : status === \"streaming\"\n            ? \"Responding — press Esc to stop\"\n            : last\n              ? `Sent “${last.text.slice(0, 48)}${last.text.length > 48 ? \"…\" : \"\"}”${\n                  last.attachments.length ? ` with ${last.attachments.length} file(s)` : \"\"\n                }`\n              : \"Enter to send · Shift+Enter for a new line\"}\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ComposerStates",
          "title": "Submitted, streaming, over limit and disabled",
          "kind": "state",
          "code": "import { Composer, ComposerActions, ComposerAttachButton, ComposerHint, ComposerInput, ComposerSubmit, ComposerToolbar, ComposerTools } from \"@kura-ui/react/composer\";\n\nexport function ComposerStates() {\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <Composer defaultValue=\"Summarise the attached incident report\" status=\"submitted\">\n        <ComposerInput />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <Composer\n        defaultValue=\"Draft release notes for v2.4 — keep it under 200 words and lead with the breaking change to the auth middleware.\"\n        status=\"streaming\"\n        maxLength={120}\n      >\n        <ComposerInput />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerHint />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <Composer disabled placeholder=\"Upgrade to continue the conversation\" />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ComposerNewChat",
          "title": "New chat home",
          "description": "An empty-chat start: ModelPicker and VoiceDictation in the toolbar, PromptSuggestions chips that fill and focus the input.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { ChartLine, Code, FileText, Mail } from \"lucide-react\";\nimport * as React from \"react\";\nimport { ModelPicker, type ModelPickerModel } from \"@kura-ui/react/model-picker\";\nimport { type PromptSuggestion, PromptSuggestions } from \"@kura-ui/react/prompt-suggestions\";\nimport { VoiceDictation } from \"@kura-ui/react/voice-dictation\";\nimport { Composer, ComposerActions, ComposerAttachButton, ComposerAttachments, ComposerInput, type ComposerStatus, ComposerSubmit, ComposerToolbar, ComposerTools } from \"@kura-ui/react/composer\";\n\nconst models: ModelPickerModel[] = [\n  {\n    id: \"opus\",\n    name: \"Claude Opus 5.5\",\n    provider: \"Anthropic\",\n    description: \"Most capable for complex, multi-step work.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 1,\n    cost: 3,\n  },\n  {\n    id: \"sonnet\",\n    name: \"Claude Sonnet 5.5\",\n    provider: \"Anthropic\",\n    description: \"Balanced speed and intelligence.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"haiku\",\n    name: \"Claude Haiku 4.5\",\n    provider: \"Anthropic\",\n    description: \"Fastest, for quick answers.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 200_000,\n    speed: 3,\n    cost: 1,\n  },\n];\n\nconst starters: PromptSuggestion[] = [\n  {\n    title: \"Analyze a CSV\",\n    prompt: \"Find the three biggest drivers of churn in the attached CSV.\",\n    icon: <ChartLine />,\n  },\n  {\n    title: \"Summarize a doc\",\n    prompt: \"Summarize this doc in five bullets for an exec audience.\",\n    icon: <FileText />,\n  },\n  {\n    title: \"Review a diff\",\n    prompt: \"Review this diff for bugs and missing tests.\",\n    icon: <Code />,\n  },\n  {\n    title: \"Draft a reply\",\n    prompt: \"Draft a friendly reply that moves the meeting to next week.\",\n    icon: <Mail />,\n  },\n];\n\nexport function ComposerNewChat() {\n  const [value, setValue] = React.useState(\"\");\n  const [interim, setInterim] = React.useState(\"\");\n  const [model, setModel] = React.useState(\"opus\");\n  const [status, setStatus] = React.useState<ComposerStatus>(\"idle\");\n  const inputRef = React.useRef<HTMLTextAreaElement>(null);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n\n  return (\n    <div className=\"flex w-full max-w-xl flex-col items-center gap-5\">\n      <div className=\"flex flex-col items-center gap-1 text-center\">\n        <h3 className=\"font-medium text-2xl text-kura-fg tracking-tight\">\n          What are we working on?\n        </h3>\n        <p className=\"text-kura-fg-muted text-sm\">\n          Start typing, dictate, or pick a starting point.\n        </p>\n      </div>\n      <Composer\n        value={value}\n        onValueChange={setValue}\n        status={status}\n        accept=\"image/*,.pdf,.csv,.md,.txt\"\n        className=\"w-full\"\n        onSubmit={() => {\n          setStatus(\"submitted\");\n          timer.current = setTimeout(() => setStatus(\"idle\"), 1400);\n        }}\n        onStop={() => {\n          clearTimeout(timer.current);\n          setStatus(\"idle\");\n        }}\n      >\n        <ComposerAttachments />\n        <ComposerInput ref={inputRef} placeholder={interim || \"Message Kura…\"} minRows={2} />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n            <ModelPicker models={models} value={model} onValueChange={setModel} side=\"top\" />\n          </ComposerTools>\n          <ComposerActions>\n            <VoiceDictation\n              simulate=\"Compare our Q3 pricing page with the two closest competitors.\"\n              showTranscript={false}\n              hotkey={null}\n              onInterim={setInterim}\n              onStatusChange={(s) => s === \"idle\" && setInterim(\"\")}\n              onTranscript={(text) => setValue((v) => (v ? `${v.trimEnd()} ${text}` : text))}\n            />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <PromptSuggestions\n        variant=\"chips\"\n        label=\"Starting points\"\n        suggestions={starters}\n        disabled={status !== \"idle\"}\n        onSelect={(prompt) => {\n          setValue(prompt);\n          inputRef.current?.focus();\n        }}\n        className=\"justify-center\"\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Drive `status` from your chat hook: set \"submitted\" when you send, \"streaming\" on the first token, back to \"idle\" when done; pass `onStop` to abort.",
        "With the Vercel AI SDK: status maps 1:1 from useChat's status ('ready' → 'idle').",
        "Compose parts in this order: ComposerAttachments, ComposerInput, ComposerToolbar > (ComposerTools, ComposerActions > ComposerHint, ComposerSubmit).",
        "Use `useComposer()` inside custom parts to read value, setValue, attachments, addFiles, submit, status.",
        "Attachment object URLs are revoked when removed, not when sent — the sent message may still display them.",
        "To insert text from voice-dictation or prompt-suggestions, control `value` and append to it."
      ],
      "title": "Composer",
      "importPath": "@kura-ui/react/composer",
      "exports": [
        "Composer",
        "ComposerActions",
        "ComposerActionsProps",
        "ComposerAttachButton",
        "ComposerAttachButtonProps",
        "ComposerAttachments",
        "ComposerAttachmentsProps",
        "ComposerHint",
        "ComposerHintProps",
        "ComposerInput",
        "ComposerInputProps",
        "ComposerMessage",
        "ComposerProps",
        "ComposerStatus",
        "ComposerSubmit",
        "ComposerSubmitProps",
        "ComposerToolbar",
        "ComposerToolbarProps",
        "ComposerTools",
        "ComposerToolsProps",
        "useComposer"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/composer",
      "markdownUrl": "https://kuro.abstergo.fyi/components/composer.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/composer.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/composer.json",
        "cli": "npx @kura-ui/cli add composer",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "attachment",
        "icon-button",
        "kura-merge-refs",
        "kura-motion",
        "tooltip",
        "use-controllable-state",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "value": {
            "description": "Controlled text.",
            "x-ts-type": "string",
            "type": "string"
          },
          "defaultValue": {
            "description": "Initial text when uncontrolled.",
            "x-ts-type": "string",
            "type": "string",
            "default": ""
          },
          "onValueChange": {
            "description": "Called on every text change.",
            "x-ts-type": "(value: string) => void"
          },
          "attachments": {
            "description": "Controlled attachments ({ id, name, size?, type?, url?, status?, progress?, error?, file? }).",
            "x-ts-type": "AttachmentItem[]",
            "type": "array"
          },
          "defaultAttachments": {
            "description": "Initial attachments when uncontrolled.",
            "x-ts-type": "AttachmentItem[]",
            "type": "array"
          },
          "onAttachmentsChange": {
            "description": "Called when files are added or removed.",
            "x-ts-type": "(attachments: AttachmentItem[]) => void"
          },
          "onFilesAdded": {
            "description": "Called with newly accepted files (paste, drop or picker) — start uploads here and update `attachments` with status/progress.",
            "x-ts-type": "(items: AttachmentItem[]) => void"
          },
          "onFilesRejected": {
            "description": "Called with files rejected by accept, maxFileSize or maxFiles.",
            "x-ts-type": "(files: File[]) => void"
          },
          "onSubmit": {
            "description": "Send handler: receives { text (trimmed), attachments }. Not called while empty, over maxLength, uploading or busy.",
            "x-ts-type": "(message: ComposerMessage) => void"
          },
          "onStop": {
            "description": "Called by the stop button and by Escape while status is submitted or streaming.",
            "x-ts-type": "() => void"
          },
          "status": {
            "description": "Request lifecycle. submitted = waiting for the first token (stop button with a travelling arc), streaming = model writing (stop button).",
            "x-ts-type": "\"idle\" | \"submitted\" | \"streaming\" | \"error\"",
            "type": "string",
            "enum": [
              "error",
              "idle",
              "streaming",
              "submitted"
            ],
            "default": "idle"
          },
          "disabled": {
            "description": "Disable typing, attaching and sending (60% opacity).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "maxLength": {
            "description": "Character limit. The hint turns amber at 90% and red above; sending is blocked when over.",
            "x-ts-type": "number",
            "type": "number"
          },
          "submitKey": {
            "description": "enter = Enter sends and Shift+Enter adds a newline; mod+enter = ⌘/Ctrl+Enter sends and Enter adds a newline.",
            "x-ts-type": "\"enter\" | \"mod+enter\"",
            "type": "string",
            "enum": [
              "enter",
              "mod+enter"
            ],
            "default": "enter"
          },
          "accept": {
            "description": "Allowed files, like <input accept> (\"image/*,.pdf\"). \"\" accepts any file; false disables attachments entirely.",
            "x-ts-type": "string | false",
            "default": ""
          },
          "maxFiles": {
            "description": "Maximum number of attachments.",
            "x-ts-type": "number",
            "type": "number"
          },
          "maxFileSize": {
            "description": "Maximum size of one file in bytes.",
            "x-ts-type": "number",
            "type": "number"
          },
          "clearOnSubmit": {
            "description": "Clear text and attachments after a submit.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "placeholder": {
            "description": "Placeholder for the default layout (only used when Composer has no children).",
            "x-ts-type": "string",
            "type": "string"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/composer/composer.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ArrowUp, Paperclip } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { springs } from \"@/lib/kura/motion\";\nimport { AttachmentList, AttachmentProgressRing } from \"@/components/kura/attachment\";\nimport {\n  type AttachmentItem,\n  acceptsFile,\n  createAttachment,\n  dragHasFiles,\n  filesFromDataTransfer,\n} from \"@/components/kura/attachment/attachment-utils\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport type ComposerStatus = \"idle\" | \"submitted\" | \"streaming\" | \"error\";\n\nexport interface ComposerMessage {\n  /** The trimmed text. */\n  text: string;\n  attachments: AttachmentItem[];\n}\n\ninterface ComposerContextValue {\n  value: string;\n  setValue: (next: string) => void;\n  attachments: AttachmentItem[];\n  addFiles: (files: File[]) => void;\n  removeAttachment: (id: string) => void;\n  openFilePicker: () => void;\n  submit: () => void;\n  stop: () => void;\n  status: ComposerStatus;\n  disabled: boolean;\n  busy: boolean;\n  canSubmit: boolean;\n  maxLength: number | undefined;\n  submitKey: \"enter\" | \"mod+enter\";\n  acceptsFiles: boolean;\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>;\n  inputId: string;\n  hintId: string;\n}\n\nconst ComposerContext = React.createContext<ComposerContextValue | null>(null);\n\n/** Read composer state from a custom part. Must be used inside <Composer>. */\nexport function useComposer(): ComposerContextValue {\n  const ctx = React.useContext(ComposerContext);\n  if (!ctx) throw new Error(\"useComposer must be used inside <Composer>.\");\n  return ctx;\n}\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\n// ---------------------------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerProps\n  extends Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"defaultValue\" | \"onChange\"> {\n  /** Controlled text. */\n  value?: string;\n  /** Initial text when uncontrolled. Default \"\". */\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Controlled attachments. */\n  attachments?: AttachmentItem[];\n  /** Initial attachments when uncontrolled. */\n  defaultAttachments?: AttachmentItem[];\n  onAttachmentsChange?: (attachments: AttachmentItem[]) => void;\n  /** Called with files added by paste, drop or the picker (after filtering), e.g. to start uploads. */\n  onFilesAdded?: (items: AttachmentItem[]) => void;\n  /** Called with files rejected by `accept`, `maxFileSize` or `maxFiles`. */\n  onFilesRejected?: (files: File[]) => void;\n  /** Send handler. Receives the trimmed text and the attachments. */\n  onSubmit?: (message: ComposerMessage) => void;\n  /** Called by the stop button (and Escape) while status is \"submitted\" or \"streaming\". */\n  onStop?: () => void;\n  /**\n   * Request lifecycle. `submitted` = waiting for the first token, `streaming` = the model is\n   * writing. Both turn the send button into a stop button. Default \"idle\".\n   */\n  status?: ComposerStatus;\n  /** Disable typing, attaching and sending. */\n  disabled?: boolean;\n  /** Character limit. The hint turns amber at 90% and sending is blocked above it. */\n  maxLength?: number;\n  /** `enter` = Enter sends, Shift+Enter is a newline. `mod+enter` = ⌘/Ctrl+Enter sends. Default \"enter\". */\n  submitKey?: \"enter\" | \"mod+enter\";\n  /** Allowed files (same syntax as `<input accept>`). Pass `false` to disable attachments. Default \"\" (any). */\n  accept?: string | false;\n  /** Maximum number of attachments. */\n  maxFiles?: number;\n  /** Maximum size of one file in bytes. */\n  maxFileSize?: number;\n  /** Clear text and attachments after a successful submit. Default true. */\n  clearOnSubmit?: boolean;\n  /** Placeholder for the default layout (used when Composer has no children). */\n  placeholder?: string;\n}\n\n/**\n * The prompt input. Compound: `<Composer>` owns the text, attachments and request status;\n * `ComposerInput`, `ComposerAttachments`, `ComposerToolbar`, `ComposerSubmit` and friends read\n * them from context. Render `<Composer />` with no children for the complete default layout.\n */\nexport function Composer({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  attachments: attachmentsProp,\n  defaultAttachments,\n  onAttachmentsChange,\n  onFilesAdded,\n  onFilesRejected,\n  onSubmit,\n  onStop,\n  status = \"idle\",\n  disabled = false,\n  maxLength,\n  submitKey = \"enter\",\n  accept = \"\",\n  maxFiles,\n  maxFileSize,\n  clearOnSubmit = true,\n  placeholder,\n  className,\n  children,\n  onFocus,\n  onBlur,\n  onMouseDown,\n  ...props\n}: ComposerProps) {\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const [attachments, setAttachments] = useControllableState<AttachmentItem[]>({\n    value: attachmentsProp,\n    defaultValue: defaultAttachments ?? [],\n    onChange: onAttachmentsChange,\n  });\n  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const [focused, setFocused] = React.useState(false);\n  const [dragging, setDragging] = React.useState(false);\n  const dragDepth = React.useRef(0);\n  const inputId = React.useId();\n  const hintId = React.useId();\n\n  const acceptsFiles = accept !== false && !disabled;\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const overLimit = maxLength !== undefined && value.length > maxLength;\n  const uploading = attachments.some((a) => a.status === \"uploading\");\n  const hasContent = value.trim().length > 0 || attachments.length > 0;\n  const canSubmit = !disabled && !busy && hasContent && !overLimit && !uploading;\n\n  const addFiles = React.useCallback(\n    (files: File[]) => {\n      if (accept === false || disabled || files.length === 0) return;\n      const ok: AttachmentItem[] = [];\n      const rejected: File[] = [];\n      let room = maxFiles === undefined ? Number.POSITIVE_INFINITY : maxFiles - attachments.length;\n      for (const file of files) {\n        const fits = maxFileSize === undefined || file.size <= maxFileSize;\n        if (room > 0 && fits && acceptsFile(file, accept || undefined)) {\n          ok.push(createAttachment(file));\n          room--;\n        } else rejected.push(file);\n      }\n      if (ok.length) {\n        setAttachments((list) => [...list, ...ok]);\n        onFilesAdded?.(ok);\n      }\n      if (rejected.length) onFilesRejected?.(rejected);\n    },\n    [\n      accept,\n      disabled,\n      maxFiles,\n      maxFileSize,\n      attachments.length,\n      setAttachments,\n      onFilesAdded,\n      onFilesRejected,\n    ],\n  );\n\n  const removeAttachment = React.useCallback(\n    (id: string) => {\n      setAttachments((list) => {\n        const gone = list.find((a) => a.id === id);\n        if (gone?.file && gone.url?.startsWith(\"blob:\")) URL.revokeObjectURL(gone.url);\n        return list.filter((a) => a.id !== id);\n      });\n      textareaRef.current?.focus();\n    },\n    [setAttachments],\n  );\n\n  const submit = React.useCallback(() => {\n    if (!canSubmit) return;\n    onSubmit?.({ text: value.trim(), attachments });\n    if (clearOnSubmit) {\n      setValue(\"\");\n      setAttachments([]);\n    }\n    textareaRef.current?.focus();\n  }, [canSubmit, onSubmit, value, attachments, clearOnSubmit, setValue, setAttachments]);\n\n  const stop = React.useCallback(() => {\n    if (busy) onStop?.();\n  }, [busy, onStop]);\n\n  const ctx: ComposerContextValue = {\n    value,\n    setValue,\n    attachments,\n    addFiles,\n    removeAttachment,\n    openFilePicker: () => fileRef.current?.click(),\n    submit,\n    stop,\n    status,\n    disabled,\n    busy,\n    canSubmit,\n    maxLength,\n    submitKey,\n    acceptsFiles,\n    textareaRef,\n    inputId,\n    hintId,\n  };\n\n  return (\n    <ComposerContext.Provider value={ctx}>\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: focus tracking, click-to-focus and file drop; all controls inside are native. */}\n      <div\n        data-slot=\"composer\"\n        data-status={status}\n        data-focused={focused}\n        data-dragging={dragging || undefined}\n        data-disabled={disabled || undefined}\n        aria-busy={busy || undefined}\n        onFocus={(e) => {\n          setFocused(true);\n          onFocus?.(e);\n        }}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n          onBlur?.(e);\n        }}\n        onMouseDown={(e) => {\n          onMouseDown?.(e);\n          // Clicking the padding or toolbar background focuses the textarea, like a native field.\n          const t = e.target as HTMLElement;\n          if (\n            !e.defaultPrevented &&\n            textareaRef.current &&\n            t !== textareaRef.current &&\n            (t === e.currentTarget || t.dataset.slot === \"composer-toolbar\")\n          ) {\n            e.preventDefault();\n            textareaRef.current.focus();\n          }\n        }}\n        onDragEnter={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          dragDepth.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          e.dataTransfer.dropEffect = \"copy\";\n        }}\n        onDragLeave={() => {\n          dragDepth.current = Math.max(0, dragDepth.current - 1);\n          if (dragDepth.current === 0) setDragging(false);\n        }}\n        onDrop={(e) => {\n          if (!acceptsFiles || !dragHasFiles(e.dataTransfer)) return;\n          e.preventDefault();\n          dragDepth.current = 0;\n          setDragging(false);\n          addFiles(filesFromDataTransfer(e.dataTransfer));\n        }}\n        className={cn(\n          \"group/composer relative isolate flex w-full cursor-text flex-col rounded-kura-2xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n          \"transition-[box-shadow,opacity] duration-(--kura-duration-base) ease-kura-out\",\n          \"data-[focused=true]:shadow-[0_0_0_1px_var(--kura-border-strong),0_0_0_4px_color-mix(in_oklch,var(--kura-ring)_14%,transparent),var(--kura-shadow-md)]\",\n          disabled && \"cursor-not-allowed opacity-60\",\n          className,\n        )}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <ComposerAttachments />\n            <ComposerInput placeholder={placeholder} />\n            <ComposerToolbar>\n              <ComposerTools>{acceptsFiles && <ComposerAttachButton />}</ComposerTools>\n              <ComposerActions>\n                <ComposerHint />\n                <ComposerSubmit />\n              </ComposerActions>\n            </ComposerToolbar>\n          </>\n        )}\n        {accept !== false && (\n          <input\n            ref={fileRef}\n            type=\"file\"\n            multiple\n            tabIndex={-1}\n            aria-hidden\n            className=\"hidden\"\n            accept={accept || undefined}\n            disabled={disabled}\n            onChange={(e) => {\n              addFiles(Array.from(e.currentTarget.files ?? []));\n              e.currentTarget.value = \"\";\n              textareaRef.current?.focus();\n            }}\n          />\n        )}\n        <ComposerDropOverlay visible={dragging} />\n      </div>\n    </ComposerContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Input\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerInputProps\n  extends Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\" | \"rows\"> {\n  /** Rows shown when empty. Default 1. */\n  minRows?: number;\n  /** Rows before the textarea scrolls instead of growing. Default 8. */\n  maxRows?: number;\n}\n\n/**\n * Auto-growing textarea. Enter sends (Shift+Enter inserts a newline), IME composition is never\n * interrupted, pasted files become attachments, and Escape stops a streaming response.\n */\nexport function ComposerInput({\n  minRows = 1,\n  maxRows = 8,\n  className,\n  onKeyDown,\n  onPaste,\n  onChange,\n  placeholder = \"Ask anything…\",\n  ref,\n  id,\n  disabled: disabledProp,\n  ...props\n}: ComposerInputProps) {\n  const ctx = useComposer();\n  const { textareaRef, value } = ctx;\n  const disabled = disabledProp ?? ctx.disabled;\n  const overLimit = ctx.maxLength !== undefined && value.length > ctx.maxLength;\n\n  const resize = React.useCallback(() => {\n    const el = textareaRef.current;\n    if (!el || typeof window === \"undefined\") return;\n    const style = window.getComputedStyle(el);\n    const line = Number.parseFloat(style.lineHeight) || 24;\n    const pad = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) || 0;\n    const min = line * minRows + pad;\n    const max = line * maxRows + pad;\n    el.style.height = \"auto\";\n    const next = Math.min(max, Math.max(min, el.scrollHeight));\n    el.style.height = `${next}px`;\n    el.style.overflowY = el.scrollHeight > max + 1 ? \"auto\" : \"hidden\";\n  }, [textareaRef, minRows, maxRows]);\n\n  // Re-measure whenever the text changes.\n  useIsoLayoutEffect(() => {\n    if (value !== undefined) resize();\n  }, [resize, value]);\n\n  React.useEffect(() => {\n    const el = textareaRef.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    let width = el.clientWidth;\n    const ro = new ResizeObserver(() => {\n      if (el.clientWidth !== width) {\n        width = el.clientWidth;\n        resize();\n      }\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [textareaRef, resize]);\n\n  return (\n    <textarea\n      data-slot=\"composer-input\"\n      ref={mergeRefs(textareaRef, ref)}\n      id={id ?? ctx.inputId}\n      rows={minRows}\n      value={value}\n      disabled={disabled}\n      placeholder={placeholder}\n      aria-invalid={overLimit || undefined}\n      aria-describedby={ctx.maxLength !== undefined ? ctx.hintId : undefined}\n      aria-label={props[\"aria-label\"] ?? (props[\"aria-labelledby\"] ? undefined : \"Message\")}\n      enterKeyHint=\"send\"\n      onChange={(e) => {\n        onChange?.(e);\n        if (!e.defaultPrevented) ctx.setValue(e.currentTarget.value);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        if (e.key === \"Escape\" && ctx.busy) {\n          e.preventDefault();\n          ctx.stop();\n          return;\n        }\n        if (e.key !== \"Enter\") return;\n        // Never submit while an IME is composing (Japanese, Chinese, Korean input).\n        if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n        const mod = e.metaKey || e.ctrlKey;\n        if (ctx.submitKey === \"enter\" ? !e.shiftKey && !e.altKey : mod) {\n          e.preventDefault();\n          ctx.submit();\n        }\n      }}\n      onPaste={(e) => {\n        onPaste?.(e);\n        if (e.defaultPrevented || !ctx.acceptsFiles) return;\n        const files = filesFromDataTransfer(e.clipboardData);\n        if (files.length > 0) {\n          e.preventDefault();\n          ctx.addFiles(files);\n        }\n      }}\n      className={cn(\n        \"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] text-kura-fg leading-6 outline-none\",\n        \"placeholder:text-kura-fg-subtle disabled:cursor-not-allowed [scrollbar-gutter:stable] [scrollbar-width:thin]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Attachments\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerAttachmentsProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Collapse into a \"+N\" stack after this many. */\n  max?: number;\n}\n\n/** The composer's attachment row. Renders nothing while there are no attachments. */\nexport function ComposerAttachments({ max, className, ...props }: ComposerAttachmentsProps) {\n  const ctx = useComposer();\n  if (ctx.attachments.length === 0) return null;\n  return (\n    <div\n      data-slot=\"composer-attachments\"\n      className={cn(\"cursor-default px-3 pt-3\", className)}\n      {...props}\n    >\n      <AttachmentList\n        items={ctx.attachments}\n        max={max}\n        onRemove={ctx.disabled ? undefined : ctx.removeAttachment}\n      />\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Toolbar\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerToolbarProps extends React.ComponentProps<\"div\"> {}\n\n/** Bottom row. Put `ComposerTools` (left) and `ComposerActions` (right) inside. */\nexport function ComposerToolbar({ className, ...props }: ComposerToolbarProps) {\n  return (\n    <div\n      data-slot=\"composer-toolbar\"\n      className={cn(\"flex min-h-12 items-center justify-between gap-2 p-2 pt-1\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerToolsProps extends React.ComponentProps<\"div\"> {}\n\n/** Left toolbar group for the attach button, model picker, tool toggles. */\nexport function ComposerTools({ className, ...props }: ComposerToolsProps) {\n  return (\n    <div\n      data-slot=\"composer-tools\"\n      className={cn(\"flex min-w-0 flex-1 items-center gap-1\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerActionsProps extends React.ComponentProps<\"div\"> {}\n\n/** Right toolbar group for the hint, voice input and the submit button. */\nexport function ComposerActions({ className, ...props }: ComposerActionsProps) {\n  return (\n    <div\n      data-slot=\"composer-actions\"\n      className={cn(\"flex shrink-0 items-center gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface ComposerAttachButtonProps\n  extends Omit<React.ComponentProps<typeof IconButton>, \"label\" | \"icon\"> {\n  /** Accessible label and tooltip. Default \"Attach files\". */\n  label?: string;\n  icon?: React.ReactNode;\n}\n\n/** Opens the file picker. Hidden automatically when the composer has `accept={false}`. */\nexport function ComposerAttachButton({\n  label = \"Attach files\",\n  icon,\n  onClick,\n  className,\n  ...props\n}: ComposerAttachButtonProps) {\n  const ctx = useComposer();\n  if (!ctx.acceptsFiles && !ctx.disabled) return null;\n  return (\n    <IconButton\n      data-slot=\"composer-attach\"\n      label={label}\n      icon={icon ?? <Paperclip />}\n      disabled={ctx.disabled}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!e.defaultPrevented) ctx.openFilePicker();\n      }}\n      className={cn(\"rounded-kura-lg\", className)}\n      {...props}\n    />\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Hint (characters / tokens)\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerHintProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** `characters` counts characters (default with `maxLength`); `tokens` shows a ~4 chars/token estimate. */\n  mode?: \"characters\" | \"tokens\";\n  /** Only show once the text reaches this fraction of `maxLength`. Default 0 (always, when not empty). */\n  showAt?: number;\n  /** Custom token counter for `mode=\"tokens\"`. */\n  countTokens?: (text: string) => number;\n}\n\nconst nf = new Intl.NumberFormat(\"en-US\");\n\n/**\n * Character or token count with a tiny gauge. Quiet until it matters: amber at 90% of\n * `maxLength`, red when over (sending is then blocked).\n */\nexport function ComposerHint({\n  mode,\n  showAt = 0,\n  countTokens,\n  className,\n  ...props\n}: ComposerHintProps) {\n  const { value, maxLength, hintId } = useComposer();\n  const resolved = mode ?? (maxLength !== undefined ? \"characters\" : \"tokens\");\n  const length = value.length;\n  const tokens = countTokens ? countTokens(value) : Math.ceil(value.trim().length / 4);\n  const ratio = maxLength ? length / maxLength : 0;\n  const level = ratio > 1 ? \"over\" : ratio >= 0.9 ? \"near\" : \"ok\";\n  const visible = length > 0 && (!maxLength || ratio >= showAt);\n\n  const label =\n    resolved === \"tokens\"\n      ? `~${nf.format(tokens)} ${tokens === 1 ? \"token\" : \"tokens\"}`\n      : maxLength\n        ? `${nf.format(length)} / ${nf.format(maxLength)}`\n        : nf.format(length);\n\n  return (\n    <span\n      data-slot=\"composer-hint\"\n      data-level={level}\n      id={hintId}\n      aria-hidden={!visible || undefined}\n      className={cn(\n        \"inline-flex select-none items-center gap-1.5 text-xs text-kura-fg-subtle tabular-nums\",\n        \"transition-[opacity,color] duration-(--kura-duration-base) ease-kura-out\",\n        !visible && \"pointer-events-none opacity-0\",\n        level === \"near\" && \"text-kura-warning-text\",\n        level === \"over\" && \"text-kura-danger-text\",\n        className,\n      )}\n      {...props}\n    >\n      {maxLength !== undefined && resolved === \"characters\" && (\n        <AttachmentProgressRing progress={Math.min(1, ratio)} thickness={3} className=\"size-3.5\" />\n      )}\n      <span aria-hidden>{label}</span>\n      {maxLength !== undefined && (\n        <span className=\"sr-only\">\n          {level === \"over\"\n            ? `Message exceeds the ${nf.format(maxLength)} character limit`\n            : `${nf.format(maxLength)} character limit`}\n        </span>\n      )}\n    </span>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Submit / stop\n// ---------------------------------------------------------------------------------------------\n\nexport interface ComposerSubmitProps\n  extends Omit<React.ComponentProps<\"button\">, \"children\" | \"type\"> {\n  /** Accessible label while idle. Default \"Send message\". */\n  sendLabel?: string;\n  /** Accessible label while busy. Default \"Stop generating\". */\n  stopLabel?: string;\n  /** Show a tooltip with the shortcut. Default true. */\n  tooltip?: boolean;\n}\n\n/**\n * Send button that becomes a stop button while the composer is `submitted` or `streaming`.\n * The icons cross-fade with a 2px blur; a thin arc orbits the button while waiting for the\n * first token.\n */\nexport function ComposerSubmit({\n  sendLabel = \"Send message\",\n  stopLabel = \"Stop generating\",\n  tooltip = true,\n  className,\n  onClick,\n  disabled: disabledProp,\n  ...props\n}: ComposerSubmitProps) {\n  const ctx = useComposer();\n  const reduced = useReducedMotion();\n  const mode = ctx.busy ? \"stop\" : \"send\";\n  const disabled = disabledProp ?? (ctx.busy ? false : !ctx.canSubmit);\n  const ready = mode === \"stop\" || ctx.canSubmit;\n\n  const swap = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: 0.12 },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" },\n        transition: springs.snappy,\n      };\n\n  const button = (\n    <button\n      type=\"button\"\n      data-slot=\"composer-submit\"\n      data-mode={mode}\n      data-ready={ready}\n      aria-label={mode === \"stop\" ? stopLabel : sendLabel}\n      disabled={disabled}\n      onClick={(e) => {\n        onClick?.(e);\n        if (e.defaultPrevented) return;\n        if (mode === \"stop\") ctx.stop();\n        else ctx.submit();\n      }}\n      className={cn(\n        \"relative flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-kura-lg outline-none\",\n        \"transition-[background-color,color,box-shadow,transform] duration-(--kura-duration-fast) ease-kura-out\",\n        \"focus-visible:shadow-kura-focus active:not-disabled:scale-[0.94] motion-reduce:active:not-disabled:scale-100\",\n        \"disabled:cursor-not-allowed\",\n        ready\n          ? \"bg-kura-ink text-kura-fg-inverted shadow-kura-xs hover:bg-kura-ink-hover\"\n          : \"bg-kura-active text-kura-fg-subtle\",\n        className,\n      )}\n      {...props}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span key={mode} className=\"flex items-center justify-center\" {...swap}>\n          {mode === \"stop\" ? (\n            <span aria-hidden className=\"size-2.5 rounded-[2.5px] bg-current\" />\n          ) : (\n            <ArrowUp aria-hidden className=\"size-4\" strokeWidth={2.25} />\n          )}\n        </motion.span>\n      </AnimatePresence>\n      {ctx.status === \"submitted\" && (\n        <svg\n          aria-hidden\n          data-slot=\"composer-submit-pending\"\n          viewBox=\"0 0 36 36\"\n          className=\"-inset-0.5 pointer-events-none absolute size-9 text-kura-fg-subtle motion-reduce:animate-kura-pulse\"\n          fill=\"none\"\n        >\n          {/* a short arc travelling around the button's own rounded outline */}\n          <motion.rect\n            x=\"1\"\n            y=\"1\"\n            width=\"34\"\n            height=\"34\"\n            rx=\"13\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            pathLength={100}\n            strokeDasharray=\"22 78\"\n            strokeLinecap=\"round\"\n            initial={{ strokeDashoffset: 0 }}\n            animate={reduced ? { strokeDashoffset: 0 } : { strokeDashoffset: -100 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : { duration: 1.4, ease: \"linear\", repeat: Number.POSITIVE_INFINITY }\n            }\n          />\n        </svg>\n      )}\n    </button>\n  );\n\n  if (!tooltip) return button;\n  return (\n    <Tooltip\n      content={mode === \"stop\" ? \"Stop\" : \"Send\"}\n      shortcut={\n        mode === \"stop\" ? [\"esc\"] : ctx.submitKey === \"enter\" ? [\"enter\"] : [\"mod\", \"enter\"]\n      }\n    >\n      {button}\n    </Tooltip>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Drop overlay\n// ---------------------------------------------------------------------------------------------\n\nfunction ComposerDropOverlay({ visible }: { visible: boolean }) {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"composer-drop-overlay\"\n      data-state={visible ? \"visible\" : \"hidden\"}\n      className={cn(\n        \"pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] bg-kura-overlay backdrop-blur-sm\",\n        \"transition-opacity duration-(--kura-duration-fast) ease-kura-out\",\n        visible ? \"opacity-100\" : \"opacity-0\",\n      )}\n    >\n      <svg\n        aria-hidden\n        className=\"absolute inset-0 size-full overflow-visible text-kura-border-strong\"\n      >\n        <rect\n          x=\"4.5\"\n          y=\"4.5\"\n          rx=\"15.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeDasharray=\"5 4\"\n          style={{ width: \"calc(100% - 9px)\", height: \"calc(100% - 9px)\" }}\n        />\n      </svg>\n      <span\n        className={cn(\n          \"flex items-center gap-2 font-medium text-kura-fg text-sm transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n          visible ? \"translate-y-0\" : \"translate-y-1\",\n        )}\n      >\n        <Paperclip className=\"size-4 text-kura-fg-muted\" aria-hidden />\n        Drop to attach\n      </span>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/composer/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Composer,\n  ComposerActions,\n  type ComposerActionsProps,\n  ComposerAttachButton,\n  type ComposerAttachButtonProps,\n  ComposerAttachments,\n  type ComposerAttachmentsProps,\n  ComposerHint,\n  type ComposerHintProps,\n  ComposerInput,\n  type ComposerInputProps,\n  type ComposerMessage,\n  type ComposerProps,\n  type ComposerStatus,\n  ComposerSubmit,\n  type ComposerSubmitProps,\n  ComposerToolbar,\n  type ComposerToolbarProps,\n  ComposerTools,\n  type ComposerToolsProps,\n  useComposer,\n} from \"./composer\";\n"
        }
      ]
    },
    {
      "slug": "context-meter",
      "name": "ContextMeter",
      "category": "agent",
      "status": "beta",
      "description": "Token / context-window usage as a compact ring or bar with warning and danger thresholds, rolling numbers and a segmented breakdown card.",
      "whenToUse": [
        "Show how much of a model's context window a conversation uses (e.g. in a composer footer or chat header).",
        "Break usage down by source — system prompt, tool definitions, attached files, history — in a hover card.",
        "Warn before the context fills up (auto-compaction, truncation) with warning/danger thresholds.",
        "Show any other token budget (per-request output limit, monthly quota) as a bar."
      ],
      "whenNotToUse": [
        "Indeterminate progress or “thinking” — use shimmer-text or reasoning.",
        "Upload or task progress — use a progress bar (or agent-timeline for multi-step work).",
        "Billing/cost dashboards with history — use a chart."
      ],
      "props": [
        {
          "name": "max",
          "type": "number",
          "required": true,
          "description": "Context window size in tokens, e.g. 200000."
        },
        {
          "name": "used",
          "type": "number",
          "description": "Tokens used. Default: the sum of `segments`."
        },
        {
          "name": "segments",
          "type": "readonly ContextSegment[]",
          "description": "Breakdown: { id, label, tokens, color? }[], e.g. system / tools / files / history. Enables the hover card and the segmented bar. Colors default to ink tints."
        },
        {
          "name": "variant",
          "type": "\"ring\" | \"bar\"",
          "default": "\"ring\"",
          "description": "ring = 18px donut + value for tight toolbars; bar = horizontal (segmented) bar + value."
        },
        {
          "name": "warningAt",
          "type": "number",
          "default": "0.7",
          "description": "Fraction (0–1) from which the meter is amber."
        },
        {
          "name": "dangerAt",
          "type": "number",
          "default": "0.9",
          "description": "Fraction (0–1) from which the meter is red."
        },
        {
          "name": "valueDisplay",
          "type": "\"percent\" | \"tokens\" | \"none\"",
          "description": "Value next to the meter: 42% or 84.2k / 200k. Default percent for ring, tokens for bar."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Context\"",
          "description": "Name used in the accessible label and the card title (“Context window”)."
        },
        {
          "name": "footer",
          "type": "React.ReactNode",
          "description": "Note at the bottom of the card. Defaults to a warning sentence at warning/danger levels."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Hover card open (controlled)."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the hover card opens or closes."
        }
      ],
      "parts": [
        {
          "name": "ContextMeter",
          "description": "Trigger button. `data-variant`, `data-level=\"ok\" | \"warning\" | \"danger\"`.",
          "dataSlot": "context-meter"
        },
        {
          "name": "Ring",
          "description": "SVG donut (aria-hidden).",
          "dataSlot": "context-meter-ring"
        },
        {
          "name": "Bar",
          "description": "Track + fill; segmented by `segments` while level is ok, a single amber/red fill above the thresholds.",
          "dataSlot": "context-meter-bar"
        },
        {
          "name": "Value",
          "description": "Mono tabular value that rolls to new numbers.",
          "dataSlot": "context-meter-value"
        },
        {
          "name": "Card",
          "description": "Hover/click card: totals, segmented bar, legend with tokens and % of the window, free space, footer.",
          "dataSlot": "context-meter-card"
        },
        {
          "name": "Segment",
          "description": "One legend row.",
          "dataSlot": "context-meter-segment"
        }
      ],
      "a11y": {
        "notes": [
          "The meter is a button whose accessible name carries the full reading: “Context: 84,210 of 200,000 tokens used (42%, filling up)”.",
          "The breakdown opens on hover (300ms delay) and on click/Enter, without moving focus; Escape closes it.",
          "Levels are conveyed in text (name + card footer), not only by colour."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Open or close the breakdown card"
          },
          {
            "keys": "Escape",
            "action": "Close the card"
          }
        ],
        "reducedMotion": "Numbers, ring and bar jump to the new value instead of rolling/growing; the card only fades."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "popover"
        ]
      },
      "related": [
        "composer",
        "model-picker",
        "message",
        "tool-call"
      ],
      "tags": [
        "tokens",
        "context-window",
        "usage",
        "meter",
        "budget",
        "progress",
        "llm",
        "limits"
      ],
      "examples": [
        {
          "name": "ContextMeterMinimal",
          "title": "Ring",
          "description": "Used and max; nothing else needed.",
          "kind": "minimal",
          "code": "import { ContextMeter } from \"@kura-ui/react/context-meter\";\n\nexport function ContextMeterMinimal() {\n  return <ContextMeter used={84_210} max={200_000} />;\n}\n"
        },
        {
          "name": "ContextMeterFull",
          "title": "Ring and bar with a breakdown",
          "description": "Segments, thresholds, value display and a footer. Add tokens to watch the numbers roll into warning and danger.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Plus } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ContextMeter, type ContextSegment } from \"@kura-ui/react/context-meter\";\n\nconst BASE: ContextSegment[] = [\n  { id: \"system\", label: \"System prompt\", tokens: 3_400 },\n  { id: \"tools\", label: \"Tool definitions\", tokens: 11_800 },\n  { id: \"files\", label: \"Attached files\", tokens: 38_600 },\n  { id: \"history\", label: \"Conversation\", tokens: 30_410 },\n];\n\nexport function ContextMeterFull() {\n  const [history, setHistory] = React.useState(30_410);\n  const segments = BASE.map((s) => (s.id === \"history\" ? { ...s, tokens: history } : s));\n  return (\n    <div className=\"flex flex-col items-center gap-6\">\n      <div className=\"flex items-center gap-6\">\n        <ContextMeter\n          max={200_000}\n          segments={segments}\n          variant=\"ring\"\n          valueDisplay=\"percent\"\n          warningAt={0.7}\n          dangerAt={0.9}\n          label=\"Context\"\n        />\n        <ContextMeter\n          max={200_000}\n          segments={segments}\n          variant=\"bar\"\n          valueDisplay=\"tokens\"\n          footer=\"Files count once per turn; tool definitions are cached.\"\n        />\n      </div>\n      <div className=\"flex gap-2\">\n        <Button variant=\"secondary\" size=\"sm\" onClick={() => setHistory((h) => h + 24_000)}>\n          <Plus /> Add 24k tokens\n        </Button>\n        <Button variant=\"ghost\" size=\"sm\" onClick={() => setHistory(30_410)}>\n          Reset\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ContextMeterThresholds",
          "title": "Thresholds",
          "description": "ok, warning and danger bars.",
          "kind": "state",
          "code": "import { ContextMeter } from \"@kura-ui/react/context-meter\";\n\nexport function ContextMeterThresholds() {\n  return (\n    <div className=\"flex flex-col items-start gap-3\">\n      <ContextMeter max={128_000} used={52_000} variant=\"bar\" />\n      <ContextMeter max={128_000} used={98_500} variant=\"bar\" />\n      <ContextMeter max={128_000} used={121_900} variant=\"bar\" />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ContextMeterInComposer",
          "title": "In a composer footer",
          "description": "Next to an IconButton send action.",
          "kind": "composition",
          "code": "import { ArrowUp } from \"lucide-react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { ContextMeter, type ContextSegment } from \"@kura-ui/react/context-meter\";\n\nconst BASE: ContextSegment[] = [\n  { id: \"system\", label: \"System prompt\", tokens: 3_400 },\n  { id: \"tools\", label: \"Tool definitions\", tokens: 11_800 },\n  { id: \"files\", label: \"Attached files\", tokens: 38_600 },\n  { id: \"history\", label: \"Conversation\", tokens: 30_410 },\n];\n\nexport function ContextMeterInComposer() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-2 rounded-kura-xl bg-kura-surface p-3 shadow-kura-sm\">\n      <p className=\"min-h-12 px-1 text-[15px] text-kura-fg-subtle\">Ask a follow-up…</p>\n      <div className=\"flex items-center justify-between\">\n        <ContextMeter max={200_000} segments={BASE} />\n        <IconButton\n          label=\"Send\"\n          variant=\"primary\"\n          size=\"sm\"\n          icon={<ArrowUp />}\n          className=\"rounded-full\"\n        />\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass `segments` whenever you know the breakdown; the meter then sums them and gets a hover card. Without segments it is a plain reading (no card).",
        "Use the provider's real window size for `max` (e.g. 200000) and recount after every turn — counts roll smoothly between values.",
        "For a quota rather than a context window, set `label` (e.g. \"Output\") and use variant=\"bar\"."
      ],
      "title": "Context Meter",
      "importPath": "@kura-ui/react/context-meter",
      "exports": [
        "ContextMeter",
        "ContextMeterProps",
        "ContextSegment",
        "formatTokens"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/context-meter",
      "markdownUrl": "https://kuro.abstergo.fyi/components/context-meter.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/context-meter.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/context-meter.json",
        "cli": "npx @kura-ui/cli add context-meter",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "popover",
        "use-controllable-state",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "max": {
            "description": "Context window size in tokens, e.g. 200000.",
            "x-ts-type": "number",
            "type": "number"
          },
          "used": {
            "description": "Tokens used. Default: the sum of `segments`.",
            "x-ts-type": "number",
            "type": "number"
          },
          "segments": {
            "description": "Breakdown: { id, label, tokens, color? }[], e.g. system / tools / files / history. Enables the hover card and the segmented bar. Colors default to ink tints.",
            "x-ts-type": "readonly ContextSegment[]",
            "type": "array"
          },
          "variant": {
            "description": "ring = 18px donut + value for tight toolbars; bar = horizontal (segmented) bar + value.",
            "x-ts-type": "\"ring\" | \"bar\"",
            "type": "string",
            "enum": [
              "bar",
              "ring"
            ],
            "default": "ring"
          },
          "warningAt": {
            "description": "Fraction (0–1) from which the meter is amber.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.7
          },
          "dangerAt": {
            "description": "Fraction (0–1) from which the meter is red.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.9
          },
          "valueDisplay": {
            "description": "Value next to the meter: 42% or 84.2k / 200k. Default percent for ring, tokens for bar.",
            "x-ts-type": "\"percent\" | \"tokens\" | \"none\"",
            "type": "string",
            "enum": [
              "none",
              "percent",
              "tokens"
            ]
          },
          "label": {
            "description": "Name used in the accessible label and the card title (“Context window”).",
            "x-ts-type": "string",
            "type": "string",
            "default": "Context"
          },
          "footer": {
            "description": "Note at the bottom of the card. Defaults to a warning sentence at warning/danger levels.",
            "x-ts-type": "React.ReactNode"
          },
          "open": {
            "description": "Hover card open (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onOpenChange": {
            "description": "Called when the hover card opens or closes.",
            "x-ts-type": "(open: boolean) => void"
          }
        },
        "required": [
          "max"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/context-meter/context-meter.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/kura/popover\";\n\nexport interface ContextSegment {\n  /** Stable key, e.g. \"system\". */\n  id: string;\n  /** Legend label, e.g. \"System prompt\". */\n  label: string;\n  /** Tokens used by this part of the context. */\n  tokens: number;\n  /** Optional CSS color for the swatch and bar segment. Default: an ink tint by position. */\n  color?: string;\n}\n\nexport interface ContextMeterProps extends Omit<React.ComponentProps<\"button\">, \"children\"> {\n  /** Context window size in tokens, e.g. 200000. */\n  max: number;\n  /** Tokens used. Default: the sum of `segments`. */\n  used?: number;\n  /** Breakdown shown in the bar and the hover card, e.g. system, history, tools, files. */\n  segments?: readonly ContextSegment[];\n  /** `ring` = compact donut + value; `bar` = a horizontal (segmented) bar with value. Default \"ring\". */\n  variant?: \"ring\" | \"bar\";\n  /** Fraction (0–1) at which the meter turns to warning. Default 0.7. */\n  warningAt?: number;\n  /** Fraction (0–1) at which the meter turns to danger. Default 0.9. */\n  dangerAt?: number;\n  /** What the value shows next to the meter. Default \"percent\" for ring, \"tokens\" for bar. */\n  valueDisplay?: \"percent\" | \"tokens\" | \"none\";\n  /** Short name used in the accessible label and the card title. Default “Context”. */\n  label?: string;\n  /** Extra content at the bottom of the hover card, e.g. “Older turns will be summarized”. */\n  footer?: React.ReactNode;\n  /** Hover card open (controlled). */\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport function formatTokens(n: number): string {\n  const v = Math.max(0, n);\n  if (v < 1000) return String(Math.round(v));\n  if (v < 1_000_000) {\n    const k = v / 1000;\n    return `${k < 100 ? k.toFixed(1).replace(/\\.0$/, \"\") : Math.round(k)}k`;\n  }\n  const m = v / 1_000_000;\n  return `${m.toFixed(m < 10 ? 2 : 1).replace(/\\.?0+$/, \"\")}M`;\n}\n\n/** Eases a number toward its target so counters roll instead of jumping. */\nfunction useTweened(target: number, duration = 520): number {\n  const reduced = useReducedMotion();\n  const [value, setValue] = React.useState(target);\n  const from = React.useRef(target);\n  const current = React.useRef(target);\n  current.current = value;\n  React.useEffect(() => {\n    if (reduced || typeof requestAnimationFrame !== \"function\") {\n      setValue(target);\n      return;\n    }\n    from.current = current.current;\n    const start = performance.now();\n    let frame = 0;\n    const tick = (t: number) => {\n      const p = Math.min(1, (t - start) / duration);\n      const eased = 1 - (1 - p) ** 4;\n      setValue(from.current + (target - from.current) * eased);\n      if (p < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [target, duration, reduced]);\n  return value;\n}\n\nconst tints = [\"var(--kura-fg)\", \"var(--kura-gray-9)\", \"var(--kura-gray-8)\", \"var(--kura-gray-7)\"];\nconst segmentColor = (s: ContextSegment, i: number) =>\n  s.color ?? `color-mix(in oklab, ${tints[i % tints.length]} ${i < 4 ? 100 : 60}%, transparent)`;\n\n/**\n * Token / context-window usage as a compact ring or bar with warning and danger thresholds,\n * rolling tabular numbers, and a hover card that breaks usage down by segment.\n */\nexport function ContextMeter({\n  max,\n  used: usedProp,\n  segments,\n  variant = \"ring\",\n  warningAt = 0.7,\n  dangerAt = 0.9,\n  valueDisplay,\n  label = \"Context\",\n  footer,\n  open: openProp,\n  onOpenChange,\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: ContextMeterProps) {\n  const used = usedProp ?? segments?.reduce((sum, s) => sum + s.tokens, 0) ?? 0;\n  const ratio = max > 0 ? Math.min(1, Math.max(0, used / max)) : 0;\n  const level = ratio >= dangerAt ? \"danger\" : ratio >= warningAt ? \"warning\" : \"ok\";\n  const display = valueDisplay ?? (variant === \"ring\" ? \"percent\" : \"tokens\");\n  const tweenedRatio = useTweened(ratio);\n  const tweenedUsed = useTweened(used);\n\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: false,\n    onChange: onOpenChange,\n  });\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const schedule = (next: boolean, delay: number) => {\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setOpen(next), delay);\n  };\n\n  const pct = Math.round(ratio * 100);\n  const levelText =\n    level === \"danger\" ? \", nearly full\" : level === \"warning\" ? \", filling up\" : \"\";\n  const accessibleName = `${label}: ${used.toLocaleString(\"en-US\")} of ${max.toLocaleString(\"en-US\")} tokens used (${pct}%${levelText})`;\n\n  const tone = cn(\n    level === \"ok\" && \"text-kura-fg-muted\",\n    level === \"warning\" && \"text-kura-warning-text\",\n    level === \"danger\" && \"text-kura-danger-text\",\n  );\n  const stroke =\n    level === \"danger\"\n      ? \"var(--kura-danger)\"\n      : level === \"warning\"\n        ? \"var(--kura-warning)\"\n        : \"var(--kura-fg-muted)\";\n\n  const value =\n    display === \"none\" ? null : (\n      <span\n        data-slot=\"context-meter-value\"\n        className={cn(\n          \"text-left font-kura-mono text-xs tabular-nums\",\n          display === \"percent\" ? \"min-w-[4ch]\" : \"min-w-[13ch]\",\n          tone,\n        )}\n      >\n        {display === \"percent\" ? (\n          `${Math.round(tweenedRatio * 100)}%`\n        ) : (\n          <>\n            {formatTokens(tweenedUsed)}\n            <span className=\"text-kura-fg-subtle\"> / {formatTokens(max)}</span>\n          </>\n        )}\n      </span>\n    );\n\n  const R = 7;\n  const C = 2 * Math.PI * R;\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          data-slot=\"context-meter\"\n          data-variant={variant}\n          data-level={level}\n          aria-label={accessibleName}\n          onPointerEnter={(e) => {\n            onPointerEnter?.(e);\n            if (e.pointerType !== \"touch\" && segments?.length) schedule(true, 300);\n          }}\n          onPointerLeave={(e) => {\n            onPointerLeave?.(e);\n            schedule(false, 150);\n          }}\n          onFocus={onFocus}\n          onBlur={(e) => {\n            onBlur?.(e);\n            schedule(false, 100);\n          }}\n          className={cn(\n            \"group/meter inline-flex h-7 cursor-pointer items-center gap-2 rounded-kura-sm px-1.5 font-kura-sans outline-none\",\n            \"transition-[background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-kura-focus data-[state=open]:bg-kura-hover\",\n            className,\n          )}\n          {...props}\n        >\n          {variant === \"ring\" ? (\n            <svg\n              aria-hidden\n              data-slot=\"context-meter-ring\"\n              viewBox=\"0 0 18 18\"\n              className=\"size-[18px] shrink-0 -rotate-90\"\n            >\n              <circle\n                cx=\"9\"\n                cy=\"9\"\n                r={R}\n                fill=\"none\"\n                strokeWidth=\"2.25\"\n                className=\"stroke-kura-border\"\n              />\n              <circle\n                cx=\"9\"\n                cy=\"9\"\n                r={R}\n                fill=\"none\"\n                strokeWidth=\"2.25\"\n                strokeLinecap=\"round\"\n                strokeDasharray={C}\n                strokeDashoffset={C * (1 - tweenedRatio)}\n                style={{ stroke }}\n                className=\"transition-[stroke] duration-(--kura-duration-slow) ease-kura-out\"\n                opacity={ratio === 0 ? 0 : 1}\n              />\n            </svg>\n          ) : (\n            <SegmentBar\n              segments={segments}\n              used={used}\n              max={max}\n              level={level}\n              ratio={tweenedRatio}\n              className=\"h-1.5 w-28 shrink-0\"\n            />\n          )}\n          {value}\n        </button>\n      </PopoverTrigger>\n      {segments && segments.length > 0 && (\n        <PopoverContent\n          side=\"top\"\n          align={variant === \"bar\" ? \"start\" : \"center\"}\n          data-slot=\"context-meter-card\"\n          className=\"w-72 p-3.5\"\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          onPointerEnter={() => clearTimeout(timer.current)}\n          onPointerLeave={() => schedule(false, 150)}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex items-baseline justify-between\">\n              <span className=\"font-medium text-kura-fg text-sm\">{label} window</span>\n              <span className={cn(\"font-kura-mono text-xs tabular-nums\", tone)}>\n                {formatTokens(used)}{\" \"}\n                <span className=\"text-kura-fg-subtle\">/ {formatTokens(max)}</span>\n              </span>\n            </div>\n            <SegmentBar\n              segments={segments}\n              used={used}\n              max={max}\n              level={level}\n              ratio={ratio}\n              className=\"h-2\"\n            />\n            <ul className=\"flex flex-col gap-1.5\" aria-label=\"Breakdown\">\n              {segments.map((s, i) => (\n                <li\n                  key={s.id}\n                  data-slot=\"context-meter-segment\"\n                  className=\"flex items-center gap-2 text-[13px]\"\n                >\n                  <span\n                    aria-hidden\n                    className=\"size-2 shrink-0 rounded-[2px]\"\n                    style={{ background: segmentColor(s, i) }}\n                  />\n                  <span className=\"min-w-0 flex-1 truncate text-kura-fg-muted\">{s.label}</span>\n                  <span className=\"font-kura-mono text-kura-fg text-xs tabular-nums\">\n                    {formatTokens(s.tokens)}\n                  </span>\n                  <span className=\"w-9 text-right font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n                    {max > 0 ? `${Math.round((s.tokens / max) * 100)}%` : \"–\"}\n                  </span>\n                </li>\n              ))}\n              <li className=\"flex items-center gap-2 text-[13px]\">\n                <span\n                  aria-hidden\n                  className=\"size-2 shrink-0 rounded-[2px] shadow-[inset_0_0_0_1px_var(--kura-border-strong)]\"\n                />\n                <span className=\"flex-1 text-kura-fg-subtle\">Free</span>\n                <span className=\"font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n                  {formatTokens(Math.max(0, max - used))}\n                </span>\n                <span className=\"w-9\" />\n              </li>\n            </ul>\n            {(footer != null || level !== \"ok\") && (\n              <p\n                className={cn(\n                  \"text-xs leading-relaxed shadow-[inset_0_1px_0_var(--kura-border-subtle)] pt-2.5\",\n                  level === \"ok\" ? \"text-kura-fg-muted\" : tone,\n                )}\n              >\n                {footer ??\n                  (level === \"danger\"\n                    ? \"Nearly full — the oldest turns will be dropped or summarized.\"\n                    : \"Filling up — consider starting a new thread for unrelated work.\")}\n              </p>\n            )}\n          </div>\n        </PopoverContent>\n      )}\n    </Popover>\n  );\n}\n\nfunction SegmentBar({\n  segments,\n  used,\n  max,\n  level,\n  ratio,\n  className,\n}: {\n  segments: readonly ContextSegment[] | undefined;\n  used: number;\n  max: number;\n  level: \"ok\" | \"warning\" | \"danger\";\n  ratio: number;\n  className?: string;\n}) {\n  const fill =\n    level === \"danger\"\n      ? \"bg-kura-danger\"\n      : level === \"warning\"\n        ? \"bg-kura-warning\"\n        : \"bg-kura-fg-muted\";\n  return (\n    <span\n      aria-hidden\n      data-slot=\"context-meter-bar\"\n      className={cn(\"relative flex overflow-hidden rounded-full bg-kura-active\", className)}\n    >\n      {segments && segments.length > 0 && level === \"ok\" ? (\n        <span\n          className=\"flex h-full origin-left transition-[width] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\"\n          style={{ width: `${ratio * 100}%` }}\n        >\n          {segments.map((s, i) => (\n            <span\n              key={s.id}\n              className=\"h-full [&+&]:ml-px\"\n              style={{\n                width: `${used > 0 ? (s.tokens / used) * 100 : 0}%`,\n                background: segmentColor(s, i),\n              }}\n            />\n          ))}\n        </span>\n      ) : (\n        <span\n          className={cn(\n            \"h-full rounded-full transition-[width,background-color] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\",\n            fill,\n          )}\n          style={{ width: `${max > 0 ? ratio * 100 : 0}%` }}\n        />\n      )}\n    </span>\n  );\n}\n"
        },
        {
          "target": "components/kura/context-meter/index.ts",
          "type": "registry:ui",
          "content": "export {\n  ContextMeter,\n  type ContextMeterProps,\n  type ContextSegment,\n  formatTokens,\n} from \"./context-meter\";\n"
        }
      ]
    },
    {
      "slug": "decode-text",
      "name": "DecodeText",
      "category": "effects",
      "status": "beta",
      "description": "Text reveal that scrambles glyphs and resolves them left to right, like an AI decoding — width-stable, with the final text read to screen readers at once.",
      "whenToUse": [
        "Revealing a short status or result the agent just produced: “Plan ready”, “Connected to runner-07”.",
        "Hero taglines or section headings that should arrive with a technical, machine-like flourish.",
        "IDs, hashes, timings and other mono data appearing after a computation."
      ],
      "whenNotToUse": [
        "Streaming a model's answer token by token — use streaming-text.",
        "An in-progress label — use shimmer-text or thinking-indicator.",
        "Paragraphs or anything longer than a line or two: the effect becomes noise and per-glyph DOM gets heavy.",
        "Text that changes many times a second (live counters) — render it plainly."
      ],
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The final text (a plain string). Changing it replays the reveal."
        },
        {
          "name": "startOn",
          "type": "\"mount\" | \"view\"",
          "default": "\"mount\"",
          "description": "When the first reveal runs: immediately, or the first time ≥ 40% of it scrolls into view (a dim, still scramble shows until then)."
        },
        {
          "name": "trigger",
          "type": "string | number | boolean",
          "description": "Replay whenever this value changes — a counter, an id or a boolean."
        },
        {
          "name": "duration",
          "type": "number",
          "default": "1000",
          "description": "Milliseconds for the reveal to sweep the whole text (ease-out)."
        },
        {
          "name": "spread",
          "type": "number",
          "default": "6",
          "description": "How many characters ahead of the resolved edge scramble actively, in the accent colour."
        },
        {
          "name": "glyphs",
          "type": "string",
          "description": "Characters used for scrambling. Default \"A–Z 0–9 #%&*+=/<>\". Try \"01\" for binary or a single \"·\" for a quiet reveal."
        },
        {
          "name": "as",
          "type": "\"span\" | \"p\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"code\"",
          "default": "\"span\"",
          "description": "Element to render."
        },
        {
          "name": "onComplete",
          "type": "() => void",
          "description": "Called when a reveal finishes (not called under reduced motion)."
        }
      ],
      "parts": [
        {
          "name": "DecodeText",
          "description": "Root element. `data-state` = pending | running | done.",
          "dataSlot": "decode-text"
        },
        {
          "name": "Glyphs",
          "description": "The visual (aria-hidden) text; plain text once done.",
          "dataSlot": "decode-text-glyphs"
        },
        {
          "name": "Char",
          "description": "One glyph slot while decoding. `data-state` = pending | active | done; `data-char` holds the final character.",
          "dataSlot": "decode-text-char"
        }
      ],
      "a11y": {
        "notes": [
          "The final text is always present in a visually hidden span, so screen readers read it immediately and once; the scrambling glyphs are aria-hidden.",
          "Not a live region. Wrap it in role=status if a replay (new text) should be announced.",
          "Each glyph reserves the width of its final character, so the line never reflows while decoding."
        ],
        "keyboard": [],
        "reducedMotion": "No scramble: the final text renders immediately."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "streaming-text",
        "shimmer-text",
        "thinking-indicator"
      ],
      "tags": [
        "text",
        "reveal",
        "scramble",
        "decode",
        "typewriter",
        "animation",
        "mono",
        "hacker"
      ],
      "examples": [
        {
          "name": "DecodeTextMinimal",
          "title": "Decode on mount",
          "kind": "minimal",
          "code": "import { DecodeText } from \"@kura-ui/react/decode-text\";\n\nexport function DecodeTextMinimal() {\n  return <DecodeText className=\"font-kura-mono text-kura-fg text-sm\">Agent online</DecodeText>;\n}\n"
        },
        {
          "name": "DecodeTextFull",
          "title": "Triggers, glyph sets and fonts",
          "description": "Replay via `trigger`, text changes, `startOn=\"view\"`, a binary glyph set and a proportional font.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { DecodeText } from \"@kura-ui/react/decode-text\";\n\nconst lines = [\n  \"Plan ready: 3 steps, 2 files\",\n  \"Migration applied to staging\",\n  \"All 214 tests passing\",\n];\n\nexport function DecodeTextFull() {\n  const [i, setI] = React.useState(0);\n  const [runs, setRuns] = React.useState(0);\n  return (\n    <div className=\"flex w-full max-w-md flex-col items-start gap-5\">\n      <DecodeText\n        as=\"h3\"\n        trigger={runs}\n        duration={1400}\n        spread={8}\n        className=\"font-kura-mono font-medium text-kura-fg text-xl tracking-tight\"\n      >\n        {lines[i % lines.length]!}\n      </DecodeText>\n      <DecodeText\n        as=\"p\"\n        startOn=\"view\"\n        duration={900}\n        glyphs=\"01\"\n        className=\"font-kura-mono text-kura-fg-muted text-xs\"\n      >\n        sha 9f3c2e1 · 412 ms · 1,284 tokens\n      </DecodeText>\n      <DecodeText as=\"p\" duration={1200} className=\"text-kura-fg-muted text-sm\">\n        Proportional fonts keep their width while decoding.\n      </DecodeText>\n      <div className=\"flex gap-1.5\">\n        <Button size=\"sm\" variant=\"secondary\" onClick={() => setI((v) => v + 1)}>\n          Next message\n        </Button>\n        <Button size=\"sm\" variant=\"ghost\" onClick={() => setRuns((r) => r + 1)}>\n          <RotateCcw /> Replay\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "DecodeTextAgentHandshake",
          "title": "Agent connection status",
          "description": "A status card that decodes its new state inside a polite live region.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { DecodeText } from \"@kura-ui/react/decode-text\";\n\nexport function DecodeTextAgentHandshake() {\n  const [connected, setConnected] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-sm flex-col gap-3 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm\">\n      <div className=\"flex items-center justify-between\">\n        <span className=\"text-kura-fg-subtle text-xs uppercase tracking-wide\">Remote agent</span>\n        <span\n          className=\"size-2 rounded-full bg-kura-accent data-[on=false]:bg-kura-gray-8\"\n          data-on={connected}\n          aria-hidden\n        />\n      </div>\n      <div role=\"status\" aria-live=\"polite\" className=\"font-kura-mono text-kura-fg text-sm\">\n        <DecodeText trigger={connected} duration={1100}>\n          {connected ? \"Connected to build-runner-07\" : \"Not connected\"}\n        </DecodeText>\n      </div>\n      <Button\n        size=\"sm\"\n        variant={connected ? \"secondary\" : \"primary\"}\n        onClick={() => setConnected((c) => !c)}\n      >\n        {connected ? \"Disconnect\" : \"Connect\"}\n      </Button>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass a plain string child. For mixed formatting, wrap several DecodeTexts.",
        "Monospace (`font-kura-mono`) gives the cleanest look; proportional fonts work because each glyph keeps its final width.",
        "Use `trigger` to replay without changing the text (e.g. when a card re-opens); change `children` to decode new text.",
        "Keep it to one or two lines and ≤ 1.5s — it should feel like arrival, not waiting."
      ],
      "title": "Decode Text",
      "importPath": "@kura-ui/react/decode-text",
      "exports": [
        "DecodeText",
        "DecodeTextProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/decode-text",
      "markdownUrl": "https://kuro.abstergo.fyi/components/decode-text.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/decode-text.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/decode-text.json",
        "cli": "npx @kura-ui/cli add decode-text",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "children": {
            "description": "The final text (a plain string). Changing it replays the reveal.",
            "x-ts-type": "string",
            "type": "string"
          },
          "startOn": {
            "description": "When the first reveal runs: immediately, or the first time ≥ 40% of it scrolls into view (a dim, still scramble shows until then).",
            "x-ts-type": "\"mount\" | \"view\"",
            "type": "string",
            "enum": [
              "mount",
              "view"
            ],
            "default": "mount"
          },
          "trigger": {
            "description": "Replay whenever this value changes — a counter, an id or a boolean.",
            "x-ts-type": "string | number | boolean"
          },
          "duration": {
            "description": "Milliseconds for the reveal to sweep the whole text (ease-out).",
            "x-ts-type": "number",
            "type": "number",
            "default": 1000
          },
          "spread": {
            "description": "How many characters ahead of the resolved edge scramble actively, in the accent colour.",
            "x-ts-type": "number",
            "type": "number",
            "default": 6
          },
          "glyphs": {
            "description": "Characters used for scrambling. Default \"A–Z 0–9 #%&*+=/<>\". Try \"01\" for binary or a single \"·\" for a quiet reveal.",
            "x-ts-type": "string",
            "type": "string"
          },
          "as": {
            "description": "Element to render.",
            "x-ts-type": "\"span\" | \"p\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"code\"",
            "type": "string",
            "enum": [
              "code",
              "div",
              "h1",
              "h2",
              "h3",
              "p",
              "span"
            ],
            "default": "span"
          },
          "onComplete": {
            "description": "Called when a reveal finishes (not called under reduced motion).",
            "x-ts-type": "() => void"
          }
        },
        "required": [
          "children"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/decode-text/decode-text.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type DecodeTextStartOn = \"mount\" | \"view\";\n\nexport interface DecodeTextProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** The final text. Plain string only. */\n  children: string;\n  /** When the first reveal runs: on mount, or the first time it scrolls into view. Default \"mount\". */\n  startOn?: DecodeTextStartOn;\n  /** Replay the reveal whenever this value changes (a counter, an id, a boolean…). */\n  trigger?: string | number | boolean;\n  /** Milliseconds for the reveal to sweep the whole text. Default 1000. */\n  duration?: number;\n  /** How many characters ahead of the resolved edge actively scramble (in accent). Default 6. */\n  spread?: number;\n  /** Characters used for the scramble. Default: A–Z, 0–9 and a few symbols. */\n  glyphs?: string;\n  /** Element to render. Default \"span\". */\n  as?: \"span\" | \"p\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"code\";\n  /** Called when a reveal finishes. */\n  onComplete?: () => void;\n}\n\ntype Phase = \"pending\" | \"running\" | \"done\";\n\nconst DEFAULT_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>\";\n\nconst easeOut = (t: number) => 1 - (1 - t) ** 2;\n\n/**\n * A text reveal that scrambles glyphs and resolves them left to right, like an AI decoding a\n * message. Every glyph slot reserves the width of its final character, so proportional fonts do\n * not jitter (monospace fonts look best). Screen readers get the final text immediately; the\n * animation is aria-hidden. Runs on mount, on first view, or when `trigger` changes.\n */\nexport function DecodeText({\n  children: text,\n  startOn = \"mount\",\n  trigger,\n  duration = 1000,\n  spread = 6,\n  glyphs = DEFAULT_GLYPHS,\n  as = \"span\",\n  onComplete,\n  className,\n  ref,\n  ...props\n}: DecodeTextProps) {\n  const reducedMotion = useReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const charRefs = React.useRef<Array<HTMLSpanElement | null>>([]);\n  const [phase, setPhase] = React.useState<Phase>(\"pending\");\n  const seen = React.useRef(startOn === \"mount\");\n  const live = React.useRef({ duration, spread, glyphs, onComplete });\n  live.current = { duration, spread, glyphs, onComplete };\n\n  const words = React.useMemo(() => text.split(/(\\s+)/), [text]);\n\n  const scrambleAll = React.useCallback(() => {\n    const g = live.current.glyphs || DEFAULT_GLYPHS;\n    for (const el of charRefs.current) {\n      if (!el) continue;\n      el.textContent = g[Math.floor(Math.random() * g.length)] ?? \"\";\n      el.dataset.state = \"pending\";\n    }\n  }, []);\n\n  // Decide what to do whenever the text or trigger changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: replay on text/trigger changes only.\n  React.useLayoutEffect(() => {\n    if (reducedMotion) {\n      setPhase(\"done\");\n      return;\n    }\n    if (seen.current) {\n      setPhase(\"running\");\n      return;\n    }\n    setPhase(\"pending\");\n    const el = rootRef.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      seen.current = true;\n      setPhase(\"running\");\n      return;\n    }\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        io.disconnect();\n        seen.current = true;\n        setPhase(\"running\");\n      },\n      { threshold: 0.4 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [text, trigger, reducedMotion]);\n\n  // Pending: show a still scramble so the slot is not empty while waiting to be seen.\n  React.useLayoutEffect(() => {\n    if (phase === \"pending\") scrambleAll();\n  }, [phase, scrambleAll]);\n\n  // Running: drive the glyphs imperatively (no React re-render per frame).\n  // biome-ignore lint/correctness/useExhaustiveDependencies: restarts per run; reads live props.\n  React.useLayoutEffect(() => {\n    if (phase !== \"running\") return;\n    const els = charRefs.current.filter((e): e is HTMLSpanElement => !!e);\n    const finals = els.map((e) => e.dataset.char ?? \"\");\n    const n = els.length;\n    scrambleAll();\n    const start = performance.now();\n    const lastSwap = new Float64Array(n);\n    let raf = 0;\n    const frame = (now: number) => {\n      const { duration: d, spread: s, glyphs: gl } = live.current;\n      const g = gl || DEFAULT_GLYPHS;\n      const t = Math.min(1, (now - start) / Math.max(1, d));\n      const front = easeOut(t) * (n + s);\n      for (let k = 0; k < n; k++) {\n        const el = els[k]!;\n        if (front >= k + s || t >= 1) {\n          if (el.dataset.state !== \"done\") {\n            el.textContent = finals[k]!;\n            el.dataset.state = \"done\";\n          }\n          continue;\n        }\n        const active = front >= k;\n        const every = active ? 45 : 140;\n        if (now - lastSwap[k]! > every) {\n          lastSwap[k] = now;\n          el.textContent = g[Math.floor(Math.random() * g.length)] ?? \"\";\n        }\n        el.dataset.state = active ? \"active\" : \"pending\";\n      }\n      if (t < 1) raf = requestAnimationFrame(frame);\n      else {\n        setPhase(\"done\");\n        live.current.onComplete?.();\n      }\n    };\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [phase, text, trigger]);\n\n  const Comp = as as \"span\";\n  charRefs.current = [];\n  let index = 0;\n\n  return (\n    <Comp\n      ref={mergeRefs(rootRef as React.Ref<HTMLSpanElement>, ref)}\n      data-slot=\"decode-text\"\n      data-state={phase}\n      className={cn(\"tabular-nums\", className)}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {phase === \"done\" ? (\n        <span aria-hidden data-slot=\"decode-text-glyphs\">\n          {text}\n        </span>\n      ) : (\n        <span aria-hidden data-slot=\"decode-text-glyphs\">\n          {words.map((word, w) =>\n            /^\\s+$/.test(word) || word === \"\" ? (\n              word\n            ) : (\n              <span key={w} className=\"inline-block whitespace-nowrap\">\n                {Array.from(word).map((ch, c) => {\n                  const i = index++;\n                  return (\n                    <span key={c} className=\"inline-grid\">\n                      <span className=\"invisible col-start-1 row-start-1\">{ch}</span>\n                      <span\n                        ref={(el) => {\n                          charRefs.current[i] = el;\n                        }}\n                        data-slot=\"decode-text-char\"\n                        data-char={ch}\n                        data-state=\"pending\"\n                        className=\"col-start-1 row-start-1 overflow-visible text-center transition-colors duration-(--kura-duration-fast) data-[state=active]:text-kura-accent-text data-[state=pending]:text-kura-fg-subtle data-[state=pending]:opacity-50\"\n                      >\n                        {ch}\n                      </span>\n                    </span>\n                  );\n                })}\n              </span>\n            ),\n          )}\n        </span>\n      )}\n    </Comp>\n  );\n}\n"
        },
        {
          "target": "components/kura/decode-text/index.ts",
          "type": "registry:ui",
          "content": "export { DecodeText, type DecodeTextProps } from \"./decode-text\";\n"
        }
      ]
    },
    {
      "slug": "grain",
      "name": "Grain",
      "category": "effects",
      "status": "beta",
      "description": "Film-grain overlay from a cached SVG-noise tile, with blend modes and an optional compositor-only flicker — texture for gradients and heroes.",
      "whenToUse": [
        "Adding a print-like finish on top of a mesh-gradient, aurora or photo.",
        "Hiding colour banding on large, low-contrast gradients.",
        "Giving a flat hero or empty-state panel a subtle tactile texture."
      ],
      "whenNotToUse": [
        "As the background itself — combine it with mesh-gradient or aurora.",
        "Over body text or dense UI: noise reduces legibility.",
        "Signalling activity — grain is texture; use border-beam or thinking-indicator."
      ],
      "props": [
        {
          "name": "opacity",
          "type": "number",
          "default": "0.12",
          "description": "Strength of the grain, 0–1. 0.08–0.2 is subtle; 0.3+ is stylised."
        },
        {
          "name": "blend",
          "type": "\"overlay\" | \"soft-light\" | \"multiply\" | \"screen\" | \"normal\"",
          "default": "\"overlay\"",
          "description": "mix-blend-mode. overlay works on mid and dark tones; multiply suits light surfaces; screen suits very dark ones."
        },
        {
          "name": "scale",
          "type": "number",
          "default": "1",
          "description": "Grain size multiplier: 1 is fine film grain, 2–3 is coarse."
        },
        {
          "name": "animated",
          "type": "boolean",
          "default": "false",
          "description": "Flicker the grain like projected film (a stepped transform, compositor-only). Off by default."
        },
        {
          "name": "fps",
          "type": "number",
          "default": "12",
          "description": "Flicker rate when animated. 8–15 reads as film; higher looks like TV static."
        }
      ],
      "parts": [
        {
          "name": "Grain",
          "description": "Absolutely positioned overlay (`inset-0`, pointer-events none, overflow hidden) carrying opacity and blend mode. `data-animated` mirrors `animated`.",
          "dataSlot": "grain"
        },
        {
          "name": "Layer",
          "description": "Oversized noise layer (200%) that the flicker translates.",
          "dataSlot": "grain-layer"
        }
      ],
      "a11y": {
        "notes": [
          "Decorative and aria-hidden; it never receives pointer events.",
          "Keep opacity low over text; grain can lower perceived contrast."
        ],
        "keyboard": [],
        "reducedMotion": "The flicker stops; the grain stays as a static texture."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "mesh-gradient",
        "aurora",
        "shader-canvas",
        "particle-field"
      ],
      "tags": [
        "grain",
        "noise",
        "texture",
        "film",
        "overlay",
        "svg",
        "background",
        "effect"
      ],
      "examples": [
        {
          "name": "GrainMinimal",
          "title": "Grain over a gradient",
          "description": "Place it inside any positioned element.",
          "kind": "minimal",
          "code": "import { Grain } from \"@kura-ui/react/grain\";\n\nexport function GrainMinimal() {\n  return (\n    <div className=\"relative h-48 w-full max-w-md overflow-hidden rounded-kura-xl bg-[radial-gradient(120%_90%_at_20%_10%,var(--kura-accent-subtle),var(--kura-gray-3))] shadow-kura-sm\">\n      <Grain />\n    </div>\n  );\n}\n"
        },
        {
          "name": "GrainFull",
          "title": "Blend modes, opacity, scale, flicker",
          "description": "Compare every blend mode on a light and a dark surface.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Grain, type GrainBlend } from \"@kura-ui/react/grain\";\n\nconst blends: GrainBlend[] = [\"overlay\", \"soft-light\", \"multiply\", \"screen\", \"normal\"];\n\nexport function GrainFull() {\n  const [blend, setBlend] = React.useState<GrainBlend>(\"overlay\");\n  const [opacity, setOpacity] = React.useState(0.35);\n  const [scale, setScale] = React.useState(1);\n  const [animated, setAnimated] = React.useState(true);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <div className=\"grid h-56 grid-cols-2 overflow-hidden rounded-kura-xl shadow-kura-sm\">\n        {[\"bg-kura-accent\", \"bg-kura-gray-12\"].map((bg) => (\n          <div key={bg} className={`relative ${bg}`}>\n            <Grain opacity={opacity} blend={blend} scale={scale} animated={animated} fps={12} />\n          </div>\n        ))}\n      </div>\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        {blends.map((b) => (\n          <Button\n            key={b}\n            size=\"xs\"\n            variant={b === blend ? \"secondary\" : \"ghost\"}\n            aria-pressed={b === blend}\n            onClick={() => setBlend(b)}\n          >\n            {b}\n          </Button>\n        ))}\n        <Button\n          size=\"xs\"\n          variant=\"ghost\"\n          className=\"ml-auto\"\n          aria-pressed={animated}\n          onClick={() => setAnimated((a) => !a)}\n        >\n          Flicker {animated ? \"on\" : \"off\"}\n        </Button>\n      </div>\n      <div className=\"grid grid-cols-2 gap-6 text-kura-fg-muted text-xs\">\n        <label className=\"flex flex-col gap-1.5\">\n          Opacity {opacity.toFixed(2)}\n          <input\n            type=\"range\"\n            min={0}\n            max={0.6}\n            step={0.01}\n            value={opacity}\n            onChange={(e) => setOpacity(Number(e.target.value))}\n            className=\"accent-kura-accent\"\n          />\n        </label>\n        <label className=\"flex flex-col gap-1.5\">\n          Scale {scale.toFixed(1)}\n          <input\n            type=\"range\"\n            min={0.5}\n            max={3}\n            step={0.1}\n            value={scale}\n            onChange={(e) => setScale(Number(e.target.value))}\n            className=\"accent-kura-accent\"\n          />\n        </label>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "GrainOnGradient",
          "title": "On a mesh gradient",
          "description": "The classic pairing: MeshGradient with Grain as its last child.",
          "kind": "composition",
          "code": "import { MeshGradient } from \"@kura-ui/react/mesh-gradient\";\nimport { Grain } from \"@kura-ui/react/grain\";\n\nexport function GrainOnGradient() {\n  return (\n    <MeshGradient\n      colors=\"signal\"\n      className=\"flex h-64 w-full items-end rounded-kura-2xl p-6 shadow-kura-md\"\n    >\n      <p className=\"relative z-10 max-w-xs font-medium text-kura-fg text-lg tracking-tight\">\n        A little grain turns a gradient into a print.\n      </p>\n      <Grain opacity={0.3} blend=\"overlay\" />\n    </MeshGradient>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "The parent must be positioned (`relative`) — Grain is `absolute inset-0`. Render it last so it sits on top, and lift text above it with `relative z-10` if needed.",
        "Server-safe: no hooks, no 'use client'.",
        "On near-white surfaces `overlay` is almost invisible by design; use `blend=\"multiply\"` with opacity ~0.06 there.",
        "One Grain per surface; it tiles a 180px image, so cost does not grow with size."
      ],
      "title": "Grain",
      "importPath": "@kura-ui/react/grain",
      "exports": [
        "Grain",
        "GrainProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/grain",
      "markdownUrl": "https://kuro.abstergo.fyi/components/grain.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/grain.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/grain.json",
        "cli": "npx @kura-ui/cli add grain",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "opacity": {
            "description": "Strength of the grain, 0–1. 0.08–0.2 is subtle; 0.3+ is stylised.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.12
          },
          "blend": {
            "description": "mix-blend-mode. overlay works on mid and dark tones; multiply suits light surfaces; screen suits very dark ones.",
            "x-ts-type": "\"overlay\" | \"soft-light\" | \"multiply\" | \"screen\" | \"normal\"",
            "type": "string",
            "enum": [
              "multiply",
              "normal",
              "overlay",
              "screen",
              "soft-light"
            ],
            "default": "overlay"
          },
          "scale": {
            "description": "Grain size multiplier: 1 is fine film grain, 2–3 is coarse.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "animated": {
            "description": "Flicker the grain like projected film (a stepped transform, compositor-only). Off by default.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "fps": {
            "description": "Flicker rate when animated. 8–15 reads as film; higher looks like TV static.",
            "x-ts-type": "number",
            "type": "number",
            "default": 12
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/grain/grain.tsx",
          "type": "registry:ui",
          "content": "import type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type GrainBlend = \"overlay\" | \"soft-light\" | \"multiply\" | \"screen\" | \"normal\";\n\nconst CSS = `\n@keyframes kura-grain {\n  0%, 100% { transform: translate(0, 0); }\n  12.5% { transform: translate(-7%, 4%); }\n  25% { transform: translate(5%, -9%); }\n  37.5% { transform: translate(-11%, -3%); }\n  50% { transform: translate(8%, 7%); }\n  62.5% { transform: translate(-3%, 11%); }\n  75% { transform: translate(10%, -5%); }\n  87.5% { transform: translate(-6%, -10%); }\n}\n`;\n\nconst TILE = 180;\n\n/** A tiling, high-contrast monochrome noise tile (SVG feTurbulence) as a data URI. */\nfunction noiseTile(scale: number): string {\n  const freq = (0.85 / Math.max(0.25, scale)).toFixed(3);\n  const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${TILE}' height='${TILE}'><filter id='n' x='0' y='0'><feTurbulence type='fractalNoise' baseFrequency='${freq}' numOctaves='3' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/><feComponentTransfer><feFuncR type='linear' slope='2.4' intercept='-0.7'/><feFuncG type='linear' slope='2.4' intercept='-0.7'/><feFuncB type='linear' slope='2.4' intercept='-0.7'/></feComponentTransfer></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>`;\n  return `url(\"data:image/svg+xml,${svg.replace(/\"/g, \"'\").replace(/</g, \"%3C\").replace(/>/g, \"%3E\")}\")`;\n}\n\nexport interface GrainProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Strength of the grain, 0–1. Default 0.12. */\n  opacity?: number;\n  /** How the grain mixes with what is underneath. Default \"overlay\". */\n  blend?: GrainBlend;\n  /** Grain size multiplier; 1 is fine film grain, 2 is coarse. Default 1. */\n  scale?: number;\n  /** Flicker the grain like projected film. Off by default — stillness is the Kura default. */\n  animated?: boolean;\n  /** Flicker rate in frames per second when `animated`. Default 12. */\n  fps?: number;\n}\n\n/**\n * A film-grain overlay for gradients, heroes and imagery. One cached SVG-noise tile, composited\n * with a blend mode; the optional flicker only moves a transform in `steps()`, so it stays on the\n * compositor and costs next to nothing. Pure CSS (server-safe), decorative, reduced-motion aware.\n */\nexport function Grain({\n  opacity = 0.12,\n  blend = \"overlay\",\n  scale = 1,\n  animated = false,\n  fps = 12,\n  className,\n  style,\n  ...props\n}: GrainProps) {\n  const frames = 8;\n  return (\n    <div\n      aria-hidden\n      data-slot=\"grain\"\n      data-animated={animated}\n      className={cn(\"pointer-events-none absolute inset-0 overflow-hidden\", className)}\n      style={{\n        opacity: Math.min(1, Math.max(0, opacity)),\n        mixBlendMode: blend,\n        ...style,\n      }}\n      {...props}\n    >\n      {animated && (\n        <style href=\"kura-grain\" precedence=\"kura\">\n          {CSS}\n        </style>\n      )}\n      <div\n        data-slot=\"grain-layer\"\n        className={cn(\n          \"absolute -inset-1/2\",\n          animated &&\n            \"[animation:var(--kura-grain-animation)] will-change-transform motion-reduce:animate-none\",\n        )}\n        style={{\n          backgroundImage: noiseTile(scale),\n          backgroundSize: `${TILE}px ${TILE}px`,\n          [\"--kura-grain-animation\" as string]: `kura-grain ${(frames / Math.max(1, fps)).toFixed(3)}s steps(1, end) infinite`,\n        }}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/grain/index.ts",
          "type": "registry:ui",
          "content": "export { Grain, type GrainProps } from \"./grain\";\n"
        }
      ]
    },
    {
      "slug": "icon-button",
      "name": "IconButton",
      "category": "primitives",
      "status": "stable",
      "description": "Square icon-only button with a mandatory accessible label and an automatic tooltip (with optional shortcut).",
      "whenToUse": [
        "Toolbar and composer actions: attach, mic, send, stop, copy, retry.",
        "Any action represented only by an icon."
      ],
      "whenNotToUse": [
        "Actions that need a visible text label — use button.",
        "Toggles with on/off state in a group — compose button with aria-pressed."
      ],
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Accessible name and tooltip text."
        },
        {
          "name": "icon",
          "type": "React.ReactNode",
          "required": true,
          "description": "The icon element, e.g. <ArrowUp /> from lucide-react."
        },
        {
          "name": "tooltip",
          "type": "boolean",
          "default": "true",
          "description": "Show label (and shortcut) in a tooltip."
        },
        {
          "name": "shortcut",
          "type": "readonly KeyName[]",
          "description": "Shortcut shown in the tooltip (display only)."
        },
        {
          "name": "tooltipSide",
          "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
          "default": "\"top\"",
          "description": "Tooltip side."
        },
        {
          "name": "variant",
          "type": "\"primary\" | \"secondary\" | \"ghost\" | \"outline\" | \"accent\" | \"danger\"",
          "default": "\"ghost\"",
          "description": "Same variants as button; defaults to ghost."
        },
        {
          "name": "size",
          "type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
          "default": "\"md\"",
          "description": "24 / 28 / 32 / 40px square."
        }
      ],
      "a11y": {
        "notes": [
          "`label` becomes aria-label, so the control always has an accessible name.",
          "The tooltip opens on focus, so keyboard users see the label and shortcut too."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Activate"
          }
        ],
        "reducedMotion": "Inherits button: no press scale; tooltip fades without travel."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "button",
          "tooltip"
        ]
      },
      "related": [
        "button",
        "tooltip",
        "kbd-shortcut"
      ],
      "tags": [
        "button",
        "icon",
        "toolbar",
        "action",
        "tooltip"
      ],
      "examples": [
        {
          "name": "IconButtonMinimal",
          "title": "Basic",
          "kind": "minimal",
          "code": "import { Copy } from \"lucide-react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\n\nexport function IconButtonMinimal() {\n  return <IconButton label=\"Copy\" icon={<Copy />} />;\n}\n"
        },
        {
          "name": "IconButtonFull",
          "title": "Composer toolbar",
          "kind": "full",
          "code": "import { ArrowUp, Mic, Paperclip, RotateCcw } from \"lucide-react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\n\nexport function IconButtonFull() {\n  return (\n    <div className=\"flex items-center gap-1 rounded-kura-xl bg-kura-surface p-1.5 shadow-kura-sm\">\n      <IconButton label=\"Attach file\" icon={<Paperclip />} size=\"sm\" />\n      <IconButton label=\"Dictate\" icon={<Mic />} size=\"sm\" shortcut={[\"mod\", \"d\"]} />\n      <IconButton label=\"Retry\" icon={<RotateCcw />} size=\"sm\" tooltipSide=\"bottom\" />\n      <span className=\"mx-1 h-4 w-px bg-kura-border\" />\n      <IconButton\n        label=\"Send\"\n        icon={<ArrowUp />}\n        size=\"sm\"\n        variant=\"primary\"\n        shortcut={[\"mod\", \"enter\"]}\n        className=\"rounded-full\"\n      />\n      <IconButton\n        label=\"Sending disabled\"\n        icon={<ArrowUp />}\n        size=\"sm\"\n        variant=\"secondary\"\n        disabled\n        tooltip={false}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "IconButtonMessageActions",
          "title": "Message action bar",
          "description": "Copy with a confirm swap, pressed feedback toggles, a loading regenerate and shortcut tooltips under an assistant reply.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Check, Copy, RotateCcw, Share, ThumbsDown, ThumbsUp, Volume2 } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\n\nexport function IconButtonMessageActions() {\n  const [copied, setCopied] = React.useState(false);\n  const [rating, setRating] = React.useState<\"up\" | \"down\" | null>(null);\n  const [regenerating, setRegenerating] = React.useState(false);\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  React.useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  const copy = () => {\n    setCopied(true);\n    timers.current.push(setTimeout(() => setCopied(false), 1600));\n  };\n  const regenerate = () => {\n    setRegenerating(true);\n    timers.current.push(setTimeout(() => setRegenerating(false), 1400));\n  };\n\n  return (\n    <article\n      aria-label=\"Assistant reply\"\n      className=\"flex w-full max-w-lg flex-col gap-3 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm\"\n    >\n      <p className=\"text-[15px] text-kura-fg leading-relaxed\">\n        The dip lines up with the 2.4 release: onboarding completion fell from 71% to 58% the day\n        the new permissions step shipped. Moving that step after the first project is created should\n        recover most of it.\n      </p>\n      <div className=\"flex items-center justify-between gap-3\">\n        <div\n          role=\"toolbar\"\n          aria-label=\"Message actions\"\n          className=\"-ml-1.5 flex items-center gap-0.5\"\n        >\n          <IconButton\n            label={copied ? \"Copied\" : \"Copy\"}\n            icon={copied ? <Check className=\"text-kura-success-text\" /> : <Copy />}\n            size=\"sm\"\n            shortcut={[\"mod\", \"shift\", \"c\"]}\n            onClick={copy}\n          />\n          <IconButton\n            label=\"Good response\"\n            icon={<ThumbsUp />}\n            size=\"sm\"\n            aria-pressed={rating === \"up\"}\n            className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n            onClick={() => setRating((r) => (r === \"up\" ? null : \"up\"))}\n          />\n          <IconButton\n            label=\"Bad response\"\n            icon={<ThumbsDown />}\n            size=\"sm\"\n            aria-pressed={rating === \"down\"}\n            className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n            onClick={() => setRating((r) => (r === \"down\" ? null : \"down\"))}\n          />\n          <IconButton label=\"Read aloud\" icon={<Volume2 />} size=\"sm\" />\n          <span aria-hidden className=\"mx-1 h-4 w-px bg-kura-border\" />\n          <IconButton\n            label=\"Regenerate\"\n            icon={<RotateCcw />}\n            size=\"sm\"\n            shortcut={[\"mod\", \"r\"]}\n            loading={regenerating}\n            onClick={regenerate}\n          />\n          <IconButton label=\"Share\" icon={<Share />} size=\"sm\" />\n        </div>\n        <p className=\"shrink-0 font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n          Sonnet 5.5 · 2.1s\n        </p>\n      </div>\n    </article>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Never pass aria-label; use `label`. The shortcut is display-only — bind it with useHotkey or kbd-shortcut."
      ],
      "title": "Icon Button",
      "importPath": "@kura-ui/react/icon-button",
      "exports": [
        "IconButton",
        "IconButtonProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/icon-button",
      "markdownUrl": "https://kuro.abstergo.fyi/components/icon-button.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/icon-button.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/icon-button.json",
        "cli": "npx @kura-ui/cli add icon-button",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "kura-keys",
        "tooltip",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "label": {
            "description": "Accessible name and tooltip text.",
            "x-ts-type": "string",
            "type": "string"
          },
          "icon": {
            "description": "The icon element, e.g. <ArrowUp /> from lucide-react.",
            "x-ts-type": "React.ReactNode"
          },
          "tooltip": {
            "description": "Show label (and shortcut) in a tooltip.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "shortcut": {
            "description": "Shortcut shown in the tooltip (display only).",
            "x-ts-type": "readonly KeyName[]",
            "type": "array"
          },
          "tooltipSide": {
            "description": "Tooltip side.",
            "x-ts-type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
            "type": "string",
            "enum": [
              "bottom",
              "left",
              "right",
              "top"
            ],
            "default": "top"
          },
          "variant": {
            "description": "Same variants as button; defaults to ghost.",
            "x-ts-type": "\"primary\" | \"secondary\" | \"ghost\" | \"outline\" | \"accent\" | \"danger\"",
            "type": "string",
            "enum": [
              "accent",
              "danger",
              "ghost",
              "outline",
              "primary",
              "secondary"
            ],
            "default": "ghost"
          },
          "size": {
            "description": "24 / 28 / 32 / 40px square.",
            "x-ts-type": "\"xs\" | \"sm\" | \"md\" | \"lg\"",
            "type": "string",
            "enum": [
              "lg",
              "md",
              "sm",
              "xs"
            ],
            "default": "md"
          }
        },
        "required": [
          "label",
          "icon"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/icon-button/icon-button.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Button, type ButtonProps } from \"@/components/kura/button\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport interface IconButtonProps extends Omit<ButtonProps, \"children\" | \"aria-label\"> {\n  /** Required accessible name. Also used as the tooltip text. */\n  label: string;\n  /** The icon, e.g. `<ArrowUp />` from lucide-react. Sized automatically. */\n  icon: React.ReactNode;\n  /** Show `label` (and `shortcut`) in a tooltip. Default true. */\n  tooltip?: boolean;\n  /** Shortcut displayed in the tooltip. Display only — bind it with `useHotkey`. */\n  shortcut?: readonly KeyName[];\n  tooltipSide?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}\n\nconst square = {\n  xs: \"size-6 px-0\",\n  sm: \"size-7 px-0\",\n  md: \"size-8 px-0\",\n  lg: \"size-10 px-0\",\n} as const;\n\n/**\n * Square, icon-only button with a mandatory accessible label and an automatic tooltip.\n * Defaults to the `ghost` variant.\n */\nexport function IconButton({\n  label,\n  icon,\n  tooltip = true,\n  shortcut,\n  tooltipSide = \"top\",\n  variant = \"ghost\",\n  size = \"md\",\n  className,\n  ...props\n}: IconButtonProps) {\n  const button = (\n    <Button\n      data-slot=\"icon-button\"\n      aria-label={label}\n      variant={variant}\n      size={size}\n      className={cn(square[size], className)}\n      {...props}\n    >\n      {icon}\n    </Button>\n  );\n  if (!tooltip) return button;\n  return (\n    <Tooltip content={label} shortcut={shortcut} side={tooltipSide}>\n      {button}\n    </Tooltip>\n  );\n}\n"
        },
        {
          "target": "components/kura/icon-button/index.ts",
          "type": "registry:ui",
          "content": "export { IconButton, type IconButtonProps } from \"./icon-button\";\n"
        }
      ]
    },
    {
      "slug": "kbd",
      "name": "Kbd",
      "category": "primitives",
      "status": "stable",
      "description": "Keyboard key or key combo, platform-aware (⌘ on Apple, Ctrl elsewhere) and hydration-safe.",
      "whenToUse": [
        "Show a key or combo inline in text, menus, tooltips or docs.",
        "Building blocks for shortcut hints inside other components."
      ],
      "whenNotToUse": [
        "A labelled hint that should also bind the shortcut — use kbd-shortcut.",
        "A shortcut inside a tooltip — pass `shortcut` to tooltip."
      ],
      "props": [
        {
          "name": "keys",
          "type": "readonly KeyName[]",
          "description": "Combo to render, e.g. [\"mod\", \"k\"]. When omitted, children render as one key."
        },
        {
          "name": "variant",
          "type": "\"raised\" | \"ghost\"",
          "default": "\"raised\"",
          "description": "raised = standalone keycap; ghost = inline inside buttons, tooltips and menus."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "default": "\"md\"",
          "description": "18px or 22px tall."
        },
        {
          "name": "pressed",
          "type": "boolean",
          "default": "false",
          "description": "Render the pressed state."
        }
      ],
      "parts": [
        {
          "name": "Kbd",
          "description": "Outer <kbd>. For combos it contains sr-only label text.",
          "dataSlot": "kbd"
        },
        {
          "name": "Key",
          "description": "One nested <kbd> per key in a combo (aria-hidden).",
          "dataSlot": "kbd-key"
        }
      ],
      "a11y": {
        "notes": [
          "Combos include visually hidden text with one spoken label (“Command K”); the glyph keys are aria-hidden.",
          "Platform detection runs after hydration, so server HTML shows Ctrl and updates to ⌘ on Apple devices."
        ],
        "reducedMotion": "Pressed state does not translate."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "kbd-shortcut",
        "tooltip",
        "command-palette"
      ],
      "tags": [
        "keyboard",
        "key",
        "shortcut",
        "kbd",
        "hotkey"
      ],
      "examples": [
        {
          "name": "KbdMinimal",
          "title": "Single key",
          "kind": "minimal",
          "code": "import { Kbd } from \"@kura-ui/react/kbd\";\n\nexport function KbdMinimal() {\n  return <Kbd>Esc</Kbd>;\n}\n"
        },
        {
          "name": "KbdFull",
          "title": "Combos, variants, sizes",
          "kind": "full",
          "code": "import { Kbd } from \"@kura-ui/react/kbd\";\n\nexport function KbdFull() {\n  return (\n    <div className=\"flex flex-col items-center gap-4 text-kura-fg-muted text-sm\">\n      <p className=\"flex items-center gap-2\">\n        Search with <Kbd keys={[\"mod\", \"k\"]} /> or <Kbd>/</Kbd>\n      </p>\n      <div className=\"flex items-center gap-3\">\n        <Kbd keys={[\"mod\", \"shift\", \"p\"]} size=\"sm\" />\n        <Kbd keys={[\"alt\", \"enter\"]} variant=\"ghost\" />\n        <Kbd keys={[\"up\"]} pressed />\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "KbdShortcutSheet",
          "title": "Keyboard shortcut sheet",
          "description": "A grouped help sheet of combos and alternatives with an IconButton close bound to Esc.",
          "kind": "composition",
          "code": "import { X } from \"lucide-react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { Kbd } from \"@kura-ui/react/kbd\";\n\nconst shortcutGroups: Array<{\n  title: string;\n  items: Array<{ label: string; keys: string[]; alt?: string[] }>;\n}> = [\n  {\n    title: \"General\",\n    items: [\n      { label: \"Command palette\", keys: [\"mod\", \"k\"] },\n      { label: \"New chat\", keys: [\"mod\", \"shift\", \"o\"] },\n      { label: \"Toggle sidebar\", keys: [\"mod\", \"b\"] },\n      { label: \"Search chats\", keys: [\"/\"] },\n    ],\n  },\n  {\n    title: \"Composer\",\n    items: [\n      { label: \"Send message\", keys: [\"enter\"] },\n      { label: \"New line\", keys: [\"shift\", \"enter\"] },\n      { label: \"Stop generating\", keys: [\"esc\"] },\n      { label: \"Edit last message\", keys: [\"up\"] },\n    ],\n  },\n  {\n    title: \"Conversation\",\n    items: [\n      { label: \"Copy last reply\", keys: [\"mod\", \"shift\", \"c\"] },\n      { label: \"Regenerate\", keys: [\"mod\", \"r\"] },\n      { label: \"Previous / next reply\", keys: [\"alt\", \"up\"], alt: [\"alt\", \"down\"] },\n      { label: \"Jump to latest\", keys: [\"mod\", \"down\"] },\n    ],\n  },\n  {\n    title: \"Voice & files\",\n    items: [\n      { label: \"Dictate\", keys: [\"mod\", \"shift\", \"d\"] },\n      { label: \"Push to talk\", keys: [\"alt\", \"space\"] },\n      { label: \"Attach a file\", keys: [\"mod\", \"u\"] },\n      { label: \"Switch model\", keys: [\"mod\", \"shift\", \"m\"] },\n    ],\n  },\n];\n\nexport function KbdShortcutSheet() {\n  return (\n    <section\n      aria-labelledby=\"shortcut-sheet-title\"\n      className=\"w-full max-w-2xl rounded-kura-xl bg-kura-surface shadow-kura-md\"\n    >\n      <header className=\"flex items-center justify-between gap-4 border-kura-border-subtle border-b px-5 py-4\">\n        <div className=\"flex flex-col gap-0.5\">\n          <h3 id=\"shortcut-sheet-title\" className=\"font-medium text-[15px] text-kura-fg\">\n            Keyboard shortcuts\n          </h3>\n          <p className=\"text-kura-fg-muted text-[13px]\">\n            Press <Kbd keys={[\"mod\", \"/\"]} size=\"sm\" /> any time to open this sheet.\n          </p>\n        </div>\n        <IconButton label=\"Close\" icon={<X />} size=\"sm\" shortcut={[\"esc\"]} tooltipSide=\"left\" />\n      </header>\n      <div className=\"grid gap-x-8 gap-y-5 px-5 py-4 sm:grid-cols-2\">\n        {shortcutGroups.map((group) => (\n          <div key={group.title} className=\"flex flex-col gap-1\">\n            <h4 className=\"pb-1 font-medium text-kura-fg-subtle text-xs\">{group.title}</h4>\n            <dl className=\"flex flex-col\">\n              {group.items.map((item) => (\n                <div\n                  key={item.label}\n                  className=\"flex h-8 items-center justify-between gap-4 text-[13px]\"\n                >\n                  <dt className=\"truncate text-kura-fg-muted\">{item.label}</dt>\n                  <dd className=\"flex shrink-0 items-center gap-1.5\">\n                    <Kbd keys={item.keys} size=\"sm\" />\n                    {item.alt && (\n                      <>\n                        <span className=\"text-kura-fg-subtle text-xs\">/</span>\n                        <Kbd keys={item.alt} size=\"sm\" />\n                      </>\n                    )}\n                  </dd>\n                </div>\n              ))}\n            </dl>\n          </div>\n        ))}\n      </div>\n    </section>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Use `mod` for the primary modifier; never hard-code ⌘ or Ctrl glyphs."
      ],
      "title": "Kbd",
      "importPath": "@kura-ui/react/kbd",
      "exports": [
        "Kbd",
        "KbdProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/kbd",
      "markdownUrl": "https://kuro.abstergo.fyi/components/kbd.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/kbd.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/kbd.json",
        "cli": "npx @kura-ui/cli add kbd",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-keys",
        "use-platform",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "keys": {
            "description": "Combo to render, e.g. [\"mod\", \"k\"]. When omitted, children render as one key.",
            "x-ts-type": "readonly KeyName[]",
            "type": "array"
          },
          "variant": {
            "description": "raised = standalone keycap; ghost = inline inside buttons, tooltips and menus.",
            "x-ts-type": "\"raised\" | \"ghost\"",
            "type": "string",
            "enum": [
              "ghost",
              "raised"
            ],
            "default": "raised"
          },
          "size": {
            "description": "18px or 22px tall.",
            "x-ts-type": "\"sm\" | \"md\"",
            "type": "string",
            "enum": [
              "md",
              "sm"
            ],
            "default": "md"
          },
          "pressed": {
            "description": "Render the pressed state.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/kbd/index.ts",
          "type": "registry:ui",
          "content": "export { Kbd, type KbdProps } from \"./kbd\";\n"
        },
        {
          "target": "components/kura/kbd/kbd.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport { useIsApplePlatform } from \"@/hooks/kura/use-platform\";\nimport { cn } from \"@/lib/utils\";\nimport { formatKey, type KeyName, keyLabel } from \"@/lib/kura/keys\";\n\nexport interface KbdProps extends React.ComponentProps<\"kbd\"> {\n  /**\n   * Keys to render as a combo, e.g. `[\"mod\", \"k\"]`. `mod` renders ⌘ on Apple platforms and Ctrl\n   * elsewhere. When omitted, `children` is rendered as a single key.\n   */\n  keys?: readonly KeyName[];\n  /** `raised` = standalone keycap; `ghost` = inline inside buttons, tooltips and menus. */\n  variant?: \"raised\" | \"ghost\";\n  size?: \"sm\" | \"md\";\n  /** Render as pressed (used by KbdShortcut while the combo is held). */\n  pressed?: boolean;\n}\n\nconst keyBase =\n  \"inline-flex min-w-[1.6em] items-center justify-center font-kura-sans font-medium tabular-nums leading-none \" +\n  \"transition-[transform,background-color,box-shadow,color] duration-(--kura-duration-instant) ease-kura-out\";\n\nconst keyVariants = {\n  raised:\n    \"rounded-kura-xs bg-kura-surface text-kura-fg-muted shadow-[0_0_0_1px_var(--kura-border),0_1px_0_0_var(--kura-border-strong)] \" +\n    \"data-[pressed=true]:translate-y-px data-[pressed=true]:bg-kura-surface-sunken data-[pressed=true]:text-kura-fg data-[pressed=true]:shadow-[0_0_0_1px_var(--kura-border)] motion-reduce:data-[pressed=true]:translate-y-0\",\n  ghost:\n    \"rounded-kura-xs bg-kura-hover text-current opacity-80 data-[pressed=true]:bg-kura-active data-[pressed=true]:opacity-100\",\n} as const;\n\nconst keySizes = {\n  sm: \"h-[18px] px-1 text-[11px]\",\n  md: \"h-[22px] px-1.5 text-xs\",\n} as const;\n\n/**\n * A keyboard key or key combination. Platform-aware (⌘ on Apple, Ctrl elsewhere) and\n * hydration-safe. Combos read as one spoken label like \"Command K\" (visually hidden text; glyphs are aria-hidden).\n */\nexport function Kbd({\n  keys,\n  variant = \"raised\",\n  size = \"md\",\n  pressed = false,\n  className,\n  children,\n  ...props\n}: KbdProps) {\n  const apple = useIsApplePlatform();\n\n  if (!keys) {\n    return (\n      <kbd\n        data-slot=\"kbd\"\n        data-pressed={pressed}\n        className={cn(keyBase, keyVariants[variant], keySizes[size], className)}\n        {...props}\n      >\n        {children}\n      </kbd>\n    );\n  }\n\n  const label = keys.map((k) => keyLabel(k, apple)).join(\" \");\n  return (\n    <kbd\n      data-slot=\"kbd\"\n      data-pressed={pressed}\n      className={cn(\"inline-flex items-center gap-0.5 font-kura-sans\", className)}\n      {...props}\n    >\n      <span className=\"sr-only\">{label}</span>\n      {keys.map((k, i) => (\n        <kbd\n          key={`${k}-${i}`}\n          aria-hidden\n          data-slot=\"kbd-key\"\n          data-pressed={pressed}\n          className={cn(keyBase, keyVariants[variant], keySizes[size])}\n        >\n          {formatKey(k, apple)}\n        </kbd>\n      ))}\n    </kbd>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "kbd-shortcut",
      "name": "KbdShortcut",
      "category": "input",
      "status": "beta",
      "description": "Labelled keyboard-shortcut hint (“Send ⌘↵”) that can also bind the shortcut and light its keys while pressed.",
      "whenToUse": [
        "Hints under or inside a prompt composer: “⌘↵ to send”, “Esc to stop”.",
        "Advertise and bind a global shortcut in one element (e.g. ⌘K for search, ⌘J to toggle a panel).",
        "Empty states and onboarding that teach shortcuts."
      ],
      "whenNotToUse": [
        "Showing a single key or combo without a label or binding — use kbd.",
        "A shortcut inside a tooltip — pass `shortcut` to tooltip or icon-button instead.",
        "Binding a shortcut with no visible hint — call the useHotkey hook directly."
      ],
      "props": [
        {
          "name": "keys",
          "type": "readonly KeyName[]",
          "required": true,
          "description": "The combo, e.g. [\"mod\", \"enter\"]. `mod` renders ⌘ on Apple platforms and Ctrl elsewhere."
        },
        {
          "name": "label",
          "type": "React.ReactNode",
          "description": "Short action label shown before the keys, e.g. \"Send\"."
        },
        {
          "name": "onTrigger",
          "type": "(event: KeyboardEvent) => void",
          "description": "Bind the combo on `document` and call this when pressed. Omit for a display-only hint."
        },
        {
          "name": "enabled",
          "type": "boolean",
          "default": "true",
          "description": "Enable/disable the binding without hiding the hint (renders dimmed when false)."
        },
        {
          "name": "allowInInputs",
          "type": "boolean",
          "description": "Fire while typing in inputs. Defaults to true only when the combo contains a modifier."
        },
        {
          "name": "variant",
          "type": "\"raised\" | \"ghost\"",
          "default": "\"raised\"",
          "description": "Keycap style. Use ghost inside buttons and dense toolbars."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "default": "\"sm\"",
          "description": "Keycap size."
        }
      ],
      "parts": [
        {
          "name": "KbdShortcut",
          "description": "Wrapper; exposes `data-pressed` and `data-bound`.",
          "dataSlot": "kbd-shortcut"
        },
        {
          "name": "Label",
          "description": "Optional action label.",
          "dataSlot": "kbd-shortcut-label"
        },
        {
          "name": "Kbd",
          "description": "The combo, rendered with the kbd primitive.",
          "dataSlot": "kbd"
        }
      ],
      "a11y": {
        "notes": [
          "Renders a semantic <kbd> combo with visually hidden spoken text (e.g. “Command Enter”); individual glyphs are aria-hidden.",
          "The binding is global (document keydown); without a modifier it is ignored while the user types in inputs.",
          "It is a hint, not a control: the action must also be reachable by a focusable button."
        ],
        "keyboard": [
          {
            "keys": "The configured combo",
            "action": "Calls onTrigger and shows the keys pressed"
          }
        ],
        "reducedMotion": "The 1px key press travel is removed; colour change remains."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "kbd"
        ]
      },
      "related": [
        "kbd",
        "tooltip",
        "icon-button",
        "composer",
        "command-palette"
      ],
      "tags": [
        "keyboard",
        "shortcut",
        "hotkey",
        "hint",
        "kbd",
        "composer",
        "accelerator"
      ],
      "examples": [
        {
          "name": "KbdShortcutMinimal",
          "title": "Display-only hint",
          "kind": "minimal",
          "code": "import { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\n\nexport function KbdShortcutMinimal() {\n  return <KbdShortcut keys={[\"mod\", \"enter\"]} label=\"Send\" />;\n}\n"
        },
        {
          "name": "KbdShortcutFull",
          "title": "Bound shortcut",
          "description": "Press the combo: the keys light up and a counter increments.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\n\nexport function KbdShortcutFull() {\n  const [count, setCount] = React.useState(0);\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <KbdShortcut\n        keys={[\"mod\", \"j\"]}\n        label=\"Toggle panel\"\n        size=\"md\"\n        variant=\"raised\"\n        allowInInputs\n        enabled\n        onTrigger={() => setCount((c) => c + 1)}\n      />\n      <p className=\"text-kura-fg-muted text-xs tabular-nums\" aria-live=\"polite\">\n        Triggered {count} {count === 1 ? \"time\" : \"times\"}\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "KbdShortcutComposerHints",
          "title": "Composer footer hints",
          "description": "A row of hints as used under a prompt composer.",
          "kind": "composition",
          "code": "import { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\n\nexport function KbdShortcutComposerHints() {\n  return (\n    <div className=\"flex w-full max-w-md items-center justify-between rounded-kura-lg bg-kura-surface px-3 py-2 shadow-kura-sm\">\n      <div className=\"flex items-center gap-3\">\n        <KbdShortcut keys={[\"/\"]} label=\"Commands\" variant=\"ghost\" />\n        <KbdShortcut keys={[\"@\"]} label=\"Context\" variant=\"ghost\" />\n      </div>\n      <KbdShortcut keys={[\"mod\", \"enter\"]} label=\"Send\" />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Use `mod` (not `meta`/`ctrl`) for the primary modifier so it is correct on every platform.",
        "Only one mounted KbdShortcut should bind a given combo; render the others display-only (no onTrigger).",
        "Key names follow KeyboardEvent.key plus aliases: mod, cmd, ctrl, alt/option, shift, enter, esc, up/down/left/right, space."
      ],
      "title": "Kbd Shortcut",
      "importPath": "@kura-ui/react/kbd-shortcut",
      "exports": [
        "KbdShortcut",
        "KbdShortcutProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/kbd-shortcut",
      "markdownUrl": "https://kuro.abstergo.fyi/components/kbd-shortcut.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/kbd-shortcut.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/kbd-shortcut.json",
        "cli": "npx @kura-ui/cli add kbd-shortcut",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kbd",
        "kura-keys",
        "use-hotkey",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "keys": {
            "description": "The combo, e.g. [\"mod\", \"enter\"]. `mod` renders ⌘ on Apple platforms and Ctrl elsewhere.",
            "x-ts-type": "readonly KeyName[]",
            "type": "array"
          },
          "label": {
            "description": "Short action label shown before the keys, e.g. \"Send\".",
            "x-ts-type": "React.ReactNode"
          },
          "onTrigger": {
            "description": "Bind the combo on `document` and call this when pressed. Omit for a display-only hint.",
            "x-ts-type": "(event: KeyboardEvent) => void"
          },
          "enabled": {
            "description": "Enable/disable the binding without hiding the hint (renders dimmed when false).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "allowInInputs": {
            "description": "Fire while typing in inputs. Defaults to true only when the combo contains a modifier.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "variant": {
            "description": "Keycap style. Use ghost inside buttons and dense toolbars.",
            "x-ts-type": "\"raised\" | \"ghost\"",
            "type": "string",
            "enum": [
              "ghost",
              "raised"
            ],
            "default": "raised"
          },
          "size": {
            "description": "Keycap size.",
            "x-ts-type": "\"sm\" | \"md\"",
            "type": "string",
            "enum": [
              "md",
              "sm"
            ],
            "default": "sm"
          }
        },
        "required": [
          "keys"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/kbd-shortcut/index.ts",
          "type": "registry:ui",
          "content": "export { KbdShortcut, type KbdShortcutProps } from \"./kbd-shortcut\";\n"
        },
        {
          "target": "components/kura/kbd-shortcut/kbd-shortcut.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Kbd } from \"@/components/kura/kbd\";\n\nexport interface KbdShortcutProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** The combo, e.g. `[\"mod\", \"enter\"]`. `mod` = ⌘ on Apple, Ctrl elsewhere. */\n  keys: readonly KeyName[];\n  /** Short action label shown before the keys, e.g. \"Send\". Omit for keys only. */\n  label?: React.ReactNode;\n  /**\n   * Bind the combo globally and call this when pressed. Omit for a display-only hint.\n   * While the combo is held, the keys render pressed.\n   */\n  onTrigger?: (event: KeyboardEvent) => void;\n  /** Enable/disable the binding without hiding the hint. Default true. */\n  enabled?: boolean;\n  /** Fire while typing in inputs. Default: true when the combo has a modifier. */\n  allowInInputs?: boolean;\n  variant?: \"raised\" | \"ghost\";\n  size?: \"sm\" | \"md\";\n}\n\n/**\n * A labelled shortcut hint (\"Send ⌘↵\") that can also bind the shortcut and light up its keys\n * when pressed. Use it for the hints under composers, in empty states and in menus.\n */\nexport function KbdShortcut({\n  keys,\n  label,\n  onTrigger,\n  enabled = true,\n  allowInInputs,\n  variant = \"raised\",\n  size = \"sm\",\n  className,\n  ...props\n}: KbdShortcutProps) {\n  const [pressed, setPressed] = React.useState(false);\n  const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useHotkey(\n    onTrigger ? keys : null,\n    (event) => {\n      setPressed(true);\n      if (timeout.current) clearTimeout(timeout.current);\n      // Fallback release in case keyup is swallowed (e.g. focus moved).\n      timeout.current = setTimeout(() => setPressed(false), 400);\n      onTrigger?.(event);\n    },\n    {\n      enabled,\n      allowInInputs,\n      onKeyUp: () => {\n        if (timeout.current) clearTimeout(timeout.current);\n        setPressed(false);\n      },\n    },\n  );\n\n  React.useEffect(\n    () => () => {\n      if (timeout.current) clearTimeout(timeout.current);\n    },\n    [],\n  );\n\n  return (\n    <span\n      data-slot=\"kbd-shortcut\"\n      data-pressed={pressed}\n      data-bound={!!onTrigger && enabled}\n      className={cn(\n        \"group inline-flex items-center gap-1.5 font-kura-sans text-kura-fg-subtle text-xs\",\n        !enabled && \"opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      {label != null && (\n        <span\n          data-slot=\"kbd-shortcut-label\"\n          className=\"transition-colors duration-(--kura-duration-fast) group-data-[pressed=true]:text-kura-fg\"\n        >\n          {label}\n        </span>\n      )}\n      <Kbd keys={keys} variant={variant} size={size} pressed={pressed} />\n    </span>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "mesh-gradient",
      "name": "MeshGradient",
      "category": "effects",
      "status": "beta",
      "description": "Animated WebGL mesh gradient that blends up to six colours in OKLab, with distortion, swirl, grain and a pointer lens — for heroes and empty states.",
      "whenToUse": [
        "A hero, landing or onboarding section that needs a calm, living background behind a headline.",
        "An empty state or a 'start a conversation' panel that should feel warm instead of blank.",
        "A card or banner announcing an AI feature, using the theme-aware `signal` preset.",
        "Any container where content should sit on a soft, slowly moving colour field (pass children)."
      ],
      "whenNotToUse": [
        "Northern-lights curtains or a night-sky feel — use aurora.",
        "Showing that an agent is listening or speaking — use agent-orb.",
        "Signalling that a container is busy — use border-beam (it outlines instead of filling).",
        "Small inline indicators — use thinking-indicator or shimmer-text.",
        "Just adding texture to an existing background — use grain.",
        "A fully custom GLSL effect — build it on shader-canvas."
      ],
      "props": [
        {
          "name": "colors",
          "type": "\"signal\" | \"paper\" | \"jade\" | \"dusk\" | \"ember\" | \"glacier\" | readonly string[]",
          "default": "\"signal\"",
          "description": "A preset, or 2–6 CSS colours (OKLCH, hex, rgb, `var(--kura-*)`). `signal` and `paper` are built from tokens and follow light/dark; the others are fixed palettes."
        },
        {
          "name": "distortion",
          "type": "number",
          "default": "0.5",
          "description": "Organic noise warping of the colour field, 0–1."
        },
        {
          "name": "swirl",
          "type": "number",
          "default": "0.25",
          "description": "Twist around the centre, 0–1. Keep ≤ 0.5 behind text."
        },
        {
          "name": "speed",
          "type": "number",
          "default": "1",
          "description": "Animation speed multiplier. 0 renders a still frame."
        },
        {
          "name": "grain",
          "type": "number",
          "default": "0.12",
          "description": "Film-grain amount, 0–1. A little grain hides banding on large surfaces."
        },
        {
          "name": "interactive",
          "type": "boolean",
          "default": "true",
          "description": "Gently lens the colour field toward the pointer while it is over the element."
        },
        {
          "name": "paused",
          "type": "boolean",
          "default": "false",
          "description": "Freeze the animation on the current frame."
        },
        {
          "name": "seed",
          "type": "number",
          "default": "0",
          "description": "Changes where the colour wells start, so two gradients on a page differ."
        },
        {
          "name": "maxDpr",
          "type": "number",
          "default": "1.5",
          "description": "Device-pixel-ratio cap. The field is soft, so 1–1.5 keeps it cheap."
        },
        {
          "name": "children",
          "type": "React.ReactNode",
          "description": "Content rendered on top of the gradient (it sits in normal flow)."
        }
      ],
      "parts": [
        {
          "name": "MeshGradient",
          "description": "Root container (`relative isolate overflow-hidden`). Size it with classes. `data-state` = animating | still.",
          "dataSlot": "mesh-gradient"
        },
        {
          "name": "ShaderCanvas",
          "description": "The WebGL layer behind the children (aria-hidden).",
          "dataSlot": "mesh-gradient-canvas"
        },
        {
          "name": "Fallback",
          "description": "Layered CSS radial gradients shown when WebGL2 is unavailable.",
          "dataSlot": "mesh-gradient-fallback"
        }
      ],
      "a11y": {
        "notes": [
          "The gradient is decorative and aria-hidden; children keep their normal semantics.",
          "Text on top needs contrast: use `signal`/`paper` with text-kura-fg, or a dark preset with light text, and check both themes.",
          "The pointer lens is purely visual; nothing is interactive about the gradient itself."
        ],
        "keyboard": [],
        "reducedMotion": "Renders a single still frame (no flow, no pointer lens). Theme changes still redraw it."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "shader-canvas"
        ]
      },
      "related": [
        "aurora",
        "grain",
        "shader-canvas",
        "agent-orb",
        "particle-field"
      ],
      "tags": [
        "gradient",
        "mesh",
        "webgl",
        "shader",
        "background",
        "hero",
        "oklab",
        "ambient"
      ],
      "examples": [
        {
          "name": "MeshGradientMinimal",
          "title": "Theme-aware signal gradient",
          "description": "Give it a size; the default `signal` preset follows light and dark.",
          "kind": "minimal",
          "code": "import { MeshGradient } from \"@kura-ui/react/mesh-gradient\";\n\nexport function MeshGradientMinimal() {\n  return <MeshGradient className=\"h-64 w-full rounded-kura-xl shadow-kura-sm\" />;\n}\n"
        },
        {
          "name": "MeshGradientFull",
          "title": "Presets and controls",
          "description": "Every preset plus distortion, swirl, speed, grain and pause.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { MeshGradient, type MeshGradientPreset } from \"@kura-ui/react/mesh-gradient\";\n\nconst presets: MeshGradientPreset[] = [\"signal\", \"paper\", \"jade\", \"dusk\", \"ember\", \"glacier\"];\n\nfunction Slider({\n  label,\n  value,\n  onChange,\n  max = 1,\n  step = 0.05,\n}: {\n  label: string;\n  value: number;\n  onChange: (v: number) => void;\n  max?: number;\n  step?: number;\n}) {\n  return (\n    <label className=\"flex flex-col gap-1.5 text-kura-fg-muted text-xs\">\n      <span className=\"flex justify-between\">\n        {label}\n        <span className=\"font-kura-mono text-kura-fg-subtle tabular-nums\">{value.toFixed(2)}</span>\n      </span>\n      <input\n        type=\"range\"\n        min={0}\n        max={max}\n        step={step}\n        value={value}\n        onChange={(e) => onChange(Number(e.target.value))}\n        className=\"accent-kura-accent\"\n      />\n    </label>\n  );\n}\n\nexport function MeshGradientFull() {\n  const [preset, setPreset] = React.useState<MeshGradientPreset>(\"jade\");\n  const [distortion, setDistortion] = React.useState(0.6);\n  const [swirl, setSwirl] = React.useState(0.35);\n  const [speed, setSpeed] = React.useState(1);\n  const [grain, setGrain] = React.useState(0.15);\n  const [paused, setPaused] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-2xl flex-col gap-4\">\n      <MeshGradient\n        colors={preset}\n        distortion={distortion}\n        swirl={swirl}\n        speed={speed}\n        grain={grain}\n        paused={paused}\n        seed={2}\n        className=\"aspect-[16/9] w-full rounded-kura-xl shadow-kura-md\"\n      />\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        {presets.map((p) => (\n          <Button\n            key={p}\n            size=\"sm\"\n            variant={p === preset ? \"secondary\" : \"ghost\"}\n            aria-pressed={p === preset}\n            onClick={() => setPreset(p)}\n            className=\"capitalize\"\n          >\n            {p}\n          </Button>\n        ))}\n        <Button size=\"sm\" variant=\"ghost\" className=\"ml-auto\" onClick={() => setPaused((v) => !v)}>\n          {paused ? \"Play\" : \"Pause\"}\n        </Button>\n      </div>\n      <div className=\"grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4\">\n        <Slider label=\"Distortion\" value={distortion} onChange={setDistortion} />\n        <Slider label=\"Swirl\" value={swirl} onChange={setSwirl} />\n        <Slider label=\"Speed\" value={speed} onChange={setSpeed} max={3} step={0.1} />\n        <Slider label=\"Grain\" value={grain} onChange={setGrain} />\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "MeshGradientHero",
          "title": "Hero section",
          "description": "Headline, badge and Button rendered as children on top of the gradient.",
          "kind": "composition",
          "code": "import { ArrowRight } from \"lucide-react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { MeshGradient } from \"@kura-ui/react/mesh-gradient\";\n\nexport function MeshGradientHero() {\n  return (\n    <MeshGradient\n      colors=\"signal\"\n      distortion={0.7}\n      swirl={0.3}\n      className=\"flex min-h-80 w-full flex-col items-start justify-end gap-4 rounded-kura-2xl p-8 shadow-kura-md\"\n    >\n      <span className=\"rounded-full bg-kura-overlay px-2.5 py-1 font-medium text-kura-accent-text text-xs shadow-kura-xs backdrop-blur-xl\">\n        Agents · Beta\n      </span>\n      <h2 className=\"max-w-md text-balance font-medium text-3xl text-kura-fg tracking-tight\">\n        Hand off the busywork. Keep the judgement.\n      </h2>\n      <p className=\"max-w-sm text-kura-fg-muted text-sm leading-relaxed\">\n        Kura agents read your repo, plan the change and wait for your approval before touching\n        anything.\n      </p>\n      <Button>\n        Start a run <ArrowRight />\n      </Button>\n    </MeshGradient>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Always give the root a size (e.g. `className=\"h-64 w-full rounded-kura-xl\"` or `min-h-80`); it has no intrinsic height without children.",
        "Children render in normal flow on top — use the root as the section itself (add padding/flex), not as an absolutely positioned layer.",
        "Custom colours are resolved once per theme change; CSS variables work (`['var(--kura-accent)', 'oklch(0.9 0.04 90)']`).",
        "Pair with `<Grain />` as the last child for a print-like finish on large heroes.",
        "Use at most one or two animated gradients per page; give each a different `seed`."
      ],
      "title": "Mesh Gradient",
      "importPath": "@kura-ui/react/mesh-gradient",
      "exports": [
        "MeshGradient",
        "MeshGradientProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/mesh-gradient",
      "markdownUrl": "https://kuro.abstergo.fyi/components/mesh-gradient.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/mesh-gradient.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/mesh-gradient.json",
        "cli": "npx @kura-ui/cli add mesh-gradient",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "kura-shader-color",
        "shader-canvas",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "colors": {
            "description": "A preset, or 2–6 CSS colours (OKLCH, hex, rgb, `var(--kura-*)`). `signal` and `paper` are built from tokens and follow light/dark; the others are fixed palettes.",
            "x-ts-type": "\"signal\" | \"paper\" | \"jade\" | \"dusk\" | \"ember\" | \"glacier\" | readonly string[]",
            "type": "array",
            "default": "signal"
          },
          "distortion": {
            "description": "Organic noise warping of the colour field, 0–1.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.5
          },
          "swirl": {
            "description": "Twist around the centre, 0–1. Keep ≤ 0.5 behind text.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.25
          },
          "speed": {
            "description": "Animation speed multiplier. 0 renders a still frame.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "grain": {
            "description": "Film-grain amount, 0–1. A little grain hides banding on large surfaces.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.12
          },
          "interactive": {
            "description": "Gently lens the colour field toward the pointer while it is over the element.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "paused": {
            "description": "Freeze the animation on the current frame.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "seed": {
            "description": "Changes where the colour wells start, so two gradients on a page differ.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0
          },
          "maxDpr": {
            "description": "Device-pixel-ratio cap. The field is soft, so 1–1.5 keeps it cheap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1.5
          },
          "children": {
            "description": "Content rendered on top of the gradient (it sits in normal flow).",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/mesh-gradient/index.ts",
          "type": "registry:ui",
          "content": "export { MeshGradient, type MeshGradientProps } from \"./mesh-gradient\";\n"
        },
        {
          "target": "components/kura/mesh-gradient/mesh-gradient.glsl.ts",
          "type": "registry:ui",
          "content": "import { GLSL_COLOR } from \"@/lib/kura/shader-color\";\n\n/** Max colours the shader blends. */\nexport const MAX_COLORS = 6;\n\n/**\n * Mesh gradient: up to six colour \"wells\" drift on slow Lissajous paths; every pixel blends them\n * in OKLab with inverse-distance weights (smooth, even, no muddy midpoints). The sample position\n * is domain-warped by simplex noise (distortion), twisted around the centre (swirl) and lensed\n * toward the pointer. Output is converted to sRGB once, with film grain and IGN dithering.\n */\nexport const MESH_GRADIENT_FRAG = /* glsl */ `\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\nuniform vec3 u_c3;\nuniform vec3 u_c4;\nuniform vec3 u_c5;\nuniform float u_count;\nuniform float u_distortion;\nuniform float u_swirl;\nuniform float u_grain;\nuniform float u_seed;\nuniform vec3 u_mouse; // xy in 0–1 (y up), z = influence 0–1\n\n${GLSL_COLOR}\n\nvec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }\nfloat snoise(vec2 v) {\n  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);\n  vec2 i = floor(v + dot(v, C.yy));\n  vec2 x0 = v - i + dot(i, C.xx);\n  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n  vec4 x12 = x0.xyxy + C.xxzz;\n  x12.xy -= i1;\n  i = mod(i, 289.0);\n  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);\n  m = m * m; m = m * m;\n  vec3 x = 2.0 * fract(p * C.www) - 1.0;\n  vec3 h = abs(x) - 0.5;\n  vec3 ox = floor(x + 0.5);\n  vec3 a0 = x - ox;\n  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n  vec3 g;\n  g.x = a0.x * x0.x + h.x * x0.y;\n  g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n  return 130.0 * dot(m, g);\n}\n\nfloat hash12(vec2 p) {\n  vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n  p3 += dot(p3, p3.yzx + 33.33);\n  return fract((p3.x + p3.y) * p3.z);\n}\n\nvec3 colorAt(int i) {\n  if (i == 0) return u_c0;\n  if (i == 1) return u_c1;\n  if (i == 2) return u_c2;\n  if (i == 3) return u_c3;\n  if (i == 4) return u_c4;\n  return u_c5;\n}\n\n// Each well orbits its own anchor on a slow, irrational Lissajous path.\nvec2 wellAt(float i, float t, float aspect) {\n  float a = i * 2.39996323 + u_seed * 1.7;\n  float ring = 0.18 + 0.2 * fract(i * 0.618034 + u_seed * 0.13);\n  vec2 anchor = vec2(0.5 * aspect, 0.5) + ring * vec2(cos(a) * aspect, sin(a)) * 1.35;\n  vec2 drift = vec2(\n    sin(t * (0.21 + 0.043 * i) + a * 1.7) * aspect,\n    cos(t * (0.17 + 0.037 * i) + a * 2.3));\n  return anchor + 0.2 * drift;\n}\n\nvoid main() {\n  float aspect = u_resolution.x / u_resolution.y;\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  vec2 p = vec2(uv.x * aspect, uv.y);\n  vec2 center = vec2(0.5 * aspect, 0.5);\n  float t = u_time * 0.35 + u_seed * 10.0;\n\n  // Pointer: a soft lens that pulls the colour field toward the cursor.\n  vec2 m = vec2(u_mouse.x * aspect, u_mouse.y);\n  vec2 dm = p - m;\n  p -= dm * 0.22 * u_mouse.z * exp(-dot(dm, dm) * 7.0);\n\n  // Distortion: low-frequency drifting simplex noise warps the sample position. A second, finer\n  // octave adds silk-like folds without turning the field into smoke.\n  vec2 q = p * 0.9;\n  vec2 warp = vec2(\n    snoise(q + vec2(t * 0.16, -t * 0.12)),\n    snoise(q + vec2(5.2 - t * 0.13, 1.3 + t * 0.15)));\n  warp += 0.28 * vec2(\n    snoise(q * 1.9 + vec2(-t * 0.21, t * 0.18) + 3.7),\n    snoise(q * 1.9 + vec2(t * 0.19, t * 0.23) + 8.1));\n  p += u_distortion * 0.22 * warp;\n\n  // Swirl: rotate around the centre, strongest near it, breathing slowly.\n  vec2 c = p - center;\n  float r = length(c);\n  float ang = u_swirl * 1.8 * exp(-r * r * 3.0) * (0.8 + 0.2 * sin(t * 0.5));\n  float cs = cos(ang), sn = sin(ang);\n  p = center + mat2(cs, -sn, sn, cs) * c;\n\n  // Inverse-distance blend in OKLab.\n  vec3 acc = vec3(0.0);\n  float wsum = 0.0;\n  for (int i = 0; i < ${MAX_COLORS}; i++) {\n    if (float(i) >= u_count) break;\n    vec2 d = p - wellAt(float(i), t, aspect);\n    float w = 1.0 / pow(dot(d, d) + 0.025, 1.75);\n    acc += colorAt(i) * w;\n    wsum += w;\n  }\n  vec3 lab = acc / wsum;\n\n  vec3 col = linearToSrgb(oklabToLinear(lab));\n\n  // Film grain: luminance-only, refreshed ~24 fps, scaled down in the deepest shadows.\n  float g = hash12(gl_FragCoord.xy + fract(floor(u_time * 24.0) * 0.1371) * 517.0) - 0.5;\n  col += g * u_grain * 0.14 * (0.35 + 0.65 * lab.x);\n\n  fragColor = vec4(dither(col), 1.0);\n}\n`;\n"
        },
        {
          "target": "components/kura/mesh-gradient/mesh-gradient.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\nimport { useResolvedColors } from \"@/lib/kura/shader-color\";\nimport { ShaderCanvas } from \"@/components/kura/shader-canvas\";\nimport { MAX_COLORS, MESH_GRADIENT_FRAG } from \"./mesh-gradient.glsl\";\n\nexport type MeshGradientPreset = \"signal\" | \"paper\" | \"jade\" | \"dusk\" | \"ember\" | \"glacier\";\n\n/**\n * Colour presets. `signal` and `paper` are built from Kura tokens and follow the theme; the rest\n * are fixed OKLCH palettes that look the same in light and dark.\n */\nexport const meshGradientPresets: Record<MeshGradientPreset, readonly string[]> = {\n  signal: [\n    \"var(--kura-canvas)\",\n    \"var(--kura-accent-subtle)\",\n    \"var(--kura-accent)\",\n    \"var(--kura-gray-3)\",\n    \"var(--kura-accent-subtle)\",\n  ],\n  paper: [\n    \"var(--kura-gray-1)\",\n    \"var(--kura-gray-4)\",\n    \"var(--kura-accent-subtle)\",\n    \"var(--kura-gray-2)\",\n    \"var(--kura-gray-6)\",\n  ],\n  jade: [\n    \"oklch(0.24 0.045 185)\",\n    \"oklch(0.5 0.105 172)\",\n    \"oklch(0.8 0.12 165)\",\n    \"oklch(0.94 0.035 140)\",\n    \"oklch(0.34 0.07 195)\",\n  ],\n  dusk: [\n    \"oklch(0.22 0.045 258)\",\n    \"oklch(0.42 0.085 248)\",\n    \"oklch(0.68 0.09 222)\",\n    \"oklch(0.88 0.06 75)\",\n    \"oklch(0.3 0.06 262)\",\n  ],\n  ember: [\n    \"oklch(0.27 0.055 35)\",\n    \"oklch(0.58 0.15 42)\",\n    \"oklch(0.8 0.13 68)\",\n    \"oklch(0.95 0.035 88)\",\n    \"oklch(0.42 0.11 30)\",\n  ],\n  glacier: [\n    \"oklch(0.975 0.008 220)\",\n    \"oklch(0.87 0.045 212)\",\n    \"oklch(0.7 0.085 205)\",\n    \"oklch(0.52 0.075 218)\",\n    \"oklch(0.93 0.03 175)\",\n  ],\n};\n\nexport interface MeshGradientProps extends React.ComponentProps<\"div\"> {\n  /** A preset name, or 2–6 CSS colours (OKLCH, hex, rgb, `var(--token)`…). Default \"signal\". */\n  colors?: MeshGradientPreset | readonly string[];\n  /** Organic warping of the colour field, 0–1. Default 0.5. */\n  distortion?: number;\n  /** Twist around the centre, 0–1. Default 0.25. */\n  swirl?: number;\n  /** Animation speed multiplier. 0 freezes the flow. Default 1. */\n  speed?: number;\n  /** Film-grain amount, 0–1. Default 0.12. Hides banding on large, low-contrast gradients. */\n  grain?: number;\n  /** Lens the gradient gently toward the pointer. Default true. */\n  interactive?: boolean;\n  /** Freeze the animation on the current frame. Default false. */\n  paused?: boolean;\n  /** Changes the starting layout of the colour wells. Default 0. */\n  seed?: number;\n  /** Device-pixel-ratio cap. Gradients are soft, so 1–1.5 is plenty. Default 1.5. */\n  maxDpr?: number;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nfunction fallbackBackground(colors: readonly string[]): string {\n  const spots = [\"18% 22%\", \"82% 18%\", \"72% 82%\", \"24% 78%\", \"50% 50%\", \"92% 55%\"];\n  const layers = colors\n    .slice(1)\n    .map((c, i) => `radial-gradient(60% 70% at ${spots[i]}, ${c}, transparent 70%)`);\n  return [...layers, colors[0]].join(\", \");\n}\n\n/**\n * Animated WebGL mesh gradient for hero sections, empty states and ambient surfaces. Blends up to\n * six colours in OKLab, with distortion, swirl, grain and a subtle pointer lens. Children render on\n * top. Falls back to layered CSS radial gradients without WebGL, and to a still frame under\n * reduced motion.\n */\nexport function MeshGradient({\n  colors = \"signal\",\n  distortion = 0.5,\n  swirl = 0.25,\n  speed = 1,\n  grain = 0.12,\n  interactive = true,\n  paused = false,\n  seed = 0,\n  maxDpr = 1.5,\n  className,\n  children,\n  ref,\n  ...props\n}: MeshGradientProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const list = React.useMemo(() => {\n    const raw = typeof colors === \"string\" ? meshGradientPresets[colors] : colors;\n    const arr = raw.length ? raw.slice(0, MAX_COLORS) : meshGradientPresets.signal;\n    return arr.length === 1 ? [arr[0]!, arr[0]!] : arr;\n  }, [colors]);\n  const { colors: resolved, version } = useResolvedColors(rootRef, list);\n\n  // Smoothed pointer (xy 0–1, y up; z = influence). Mutated, never React state.\n  const pointer = React.useRef({ x: 0.5, y: 0.5, z: 0, tx: 0.5, ty: 0.5, tz: 0, last: 0 });\n  React.useEffect(() => {\n    if (!interactive) {\n      pointer.current.tz = 0;\n      return;\n    }\n    const onMove = (e: PointerEvent) => {\n      const el = rootRef.current;\n      if (!el) return;\n      const r = el.getBoundingClientRect();\n      const x = (e.clientX - r.left) / Math.max(1, r.width);\n      const y = 1 - (e.clientY - r.top) / Math.max(1, r.height);\n      const p = pointer.current;\n      p.tx = x;\n      p.ty = y;\n      p.tz = x >= 0 && x <= 1 && y >= 0 && y <= 1 ? 1 : 0;\n    };\n    const onLeave = () => {\n      pointer.current.tz = 0;\n    };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n    document.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      document.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [interactive]);\n\n  // `version` bumps when the theme flips: a new uniforms object repaints a still frame.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: version is the repaint trigger\n  const uniforms = React.useMemo(() => {\n    const color = (i: number) => () => {\n      const c = resolved.current;\n      return (c[Math.min(i, c.length - 1)]?.oklab ?? [0.5, 0, 0]) as [number, number, number];\n    };\n    return {\n      u_c0: color(0),\n      u_c1: color(1),\n      u_c2: color(2),\n      u_c3: color(3),\n      u_c4: color(4),\n      u_c5: color(5),\n      u_count: () => resolved.current.length,\n      u_distortion: clamp(distortion, 0, 1),\n      u_swirl: clamp(swirl, 0, 1),\n      u_grain: clamp(grain, 0, 1),\n      u_seed: seed,\n      u_mouse: () => {\n        const p = pointer.current;\n        const now = performance.now();\n        const dt = p.last ? Math.min(0.1, (now - p.last) / 1000) : 1;\n        p.last = now;\n        const k = 1 - Math.exp(-dt * 3.5); // ~300ms ease toward the pointer\n        p.x += (p.tx - p.x) * k;\n        p.y += (p.ty - p.y) * k;\n        p.z += (p.tz - p.z) * (1 - Math.exp(-dt * 2));\n        return [p.x, p.y, p.z] as const;\n      },\n    };\n  }, [resolved, version, distortion, swirl, grain, seed]);\n\n  const isStill = reducedMotion || paused || speed === 0;\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"mesh-gradient\"\n      data-state={isStill ? \"still\" : \"animating\"}\n      className={cn(\"relative isolate overflow-hidden\", className)}\n      {...props}\n    >\n      <ShaderCanvas\n        data-slot=\"mesh-gradient-canvas\"\n        fragmentShader={MESH_GRADIENT_FRAG}\n        uniforms={uniforms}\n        paused={paused || speed === 0}\n        speed={speed}\n        maxDpr={maxDpr}\n        staticTime={4 + seed}\n        className=\"-z-10 absolute inset-0\"\n        fallback={\n          <div\n            aria-hidden\n            data-slot=\"mesh-gradient-fallback\"\n            className=\"absolute inset-0\"\n            style={{ background: fallbackBackground(list) }}\n          />\n        }\n      />\n      {children}\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "message",
      "name": "Message",
      "category": "conversation",
      "status": "beta",
      "description": "Chat message (user/assistant) with avatar, hover actions, ‹ 2/3 › branches, error/streaming states and an autoscrolling MessageList thread.",
      "whenToUse": [
        "Render the turns of a chat with an LLM: user bubbles on the right, assistant answers as open text on the left.",
        "Add per-message actions: copy, regenerate, edit, thumbs up/down and navigation between regenerated versions.",
        "Show a failed generation with an inline error and a Retry button.",
        "Wrap a whole conversation in MessageList for stick-to-bottom autoscroll with a “Jump to latest” pill."
      ],
      "whenNotToUse": [
        "Only the streaming answer text, without chrome — use streaming-text.",
        "A model's hidden chain of thought — put a reasoning block inside the assistant Message.",
        "A tool invocation or an agent plan — use tool-call or agent-timeline inside the assistant Message.",
        "The input box at the bottom of a chat — use composer."
      ],
      "props": [
        {
          "name": "from",
          "type": "\"user\" | \"assistant\"",
          "required": true,
          "description": "Who wrote it. user = right-aligned gray bubble with a tucked bottom-right corner; assistant = full-width open text."
        },
        {
          "name": "name",
          "type": "React.ReactNode",
          "description": "Display name in the header, e.g. \"Kura\" or \"You\"."
        },
        {
          "name": "avatar",
          "type": "React.ReactNode",
          "description": "Avatar element, usually `<MessageAvatar name src? fallback? />`."
        },
        {
          "name": "timestamp",
          "type": "Date | number | string",
          "description": "Sent time. A Date/epoch renders as a localized time (“10:42 AM”) in a <time> element with the full date as title; a string renders as-is."
        },
        {
          "name": "status",
          "type": "\"idle\" | \"streaming\" | \"error\"",
          "default": "\"idle\"",
          "description": "streaming: sets aria-busy and keeps the action row invisible + inert (still laid out — no shift when it appears). error: dims the content and shows `error` with an optional Retry."
        },
        {
          "name": "error",
          "type": "React.ReactNode",
          "default": "\"Something went wrong.\"",
          "description": "Error text for status=\"error\"."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "description": "Adds a Retry button to the error row."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Action bar under the content, usually `<MessageActions>` with MessageCopyButton, MessageAction, MessageFeedback, MessageBranch."
        }
      ],
      "parts": [
        {
          "name": "MessageList",
          "description": "Thread container (`role=\"log\"` viewport). Sticks to the bottom while content grows, releases when the reader scrolls up, shows a “Jump to latest” pill. `data-at-bottom`.",
          "dataSlot": "message-list",
          "props": [
            {
              "name": "stickToBottom",
              "type": "boolean",
              "default": "true",
              "description": "Follow new content while the reader is at the bottom."
            },
            {
              "name": "threshold",
              "type": "number",
              "default": "64",
              "description": "Px from the bottom that still counts as at-bottom."
            },
            {
              "name": "jumpLabel",
              "type": "string",
              "default": "\"Jump to latest\"",
              "description": "Label of the jump pill."
            },
            {
              "name": "contentClassName",
              "type": "string",
              "description": "Classes for the inner column (default `flex flex-col gap-5 px-4 py-6`)."
            }
          ]
        },
        {
          "name": "Message",
          "description": "Root. `data-from`, `data-status`, `aria-busy` while streaming.",
          "dataSlot": "message"
        },
        {
          "name": "Header",
          "description": "Name and time.",
          "dataSlot": "message-header"
        },
        {
          "name": "Body",
          "description": "Content slot: a flex column with gap-3, with markdown-friendly styles for p, ul/ol, a, strong, inline code, blockquote and h1–h3. Drop in rendered markdown, StreamingText or CodeBlocks.",
          "dataSlot": "message-body"
        },
        {
          "name": "Error",
          "description": "role=\"alert\" row for status=\"error\".",
          "dataSlot": "message-error"
        },
        {
          "name": "MessageAvatar",
          "description": "28px round avatar: image → custom fallback → initials.",
          "dataSlot": "message-avatar",
          "props": [
            {
              "name": "name",
              "type": "string",
              "required": true,
              "description": "Alt text and initials source."
            },
            {
              "name": "src",
              "type": "string",
              "description": "Image URL; broken images fall back."
            },
            {
              "name": "fallback",
              "type": "React.ReactNode",
              "description": "Custom fallback such as a logo or lucide icon."
            }
          ]
        },
        {
          "name": "MessageActions",
          "description": "role=\"group\" action bar; fades in on hover/focus-within, always visible on coarse pointers.",
          "dataSlot": "message-actions",
          "props": [
            {
              "name": "visibility",
              "type": "\"hover\" | \"always\"",
              "default": "\"hover\"",
              "description": "Reveal on hover/focus, or always show."
            }
          ]
        },
        {
          "name": "MessageAction",
          "description": "Small labelled IconButton (tooltip included) for regenerate, edit, share…",
          "dataSlot": "message-action"
        },
        {
          "name": "MessageCopyButton",
          "description": "Copy action; the icon cross-fades to a check for 1.6s and a polite status announces it.",
          "props": [
            {
              "name": "text",
              "type": "string",
              "required": true,
              "description": "Text to copy (usually the raw markdown)."
            },
            {
              "name": "onCopied",
              "type": "() => void",
              "description": "Called after a successful copy."
            }
          ]
        },
        {
          "name": "MessageFeedback",
          "description": "Thumbs up/down toggles (aria-pressed); pressing the active one clears it. `data-value`.",
          "dataSlot": "message-feedback",
          "props": [
            {
              "name": "value",
              "type": "\"up\" | \"down\" | null",
              "description": "Controlled rating."
            },
            {
              "name": "defaultValue",
              "type": "\"up\" | \"down\" | null",
              "default": "null",
              "description": "Initial rating when uncontrolled."
            },
            {
              "name": "onValueChange",
              "type": "(value: \"up\" | \"down\" | null) => void",
              "description": "Called with the new rating."
            }
          ]
        },
        {
          "name": "MessageBranch",
          "description": "“‹ 2/3 ›” version switcher for regenerations/edits; renders nothing when total ≤ 1.",
          "dataSlot": "message-branch",
          "props": [
            {
              "name": "current",
              "type": "number",
              "required": true,
              "description": "1-based visible version."
            },
            {
              "name": "total",
              "type": "number",
              "required": true,
              "description": "Number of versions."
            },
            {
              "name": "onCurrentChange",
              "type": "(current: number) => void",
              "description": "Called with the new 1-based index."
            }
          ]
        },
        {
          "name": "Jump",
          "description": "“Jump to latest” pill; `data-state=\"visible\" | \"hidden\"` (inert when hidden).",
          "dataSlot": "message-list-jump"
        }
      ],
      "a11y": {
        "role": "log",
        "notes": [
          "MessageList's viewport is `role=\"log\"` (implicitly polite) labelled “Conversation”, and focusable so the thread scrolls with the keyboard. A streaming Message sets `aria-busy`, so assistive tech announces the finished answer rather than each token.",
          "Every action is a labelled icon button with a tooltip; feedback toggles use aria-pressed; the branch switcher announces “Version 2 of 3”.",
          "Actions are revealed on hover AND keyboard focus (focus-within), and are always visible on touch devices.",
          "The error row is `role=\"alert\"`.",
          "The hidden jump pill is inert and aria-hidden, so it never traps focus."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Moves into a message's actions (revealing the bar) and through them"
          },
          {
            "keys": "↑ / ↓ / Page Up / Page Down",
            "action": "Scroll the focused thread"
          },
          {
            "keys": "Enter / Space",
            "action": "Activate an action; toggle feedback"
          }
        ],
        "reducedMotion": "Jump-to-latest scrolls instantly and its pill only fades (no slide/scale); the copy check and thumbs swap without scale/blur."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button",
          "icon-button"
        ]
      },
      "related": [
        "streaming-text",
        "code-block",
        "reasoning",
        "tool-call",
        "citation",
        "composer"
      ],
      "tags": [
        "chat",
        "message",
        "conversation",
        "thread",
        "bubble",
        "autoscroll",
        "feedback",
        "actions"
      ],
      "examples": [
        {
          "name": "MessageMinimal",
          "title": "Two turns",
          "description": "A user bubble and an assistant answer.",
          "kind": "minimal",
          "code": "import { Message } from \"@kura-ui/react/message\";\n\nexport function MessageMinimal() {\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <Message from=\"user\">How do I make a like button feel instant?</Message>\n      <Message from=\"assistant\">\n        Render the new count optimistically and reconcile when the request finishes.\n      </Message>\n    </div>\n  );\n}\n"
        },
        {
          "name": "MessageFull",
          "title": "Avatars, actions and versions",
          "description": "Name, time, avatar, copy/regenerate/edit, controlled thumbs, ‹ 2/3 › branches. Regenerate simulates an error with Retry.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Pencil, RefreshCw } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  Message,\n  MessageAction,\n  MessageActions,\n  MessageAvatar,\n  MessageBranch,\n  MessageCopyButton,\n  MessageFeedback,\n} from \"@kura-ui/react/message\";\n\nconst VERSIONS = [\n  \"Use `useOptimistic` for the like button: render the optimistic count immediately, then reconcile when the server action resolves.\",\n  \"Wrap the mutation in a transition and render from `useOptimistic`. If the action throws, React drops the optimistic value and the real count comes back on its own.\",\n  \"Keep it simple: update local state on click, call the server action, and roll back in a `catch`. `useOptimistic` saves you that rollback.\",\n];\n\nexport function MessageFull() {\n  const [version, setVersion] = React.useState(2);\n  const [rating, setRating] = React.useState<\"up\" | \"down\" | null>(\"up\");\n  const [failed, setFailed] = React.useState(false);\n  const text = VERSIONS[version - 1]!;\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <Message\n        from=\"user\"\n        name=\"You\"\n        timestamp=\"10:41\"\n        avatar={<MessageAvatar name=\"Ada Lovelace\" />}\n        actions={\n          <MessageActions>\n            <MessageAction label=\"Edit\" icon={<Pencil />} />\n            <MessageCopyButton text=\"Why does my like button lag?\" />\n          </MessageActions>\n        }\n      >\n        Why does my like button lag?\n      </Message>\n      <Message\n        from=\"assistant\"\n        name=\"Kura\"\n        timestamp=\"10:42\"\n        avatar={<MessageAvatar name=\"Kura\" />}\n        status={failed ? \"error\" : \"idle\"}\n        error=\"The model stopped responding. Partial answer shown.\"\n        onRetry={() => setFailed(false)}\n        actions={\n          <MessageActions visibility=\"always\">\n            <MessageCopyButton text={text} />\n            <MessageAction\n              label=\"Regenerate\"\n              icon={<RefreshCw />}\n              onClick={() => setFailed(true)}\n            />\n            <MessageFeedback value={rating} onValueChange={setRating} />\n            <MessageBranch current={version} total={VERSIONS.length} onCurrentChange={setVersion} />\n          </MessageActions>\n        }\n      >\n        <p key={version} className=\"animate-kura-fade-in\">\n          {text.split(\"`\").map((part, i) => (i % 2 ? <code key={i}>{part}</code> : part))}\n        </p>\n      </Message>\n    </div>\n  );\n}\n"
        },
        {
          "name": "MessageStates",
          "title": "Streaming and error",
          "description": "A streaming answer with reserved actions, and a failed one.",
          "kind": "state",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { StreamingText } from \"@kura-ui/react/streaming-text\";\nimport { Message, MessageActions, MessageAvatar, MessageCopyButton } from \"@kura-ui/react/message\";\n\nasync function* tokens(text: string): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; ) {\n    const n = 1 + Math.floor(Math.random() * 3);\n    yield words.slice(i, i + n).join(\"\");\n    i += n;\n    await new Promise((r) => setTimeout(r, 30 + Math.random() * 110));\n  }\n}\n\nexport function MessageStates() {\n  const [stream] = React.useState(() =>\n    tokens(\n      \"Reading the trace now. The slow part is the waterfall: the like request waits on the session refresh, so the button cannot update until both finish.\",\n    ),\n  );\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <Message\n        from=\"assistant\"\n        name=\"Kura\"\n        avatar={<MessageAvatar name=\"Kura\" />}\n        status=\"streaming\"\n        actions={\n          <MessageActions>\n            <MessageCopyButton text=\"\" />\n          </MessageActions>\n        }\n      >\n        <StreamingText stream={stream} />\n      </Message>\n      <Message\n        from=\"assistant\"\n        name=\"Kura\"\n        avatar={<MessageAvatar name=\"Kura\" />}\n        status=\"error\"\n        error=\"Rate limited — try again in a few seconds.\"\n        onRetry={() => {}}\n      >\n        I started profiling the\n      </Message>\n    </div>\n  );\n}\n"
        },
        {
          "name": "MessageThread",
          "title": "Autoscrolling thread",
          "description": "MessageList with StreamingText, CodeBlock and a shimmering “Thinking…” turn. Scroll up mid-stream to see “Jump to latest”.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { ArrowUp, RefreshCw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { CodeBlock } from \"@kura-ui/react/code-block\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\nimport { StreamingText } from \"@kura-ui/react/streaming-text\";\nimport { Message, MessageAction, MessageActions, MessageAvatar, MessageCopyButton, MessageFeedback } from \"@kura-ui/react/message\";\nimport { MessageList } from \"@kura-ui/react/message\";\n\nasync function* tokens(text: string): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; ) {\n    const n = 1 + Math.floor(Math.random() * 3);\n    yield words.slice(i, i + n).join(\"\");\n    i += n;\n    await new Promise((r) => setTimeout(r, 30 + Math.random() * 110));\n  }\n}\n\nconst LONG_ANSWER =\n  \"Three things will make the thread feel calm.\\n\\nFirst, stick to the bottom only while the reader is there. The moment they scroll up, stop following — nothing is worse than being yanked back mid-sentence.\\n\\nSecond, reserve space for things that appear later, like the action bar, so the end of a stream never shifts the layout.\\n\\nThird, announce the finished message, not every token: the log is `aria-busy` while the answer streams.\";\n\nexport function MessageThread() {\n  const [turns, setTurns] = React.useState<Turn[]>([\n    { id: 1, from: \"user\", text: \"Can you review the chat layout?\" },\n    {\n      id: 2,\n      from: \"assistant\",\n      text: \"Sure. The structure is solid; two details stand out. The composer jumps when the keyboard opens on iOS, and new messages push the reader down even when they are reading older ones.\",\n    },\n    { id: 3, from: \"user\", text: \"Show me the fix for the composer.\" },\n    { id: 4, from: \"assistant\", text: \"Pin it with dynamic viewport units:\" },\n  ]);\n  const [streaming, setStreaming] = React.useState<number | null>(null);\n  const [waiting, setWaiting] = React.useState(false);\n\n  const ask = () => {\n    const id = Date.now();\n    setTurns((t) => [...t, { id, from: \"user\", text: \"How should the thread scroll?\" }]);\n    setWaiting(true);\n    setTimeout(() => {\n      setWaiting(false);\n      setStreaming(id + 1);\n      setTurns((t) => [\n        ...t,\n        { id: id + 1, from: \"assistant\", text: \"\", stream: tokens(LONG_ANSWER) },\n      ]);\n    }, 900);\n  };\n\n  return (\n    <div className=\"flex h-[440px] w-full max-w-xl flex-col overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm\">\n      <MessageList className=\"flex-1\" contentClassName=\"px-5\">\n        {turns.map((turn) => (\n          <Message\n            key={turn.id}\n            from={turn.from}\n            status={streaming === turn.id ? \"streaming\" : \"idle\"}\n            avatar={turn.from === \"assistant\" ? <MessageAvatar name=\"Kura\" /> : undefined}\n            actions={\n              turn.from === \"assistant\" ? (\n                <MessageActions>\n                  <MessageCopyButton text={turn.text} />\n                  <MessageAction label=\"Regenerate\" icon={<RefreshCw />} />\n                  <MessageFeedback />\n                </MessageActions>\n              ) : undefined\n            }\n          >\n            {turn.stream ? (\n              <StreamingText stream={turn.stream} onComplete={() => setStreaming(null)} />\n            ) : (\n              turn.text\n            )}\n            {turn.id === 4 && (\n              <CodeBlock\n                language=\"css\"\n                filename=\"composer.css\"\n                code={\".composer {\\n  position: sticky;\\n  bottom: 0;\\n  max-height: 40dvh;\\n}\"}\n              />\n            )}\n          </Message>\n        ))}\n        {waiting && (\n          <Message from=\"assistant\" avatar={<MessageAvatar name=\"Kura\" />} status=\"streaming\">\n            <ShimmerText>Thinking…</ShimmerText>\n          </Message>\n        )}\n      </MessageList>\n      <div className=\"flex items-center justify-between gap-3 px-4 py-3 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\">\n        <span className=\"text-kura-fg-subtle text-xs\">Scroll up while it streams.</span>\n        <Button size=\"sm\" onClick={ask} disabled={waiting || streaming !== null}>\n          Ask a follow-up <ArrowUp />\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Give MessageList a height (h-[480px], flex-1 inside a flex column, …) — it scrolls internally.",
        "Use `from`, not `role`, for the author (role is the ARIA attribute).",
        "Set status=\"streaming\" on the assistant Message while StreamingText is running, then \"idle\" in its onComplete; pass `actions` from the start so the row is reserved.",
        "Message renders children inside a flex column with gap-3 — pass block elements (p, CodeBlock, lists) directly; do not add margins.",
        "For markdown, render it to elements (react-markdown etc.) inside Message; inline code, links, lists and blockquotes are styled by the body.",
        "Keep MessageBranch state (current version) in your store alongside the alternative texts."
      ],
      "title": "Message",
      "importPath": "@kura-ui/react/message",
      "exports": [
        "Message",
        "MessageAction",
        "MessageActionProps",
        "MessageActions",
        "MessageActionsProps",
        "MessageAvatar",
        "MessageAvatarProps",
        "MessageBranch",
        "MessageBranchProps",
        "MessageCopyButton",
        "MessageCopyButtonProps",
        "MessageFeedback",
        "MessageFeedbackProps",
        "MessageList",
        "MessageListProps",
        "MessageProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/message",
      "markdownUrl": "https://kuro.abstergo.fyi/components/message.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/message.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/message.json",
        "cli": "npx @kura-ui/cli add message",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "icon-button",
        "use-controllable-state",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "from": {
            "description": "Who wrote it. user = right-aligned gray bubble with a tucked bottom-right corner; assistant = full-width open text.",
            "x-ts-type": "\"user\" | \"assistant\"",
            "type": "string",
            "enum": [
              "assistant",
              "user"
            ]
          },
          "name": {
            "description": "Display name in the header, e.g. \"Kura\" or \"You\".",
            "x-ts-type": "React.ReactNode"
          },
          "avatar": {
            "description": "Avatar element, usually `<MessageAvatar name src? fallback? />`.",
            "x-ts-type": "React.ReactNode"
          },
          "timestamp": {
            "description": "Sent time. A Date/epoch renders as a localized time (“10:42 AM”) in a <time> element with the full date as title; a string renders as-is.",
            "x-ts-type": "Date | number | string"
          },
          "status": {
            "description": "streaming: sets aria-busy and keeps the action row invisible + inert (still laid out — no shift when it appears). error: dims the content and shows `error` with an optional Retry.",
            "x-ts-type": "\"idle\" | \"streaming\" | \"error\"",
            "type": "string",
            "enum": [
              "error",
              "idle",
              "streaming"
            ],
            "default": "idle"
          },
          "error": {
            "description": "Error text for status=\"error\".",
            "x-ts-type": "React.ReactNode",
            "default": "Something went wrong."
          },
          "onRetry": {
            "description": "Adds a Retry button to the error row.",
            "x-ts-type": "() => void"
          },
          "actions": {
            "description": "Action bar under the content, usually `<MessageActions>` with MessageCopyButton, MessageAction, MessageFeedback, MessageBranch.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "from"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/message/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Message,\n  MessageAction,\n  type MessageActionProps,\n  MessageActions,\n  type MessageActionsProps,\n  MessageAvatar,\n  type MessageAvatarProps,\n  MessageBranch,\n  type MessageBranchProps,\n  MessageCopyButton,\n  type MessageCopyButtonProps,\n  MessageFeedback,\n  type MessageFeedbackProps,\n  type MessageProps,\n} from \"./message\";\nexport { MessageList, type MessageListProps } from \"./message-list\";\n"
        },
        {
          "target": "components/kura/message/message-list.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ArrowDown } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MessageListProps extends React.ComponentProps<\"div\"> {\n  /** Follow new content while the reader is at the bottom; stop as soon as they scroll up. Default true. */\n  stickToBottom?: boolean;\n  /** Distance in px from the bottom that still counts as “at the bottom”. Default 64. */\n  threshold?: number;\n  /** Label of the pill that appears when the reader has scrolled away. Default “Jump to latest”. */\n  jumpLabel?: string;\n  /** Classes for the inner content column (gap, max width, padding). */\n  contentClassName?: string;\n}\n\n/**\n * Scrollable chat thread (`role=\"log\"`). Sticks to the bottom while content streams in, lets the\n * reader scroll up without being yanked back, and offers a “Jump to latest” pill until they\n * return. Give it a height (`h-[480px]`, `flex-1`, …).\n */\nexport function MessageList({\n  stickToBottom = true,\n  threshold = 64,\n  jumpLabel = \"Jump to latest\",\n  contentClassName,\n  className,\n  children,\n  ...props\n}: MessageListProps) {\n  const viewport = React.useRef<HTMLDivElement>(null);\n  const content = React.useRef<HTMLDivElement>(null);\n  const stuck = React.useRef(true);\n  const lastTop = React.useRef(0);\n  const [atBottom, setAtBottom] = React.useState(true);\n  const reduced = useReducedMotion();\n\n  const distance = React.useCallback(() => {\n    const el = viewport.current;\n    return el ? el.scrollHeight - el.scrollTop - el.clientHeight : 0;\n  }, []);\n\n  const pin = React.useCallback(() => {\n    const el = viewport.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  // Start at the bottom, and follow content growth (new messages, streaming tokens) while stuck.\n  React.useLayoutEffect(() => {\n    if (stickToBottom) pin();\n    const el = content.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (stickToBottom && stuck.current) pin();\n      setAtBottom(distance() <= threshold);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [stickToBottom, threshold, pin, distance]);\n\n  const onScroll = () => {\n    const el = viewport.current;\n    if (!el) return;\n    const bottom = distance() <= threshold;\n    if (bottom) stuck.current = true;\n    else if (el.scrollTop < lastTop.current - 1) stuck.current = false;\n    lastTop.current = el.scrollTop;\n    setAtBottom(bottom);\n  };\n\n  const jump = () => {\n    stuck.current = true;\n    viewport.current?.scrollTo({\n      top: viewport.current.scrollHeight,\n      behavior: reduced ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const showJump = !atBottom;\n\n  return (\n    <div\n      data-slot=\"message-list\"\n      data-at-bottom={atBottom}\n      className={cn(\"relative flex min-h-0 flex-col overflow-hidden\", className)}\n      {...props}\n    >\n      <div\n        ref={viewport}\n        role=\"log\"\n        aria-label=\"Conversation\"\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: the log must be keyboard-scrollable\n        tabIndex={0}\n        onScroll={onScroll}\n        onWheel={(e) => {\n          // Scrolling up with a wheel or trackpad releases the stick immediately, even mid-stream.\n          if (e.deltaY < 0) stuck.current = false;\n        }}\n        data-slot=\"message-list-viewport\"\n        className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none [scrollbar-gutter:stable] [scrollbar-width:thin] focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\"\n      >\n        <div\n          ref={content}\n          data-slot=\"message-list-content\"\n          className={cn(\"flex flex-col gap-5 px-4 py-6\", contentClassName)}\n        >\n          {children}\n        </div>\n      </div>\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n        <button\n          type=\"button\"\n          data-slot=\"message-list-jump\"\n          data-state={showJump ? \"visible\" : \"hidden\"}\n          inert={!showJump || undefined}\n          aria-hidden={!showJump || undefined}\n          onClick={jump}\n          className={cn(\n            \"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-kura-overlay pr-3.5 pl-3 font-kura-sans font-medium text-kura-fg text-xs shadow-kura-md outline-none backdrop-blur-xl\",\n            \"transition-[opacity,transform,scale] duration-(--kura-duration-base) ease-kura-out focus-visible:shadow-kura-focus active:scale-[0.97]\",\n            showJump\n              ? \"pointer-events-auto translate-y-0 opacity-100\"\n              : \"translate-y-2 scale-95 opacity-0 duration-(--kura-duration-fast)\",\n            \"motion-reduce:translate-y-0 motion-reduce:scale-100 motion-reduce:active:scale-100\",\n          )}\n        >\n          <ArrowDown aria-hidden className=\"size-3.5 text-kura-fg-muted\" />\n          {jumpLabel}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/message/message.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport {\n  AlertCircle,\n  Check,\n  ChevronLeft,\n  ChevronRight,\n  Copy,\n  RotateCcw,\n  ThumbsDown,\n  ThumbsUp,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/kura/button\";\nimport { IconButton, type IconButtonProps } from \"@/components/kura/icon-button\";\n\n// ------------------------------------------------------------------------------------------------\n// Message\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageProps extends React.ComponentProps<\"div\"> {\n  /** Who wrote it. User messages render as a right-aligned bubble; assistant messages as open text. */\n  from: \"user\" | \"assistant\";\n  /** Display name shown in the header, e.g. \"Kura\" or \"You\". */\n  name?: React.ReactNode;\n  /** Avatar element, usually `<MessageAvatar />`. */\n  avatar?: React.ReactNode;\n  /** When it was sent. Dates/numbers render as a localized time (“10:42 AM”) with the full date as a tooltip. */\n  timestamp?: Date | number | string;\n  /** `streaming` sets aria-busy and keeps actions invisible (but laid out); `error` shows `error` and a Retry button. Default \"idle\". */\n  status?: \"idle\" | \"streaming\" | \"error\";\n  /** Error text shown when `status=\"error\"`. Default “Something went wrong.” */\n  error?: React.ReactNode;\n  /** Shows a Retry button in the error row. */\n  onRetry?: () => void;\n  /** Action bar under the content, usually `<MessageActions>`. */\n  actions?: React.ReactNode;\n}\n\n/**\n * One chat turn. Content goes in `children` — plain text, StreamingText, rendered markdown or\n * CodeBlocks; block children are spaced with a gap, not margins. Hover actions, error and\n * streaming states are built in.\n */\nexport function Message({\n  from,\n  name,\n  avatar,\n  timestamp,\n  status = \"idle\",\n  error,\n  onRetry,\n  actions,\n  className,\n  children,\n  ...props\n}: MessageProps) {\n  const user = from === \"user\";\n  return (\n    <div\n      data-slot=\"message\"\n      data-from={from}\n      data-status={status}\n      aria-busy={status === \"streaming\" || undefined}\n      className={cn(\n        \"group/message flex w-full gap-3 font-kura-sans\",\n        user && \"flex-row-reverse\",\n        className,\n      )}\n      {...props}\n    >\n      {avatar && <div className=\"shrink-0\">{avatar}</div>}\n      <div\n        className={cn(\"flex min-w-0 flex-1 flex-col gap-1.5\", user ? \"items-end\" : \"items-start\")}\n      >\n        {(name != null || timestamp != null) && (\n          <div\n            data-slot=\"message-header\"\n            className={cn(\n              \"flex h-7 items-center gap-2 text-[13px] leading-none\",\n              user && \"flex-row-reverse\",\n            )}\n          >\n            {name != null && <span className=\"font-medium text-kura-fg\">{name}</span>}\n            {timestamp != null && <MessageTime value={timestamp} />}\n          </div>\n        )}\n        <div\n          data-slot=\"message-body\"\n          className={cn(\n            \"flex min-w-0 max-w-full flex-col gap-3 text-[15px] text-kura-fg leading-relaxed\",\n            // Markdown-friendly defaults for rendered HTML. Links and lists skip anything that is (or\n            // sits inside) a nested Kura component, so e.g. a Sources strip keeps its own styling.\n            \"[&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:underline [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:decoration-kura-border-strong [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:underline-offset-[3px] [&_a:not([data-slot],[data-slot=message-body]_[data-slot]_*):hover]:decoration-current\",\n            \"[&_strong]:font-semibold [&_ol:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:list-decimal [&_ol:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-5 [&_ul:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:list-disc [&_ul:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-5 [&_li:not([data-slot],[data-slot=message-body]_[data-slot]_*)+li]:mt-1 [&_li:not([data-slot],[data-slot=message-body]_[data-slot]_*)]:pl-0.5 [&_li::marker]:text-kura-fg-subtle\",\n            \"[&_:not(pre)>code:not([data-slot])]:rounded-kura-xs [&_:not(pre)>code:not([data-slot])]:bg-kura-active [&_:not(pre)>code:not([data-slot])]:px-[0.3em] [&_:not(pre)>code:not([data-slot])]:py-[0.1em] [&_:not(pre)>code:not([data-slot])]:font-kura-mono [&_:not(pre)>code:not([data-slot])]:text-[0.86em]\",\n            \"[&_blockquote]:border-kura-border-strong [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:text-kura-fg-muted\",\n            \"[&_h1]:font-semibold [&_h1]:text-xl [&_h2]:font-semibold [&_h2]:text-lg [&_h3]:font-semibold [&_h3]:text-base\",\n            user\n              ? \"w-fit max-w-[min(80%,36rem)] whitespace-pre-wrap break-words rounded-kura-2xl rounded-br-kura-sm bg-kura-gray-3 px-4 py-2.5\"\n              : \"w-full\",\n            status === \"error\" && \"text-kura-fg-muted\",\n          )}\n        >\n          {children}\n        </div>\n        {status === \"error\" && (\n          <div\n            data-slot=\"message-error\"\n            role=\"alert\"\n            className=\"flex animate-kura-fade-in items-center gap-2 text-[13px] text-kura-danger-text\"\n          >\n            <AlertCircle aria-hidden className=\"size-3.5 shrink-0\" />\n            <span>{error ?? \"Something went wrong.\"}</span>\n            {onRetry && (\n              <Button variant=\"ghost\" size=\"xs\" onClick={onRetry} className=\"text-kura-fg-muted\">\n                <RotateCcw /> Retry\n              </Button>\n            )}\n          </div>\n        )}\n        {actions != null && (\n          <div\n            className={cn(\"flex\", user ? \"justify-end\" : \"-ml-1.5 justify-start\")}\n            // Keep the row laid out while streaming so actions appearing never shift the thread.\n            data-slot=\"message-actions-row\"\n            inert={status === \"streaming\" || undefined}\n          >\n            <div className={cn(status === \"streaming\" && \"invisible\")}>{actions}</div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction MessageTime({ value }: { value: Date | number | string }) {\n  if (typeof value === \"string\") {\n    return <span className=\"text-kura-fg-subtle text-xs tabular-nums\">{value}</span>;\n  }\n  const date = new Date(value);\n  return (\n    <time\n      dateTime={date.toISOString()}\n      title={date.toLocaleString()}\n      suppressHydrationWarning\n      className=\"text-kura-fg-subtle text-xs tabular-nums\"\n    >\n      {date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" })}\n    </time>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Avatar\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageAvatarProps extends React.ComponentProps<\"span\"> {\n  /** Image URL. Falls back to `fallback` (or initials of `name`) if missing or broken. */\n  src?: string;\n  /** Used for the image alt text and the initials fallback. */\n  name: string;\n  /** Custom fallback, e.g. a logo or lucide icon. */\n  fallback?: React.ReactNode;\n}\n\n/** 28px round avatar with an image, an icon fallback or initials. */\nexport function MessageAvatar({ src, name, fallback, className, ...props }: MessageAvatarProps) {\n  const [broken, setBroken] = React.useState(false);\n  const initials = name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n  return (\n    <span\n      data-slot=\"message-avatar\"\n      className={cn(\n        \"relative flex size-7 shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-kura-surface-raised font-medium text-[11px] text-kura-fg-muted shadow-kura-xs [&_svg]:size-3.5\",\n        className,\n      )}\n      {...props}\n    >\n      {src && !broken ? (\n        <img\n          src={src}\n          alt={name}\n          className=\"size-full object-cover\"\n          onError={() => setBroken(true)}\n        />\n      ) : (\n        (fallback ?? (\n          <>\n            <span aria-hidden>{initials}</span>\n            <span className=\"sr-only\">{name}</span>\n          </>\n        ))\n      )}\n    </span>\n  );\n}\n\n// ------------------------------------------------------------------------------------------------\n// Actions\n// ------------------------------------------------------------------------------------------------\n\nexport interface MessageActionsProps extends React.ComponentProps<\"div\"> {\n  /** `hover` reveals the bar on hover/focus (always on touch screens); `always` keeps it visible. Default \"hover\". */\n  visibility?: \"hover\" | \"always\";\n}\n\n/** The action bar. Fades in on hover or keyboard focus; it is always laid out, so it never shifts the thread. */\nexport function MessageActions({ visibility = \"hover\", className, ...props }: MessageActionsProps) {\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a toolbar\n    <div\n      role=\"group\"\n      aria-label=\"Message actions\"\n      data-slot=\"message-actions\"\n      data-visibility={visibility}\n      className={cn(\n        \"flex items-center gap-0.5 text-kura-fg-subtle\",\n        visibility === \"hover\" &&\n          \"opacity-0 transition-opacity duration-(--kura-duration-fast) ease-kura-out focus-within:opacity-100 group-hover/message:opacity-100 pointer-coarse:opacity-100\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport interface MessageActionProps extends Omit<IconButtonProps, \"size\"> {}\n\n/** A small labelled icon button for the action bar (regenerate, edit, share…). */\nexport function MessageAction({ className, ...props }: MessageActionProps) {\n  return (\n    <IconButton\n      data-slot=\"message-action\"\n      size=\"sm\"\n      className={cn(\"text-kura-fg-subtle\", className)}\n      {...props}\n    />\n  );\n}\n\nexport interface MessageCopyButtonProps extends Omit<MessageActionProps, \"label\" | \"icon\"> {\n  /** The text to copy (usually the message's raw markdown). */\n  text: string;\n  /** Called after a successful copy. */\n  onCopied?: () => void;\n}\n\n/** Copy action that swaps to a check (cross-fade + 2px blur) for 1.6s after copying. */\nexport function MessageCopyButton({ text, onCopied, onClick, ...props }: MessageCopyButtonProps) {\n  const [copied, setCopied] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const swap =\n    \"col-start-1 row-start-1 size-3.5 transition-[opacity,scale,filter] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\";\n  const hidden = \"scale-75 opacity-0 blur-[2px] motion-reduce:scale-100 motion-reduce:blur-none\";\n  return (\n    <>\n      <MessageAction\n        label={copied ? \"Copied\" : \"Copy\"}\n        data-copied={copied}\n        onClick={async (event) => {\n          onClick?.(event);\n          try {\n            await navigator.clipboard.writeText(text);\n            setCopied(true);\n            onCopied?.();\n            clearTimeout(timer.current);\n            timer.current = setTimeout(() => setCopied(false), 1600);\n          } catch {\n            // Clipboard unavailable (insecure context): do nothing.\n          }\n        }}\n        icon={\n          <span className=\"grid place-items-center\">\n            <Copy className={cn(swap, copied && hidden)} />\n            <Check className={cn(swap, \"text-kura-success-text\", !copied && hidden)} />\n          </span>\n        }\n        {...props}\n      />\n      <span className=\"sr-only\" role=\"status\">\n        {copied ? \"Copied to clipboard\" : \"\"}\n      </span>\n    </>\n  );\n}\n\nexport interface MessageFeedbackProps\n  extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Current rating (controlled). `null` = no rating. */\n  value?: \"up\" | \"down\" | null;\n  /** Initial rating when uncontrolled. Default null. */\n  defaultValue?: \"up\" | \"down\" | null;\n  /** Called with the new rating; pressing the active thumb again clears it to null. */\n  onValueChange?: (value: \"up\" | \"down\" | null) => void;\n}\n\n/** Thumbs up / down toggle pair (aria-pressed). Pressing the active one again clears it. */\nexport function MessageFeedback({\n  value,\n  defaultValue = null,\n  onValueChange,\n  className,\n  ...props\n}: MessageFeedbackProps) {\n  const [rating, setRating] = useControllableState<\"up\" | \"down\" | null>({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const thumb = (kind: \"up\" | \"down\") => {\n    const active = rating === kind;\n    const Icon = kind === \"up\" ? ThumbsUp : ThumbsDown;\n    return (\n      <MessageAction\n        label={kind === \"up\" ? \"Good response\" : \"Bad response\"}\n        aria-pressed={active}\n        data-active={active || undefined}\n        className={cn(active && \"text-kura-fg\")}\n        onClick={() => setRating(active ? null : kind)}\n        icon={\n          <Icon\n            className={cn(\n              \"transition-[fill,scale] duration-(--kura-duration-fast) ease-kura-spring\",\n              active && \"scale-110 fill-current motion-reduce:scale-100\",\n            )}\n          />\n        }\n      />\n    );\n  };\n  return (\n    <div\n      data-slot=\"message-feedback\"\n      data-value={rating ?? \"none\"}\n      className={cn(\"flex items-center gap-0.5\", className)}\n      {...props}\n    >\n      {thumb(\"up\")}\n      {thumb(\"down\")}\n    </div>\n  );\n}\n\nexport interface MessageBranchProps extends React.ComponentProps<\"div\"> {\n  /** 1-based index of the visible version. */\n  current: number;\n  /** Number of versions (regenerations or edits). Renders nothing when ≤ 1. */\n  total: number;\n  /** Called with the new 1-based index. */\n  onCurrentChange?: (current: number) => void;\n}\n\n/** Version switcher “‹ 2/3 ›” for regenerated or edited messages. */\nexport function MessageBranch({\n  current,\n  total,\n  onCurrentChange,\n  className,\n  ...props\n}: MessageBranchProps) {\n  if (total <= 1) return null;\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a toolbar\n    <div\n      role=\"group\"\n      aria-label=\"Message versions\"\n      data-slot=\"message-branch\"\n      className={cn(\"flex items-center text-kura-fg-subtle\", className)}\n      {...props}\n    >\n      <MessageAction\n        label=\"Previous version\"\n        icon={<ChevronLeft />}\n        disabled={current <= 1}\n        onClick={() => onCurrentChange?.(current - 1)}\n      />\n      <span\n        aria-live=\"polite\"\n        className=\"min-w-[3ch] select-none text-center font-kura-sans text-xs tabular-nums\"\n      >\n        <span className=\"sr-only\">Version </span>\n        {current}\n        <span aria-hidden>/</span>\n        <span className=\"sr-only\"> of </span>\n        {total}\n      </span>\n      <MessageAction\n        label=\"Next version\"\n        icon={<ChevronRight />}\n        disabled={current >= total}\n        onClick={() => onCurrentChange?.(current + 1)}\n      />\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "model-picker",
      "name": "ModelPicker",
      "category": "input",
      "status": "beta",
      "description": "Searchable popover for choosing an AI model, grouped by provider, with capability icons, context size, speed and cost hints.",
      "whenToUse": [
        "Let the user switch the model in a chat composer toolbar or a chat header.",
        "Compare models at a glance: vision/tools/reasoning support, context window, speed and price tier.",
        "Any “choose one of N models/agents/providers” selector that benefits from search and grouping."
      ],
      "whenNotToUse": [
        "Running actions or navigating — use command-palette.",
        "Inserting commands while typing a prompt — use slash-command-menu.",
        "Two or three static options without metadata — a segmented control or plain select is enough."
      ],
      "props": [
        {
          "name": "models",
          "type": "readonly ModelPickerModel[]",
          "required": true,
          "description": "Models: { id, name, provider?, description?, capabilities?, contextWindow?, speed?: 1|2|3, cost?: 1|2|3, badge?, disabled?, icon? }. Grouped by provider in first-seen order."
        },
        {
          "name": "value",
          "type": "string",
          "description": "Controlled selected model id."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "description": "Initial id when uncontrolled. Defaults to the first enabled model."
        },
        {
          "name": "onValueChange",
          "type": "(id: string, model: ModelPickerModel) => void",
          "description": "Called when the user picks a model."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Controlled open state."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "default": "false",
          "description": "Initial open state."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Open state changes."
        },
        {
          "name": "searchable",
          "type": "boolean",
          "description": "Show the search field. Default: true when there are more than 6 models."
        },
        {
          "name": "placeholder",
          "type": "string",
          "default": "\"Select model\"",
          "description": "Trigger text when nothing is selected."
        },
        {
          "name": "searchPlaceholder",
          "type": "string",
          "default": "\"Search models…\"",
          "description": "Search field placeholder."
        },
        {
          "name": "emptyMessage",
          "type": "string",
          "default": "\"No models match\"",
          "description": "Shown when the search has no results."
        },
        {
          "name": "showDetails",
          "type": "boolean",
          "default": "true",
          "description": "Fixed-height footer describing the highlighted model (no layout shift)."
        },
        {
          "name": "side",
          "type": "\"top\" | \"bottom\"",
          "default": "\"bottom\"",
          "description": "Preferred popover side; flips on collision. Use top in a bottom-docked composer."
        },
        {
          "name": "align",
          "type": "\"start\" | \"center\" | \"end\"",
          "default": "\"start\"",
          "description": "Popover alignment relative to the trigger."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "default": "\"sm\"",
          "description": "Trigger height: sm 32px for toolbars, md 36px."
        }
      ],
      "parts": [
        {
          "name": "ModelPicker",
          "description": "The trigger button (provider mark, model name, chevron). data-state = open | closed.",
          "dataSlot": "model-picker"
        },
        {
          "name": "ModelPickerContent",
          "description": "Popover surface; scales from the trigger (origin-aware).",
          "dataSlot": "model-picker-content"
        },
        {
          "name": "ModelPickerSearch",
          "description": "Search row; the input is the combobox that owns keyboard navigation.",
          "dataSlot": "model-picker-search"
        },
        {
          "name": "ModelPickerGroup",
          "description": "role=group per provider, labelled by its heading.",
          "dataSlot": "model-picker-group"
        },
        {
          "name": "ModelPickerItem",
          "description": "role=option row: name, badge, meta line, capability icons, check. data-active, data-disabled.",
          "dataSlot": "model-picker-item"
        },
        {
          "name": "ModelPickerDetails",
          "description": "Footer with the highlighted model's description.",
          "dataSlot": "model-picker-details"
        }
      ],
      "a11y": {
        "role": "combobox + listbox",
        "notes": [
          "The trigger is labelled “Model: <name>”. On open, focus moves to the search combobox (or the listbox when not searchable) and returns to the trigger on close.",
          "Highlighting uses aria-activedescendant, so focus never leaves the search field while navigating.",
          "Options expose aria-selected and aria-disabled; provider groups are role=group with their heading as label.",
          "Capability icons, speed and cost meters carry visually hidden text (“Vision”, “Fast”, “High cost”), so the meta line reads naturally."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space / ArrowDown",
            "action": "Open from the trigger"
          },
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Move the highlight (wraps, skips disabled models)"
          },
          {
            "keys": "Home / End",
            "action": "First / last model (when the search is empty)"
          },
          {
            "keys": "Enter",
            "action": "Choose the highlighted model and close"
          },
          {
            "keys": "Escape",
            "action": "Close and return focus to the trigger"
          }
        ],
        "reducedMotion": "The popover fades without scale or travel and the chevron flips without rotating; highlight changes are instant in all modes."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "popover"
        ]
      },
      "related": [
        "composer",
        "command-palette",
        "slash-command-menu"
      ],
      "tags": [
        "model",
        "llm",
        "select",
        "combobox",
        "picker",
        "provider",
        "dropdown"
      ],
      "examples": [
        {
          "name": "ModelPickerMinimal",
          "title": "Three models",
          "kind": "minimal",
          "code": "import { ModelPicker, type ModelPickerModel } from \"@kura-ui/react/model-picker\";\n\nconst models: ModelPickerModel[] = [\n  {\n    id: \"claude-opus-5-5\",\n    name: \"Claude Opus 5.5\",\n    provider: \"Anthropic\",\n    description: \"Most capable. Long-horizon agentic work, deep research and hard code.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 1,\n    cost: 3,\n    badge: \"New\",\n  },\n  {\n    id: \"claude-sonnet-5-5\",\n    name: \"Claude Sonnet 5.5\",\n    provider: \"Anthropic\",\n    description: \"The everyday default. Strong reasoning at a fraction of the latency.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"claude-haiku-4-5\",\n    name: \"Claude Haiku 4.5\",\n    provider: \"Anthropic\",\n    description: \"Fastest. Great for classification, extraction and sub-agents.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 200_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gpt-5\",\n    name: \"GPT-5\",\n    provider: \"OpenAI\",\n    description: \"General-purpose flagship with adjustable reasoning effort.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"web\"],\n    contextWindow: 400_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"gpt-5-mini\",\n    name: \"GPT-5 mini\",\n    provider: \"OpenAI\",\n    description: \"Smaller and cheaper, tuned for well-defined tasks.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 400_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gemini-2-5-pro\",\n    name: \"Gemini 2.5 Pro\",\n    provider: \"Google\",\n    description: \"Native audio and video understanding with a very long context.\",\n    capabilities: [\"vision\", \"audio\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"llama-4-maverick\",\n    name: \"Llama 4 Maverick\",\n    provider: \"Open weights\",\n    description: \"Self-hostable mixture-of-experts model.\",\n    capabilities: [\"vision\"],\n    contextWindow: 1_000_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"mistral-large\",\n    name: \"Mistral Large\",\n    provider: \"Open weights\",\n    description: \"Unavailable on the free plan.\",\n    capabilities: [\"tools\"],\n    contextWindow: 128_000,\n    speed: 2,\n    cost: 2,\n    badge: \"Pro\",\n    disabled: true,\n  },\n];\n\nexport function ModelPickerMinimal() {\n  return <ModelPicker models={models.slice(0, 3)} />;\n}\n"
        },
        {
          "name": "ModelPickerFull",
          "title": "Searchable, grouped, controlled",
          "description": "Eight models across providers, a disabled tier, badges, capability icons and a controlled open state.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { ModelPicker, type ModelPickerModel } from \"@kura-ui/react/model-picker\";\n\nconst models: ModelPickerModel[] = [\n  {\n    id: \"claude-opus-5-5\",\n    name: \"Claude Opus 5.5\",\n    provider: \"Anthropic\",\n    description: \"Most capable. Long-horizon agentic work, deep research and hard code.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 1,\n    cost: 3,\n    badge: \"New\",\n  },\n  {\n    id: \"claude-sonnet-5-5\",\n    name: \"Claude Sonnet 5.5\",\n    provider: \"Anthropic\",\n    description: \"The everyday default. Strong reasoning at a fraction of the latency.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"claude-haiku-4-5\",\n    name: \"Claude Haiku 4.5\",\n    provider: \"Anthropic\",\n    description: \"Fastest. Great for classification, extraction and sub-agents.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 200_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gpt-5\",\n    name: \"GPT-5\",\n    provider: \"OpenAI\",\n    description: \"General-purpose flagship with adjustable reasoning effort.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"web\"],\n    contextWindow: 400_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"gpt-5-mini\",\n    name: \"GPT-5 mini\",\n    provider: \"OpenAI\",\n    description: \"Smaller and cheaper, tuned for well-defined tasks.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 400_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gemini-2-5-pro\",\n    name: \"Gemini 2.5 Pro\",\n    provider: \"Google\",\n    description: \"Native audio and video understanding with a very long context.\",\n    capabilities: [\"vision\", \"audio\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"llama-4-maverick\",\n    name: \"Llama 4 Maverick\",\n    provider: \"Open weights\",\n    description: \"Self-hostable mixture-of-experts model.\",\n    capabilities: [\"vision\"],\n    contextWindow: 1_000_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"mistral-large\",\n    name: \"Mistral Large\",\n    provider: \"Open weights\",\n    description: \"Unavailable on the free plan.\",\n    capabilities: [\"tools\"],\n    contextWindow: 128_000,\n    speed: 2,\n    cost: 2,\n    badge: \"Pro\",\n    disabled: true,\n  },\n];\n\nexport function ModelPickerFull() {\n  const [model, setModel] = React.useState(\"claude-sonnet-5-5\");\n  const [open, setOpen] = React.useState(false);\n  const current = models.find((m) => m.id === model);\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <ModelPicker\n        models={models}\n        value={model}\n        onValueChange={(id) => setModel(id)}\n        open={open}\n        onOpenChange={setOpen}\n        searchable\n        size=\"md\"\n        side=\"bottom\"\n        align=\"center\"\n        placeholder=\"Choose a model\"\n        searchPlaceholder=\"Search by name, provider or capability…\"\n        emptyMessage=\"No model has that capability\"\n        showDetails\n      />\n      <p className=\"text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n        Using <span className=\"font-kura-mono text-kura-fg-muted\">{current?.id}</span>\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ModelPickerInComposer",
          "title": "In a composer toolbar",
          "description": "Opens upward from the composer's toolbar.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  Composer,\n  ComposerActions,\n  ComposerAttachButton,\n  ComposerInput,\n  ComposerSubmit,\n  ComposerToolbar,\n  ComposerTools,\n} from \"@kura-ui/react/composer\";\nimport { ModelPicker, type ModelPickerModel } from \"@kura-ui/react/model-picker\";\n\nconst models: ModelPickerModel[] = [\n  {\n    id: \"claude-opus-5-5\",\n    name: \"Claude Opus 5.5\",\n    provider: \"Anthropic\",\n    description: \"Most capable. Long-horizon agentic work, deep research and hard code.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 1,\n    cost: 3,\n    badge: \"New\",\n  },\n  {\n    id: \"claude-sonnet-5-5\",\n    name: \"Claude Sonnet 5.5\",\n    provider: \"Anthropic\",\n    description: \"The everyday default. Strong reasoning at a fraction of the latency.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"files\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"claude-haiku-4-5\",\n    name: \"Claude Haiku 4.5\",\n    provider: \"Anthropic\",\n    description: \"Fastest. Great for classification, extraction and sub-agents.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 200_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gpt-5\",\n    name: \"GPT-5\",\n    provider: \"OpenAI\",\n    description: \"General-purpose flagship with adjustable reasoning effort.\",\n    capabilities: [\"vision\", \"tools\", \"reasoning\", \"web\"],\n    contextWindow: 400_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"gpt-5-mini\",\n    name: \"GPT-5 mini\",\n    provider: \"OpenAI\",\n    description: \"Smaller and cheaper, tuned for well-defined tasks.\",\n    capabilities: [\"vision\", \"tools\"],\n    contextWindow: 400_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"gemini-2-5-pro\",\n    name: \"Gemini 2.5 Pro\",\n    provider: \"Google\",\n    description: \"Native audio and video understanding with a very long context.\",\n    capabilities: [\"vision\", \"audio\", \"tools\", \"reasoning\"],\n    contextWindow: 1_000_000,\n    speed: 2,\n    cost: 2,\n  },\n  {\n    id: \"llama-4-maverick\",\n    name: \"Llama 4 Maverick\",\n    provider: \"Open weights\",\n    description: \"Self-hostable mixture-of-experts model.\",\n    capabilities: [\"vision\"],\n    contextWindow: 1_000_000,\n    speed: 3,\n    cost: 1,\n  },\n  {\n    id: \"mistral-large\",\n    name: \"Mistral Large\",\n    provider: \"Open weights\",\n    description: \"Unavailable on the free plan.\",\n    capabilities: [\"tools\"],\n    contextWindow: 128_000,\n    speed: 2,\n    cost: 2,\n    badge: \"Pro\",\n    disabled: true,\n  },\n];\n\nexport function ModelPickerInComposer() {\n  const [model, setModel] = React.useState(\"claude-opus-5-5\");\n  return (\n    <div className=\"w-full max-w-xl\">\n      <Composer onSubmit={() => {}}>\n        <ComposerInput placeholder=\"Plan a migration from REST to GraphQL…\" />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n            <ModelPicker models={models} value={model} onValueChange={setModel} side=\"top\" />\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass `side=\"top\"` inside a composer docked to the bottom of the viewport.",
        "speed: 3 = fast, 1 = slow/thorough. cost: 1 = cheap, 3 = expensive. Omit either to hide it.",
        "`icon` takes a 16px provider logo; without it a monogram of the provider is shown.",
        "formatContextWindow(200000) → \"200K\" is exported for use elsewhere (e.g. a context-meter)."
      ],
      "title": "Model Picker",
      "importPath": "@kura-ui/react/model-picker",
      "exports": [
        "ModelCapability",
        "ModelPicker",
        "ModelPickerModel",
        "ModelPickerProps",
        "formatContextWindow"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/model-picker",
      "markdownUrl": "https://kuro.abstergo.fyi/components/model-picker.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/model-picker.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/model-picker.json",
        "cli": "npx @kura-ui/cli add model-picker",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "popover",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "models": {
            "description": "Models: { id, name, provider?, description?, capabilities?, contextWindow?, speed?: 1|2|3, cost?: 1|2|3, badge?, disabled?, icon? }. Grouped by provider in first-seen order.",
            "x-ts-type": "readonly ModelPickerModel[]",
            "type": "array"
          },
          "value": {
            "description": "Controlled selected model id.",
            "x-ts-type": "string",
            "type": "string"
          },
          "defaultValue": {
            "description": "Initial id when uncontrolled. Defaults to the first enabled model.",
            "x-ts-type": "string",
            "type": "string"
          },
          "onValueChange": {
            "description": "Called when the user picks a model.",
            "x-ts-type": "(id: string, model: ModelPickerModel) => void"
          },
          "open": {
            "description": "Controlled open state.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultOpen": {
            "description": "Initial open state.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onOpenChange": {
            "description": "Open state changes.",
            "x-ts-type": "(open: boolean) => void"
          },
          "searchable": {
            "description": "Show the search field. Default: true when there are more than 6 models.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "placeholder": {
            "description": "Trigger text when nothing is selected.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Select model"
          },
          "searchPlaceholder": {
            "description": "Search field placeholder.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Search models…"
          },
          "emptyMessage": {
            "description": "Shown when the search has no results.",
            "x-ts-type": "string",
            "type": "string",
            "default": "No models match"
          },
          "showDetails": {
            "description": "Fixed-height footer describing the highlighted model (no layout shift).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "side": {
            "description": "Preferred popover side; flips on collision. Use top in a bottom-docked composer.",
            "x-ts-type": "\"top\" | \"bottom\"",
            "type": "string",
            "enum": [
              "bottom",
              "top"
            ],
            "default": "bottom"
          },
          "align": {
            "description": "Popover alignment relative to the trigger.",
            "x-ts-type": "\"start\" | \"center\" | \"end\"",
            "type": "string",
            "enum": [
              "center",
              "end",
              "start"
            ],
            "default": "start"
          },
          "size": {
            "description": "Trigger height: sm 32px for toolbars, md 36px.",
            "x-ts-type": "\"sm\" | \"md\"",
            "type": "string",
            "enum": [
              "md",
              "sm"
            ],
            "default": "sm"
          }
        },
        "required": [
          "models"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/model-picker/index.ts",
          "type": "registry:ui",
          "content": "export {\n  formatContextWindow,\n  type ModelCapability,\n  ModelPicker,\n  type ModelPickerModel,\n  type ModelPickerProps,\n} from \"./model-picker\";\n"
        },
        {
          "target": "components/kura/model-picker/model-picker.tsx",
          "type": "registry:ui",
          "content": "// ARIA listbox pattern: options are highlighted via aria-activedescendant from the focused\n// combobox/listbox, so they are intentionally not focusable and have no own key handlers.\n// biome-ignore-all lint/a11y/useSemanticElements: rich rows cannot be native <option>/<fieldset>.\n// biome-ignore-all lint/a11y/useFocusableInteractive: focus stays on the combobox (activedescendant).\n// biome-ignore-all lint/a11y/useKeyWithClickEvents: keyboard selection is handled by the combobox.\n\"use client\";\n\nimport {\n  AudioLines,\n  Brain,\n  Check,\n  ChevronDown,\n  Eye,\n  FileText,\n  Globe,\n  Search,\n  Wrench,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/kura/popover\";\n\nexport type ModelCapability = \"vision\" | \"tools\" | \"reasoning\" | \"web\" | \"audio\" | \"files\";\n\nexport interface ModelPickerModel {\n  /** Stable id passed to `onValueChange`, e.g. \"claude-opus-5-5\". */\n  id: string;\n  /** Display name, e.g. \"Claude Opus 5.5\". */\n  name: string;\n  /** Group heading, e.g. \"Anthropic\". Models keep their order within a provider. */\n  provider?: string;\n  /** One sentence shown in the detail footer while the model is highlighted. */\n  description?: string;\n  capabilities?: readonly ModelCapability[];\n  /** Context window in tokens, e.g. 200000 (shown as \"200K\"). */\n  contextWindow?: number;\n  /** 1 = slow/thorough, 2 = balanced, 3 = fast. */\n  speed?: 1 | 2 | 3;\n  /** 1 = cheap, 3 = expensive. */\n  cost?: 1 | 2 | 3;\n  /** Short tag next to the name, e.g. \"New\" or \"Beta\". */\n  badge?: string;\n  disabled?: boolean;\n  /** Provider or model mark (16px). Falls back to a monogram of the provider. */\n  icon?: React.ReactNode;\n}\n\nconst CAPABILITIES: Record<\n  ModelCapability,\n  { label: string; Icon: React.ComponentType<{ className?: string }> }\n> = {\n  vision: { label: \"Vision\", Icon: Eye },\n  tools: { label: \"Tool use\", Icon: Wrench },\n  reasoning: { label: \"Reasoning\", Icon: Brain },\n  web: { label: \"Web search\", Icon: Globe },\n  audio: { label: \"Audio\", Icon: AudioLines },\n  files: { label: \"Files\", Icon: FileText },\n};\n\nconst SPEED = [\"\", \"Thorough\", \"Balanced\", \"Fast\"] as const;\nconst COST = [\"\", \"Low cost\", \"Medium cost\", \"High cost\"] as const;\n\n/** 200000 → \"200K\", 1000000 → \"1M\". */\nexport function formatContextWindow(tokens: number): string {\n  if (tokens >= 1_000_000) {\n    const m = tokens / 1_000_000;\n    return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;\n  }\n  if (tokens >= 1000) return `${Math.round(tokens / 1000)}K`;\n  return String(tokens);\n}\n\nfunction matches(model: ModelPickerModel, query: string): boolean {\n  const hay = [\n    model.name,\n    model.id,\n    model.provider,\n    model.description,\n    model.badge,\n    ...(model.capabilities ?? []).map((c) => CAPABILITIES[c].label),\n  ]\n    .filter(Boolean)\n    .join(\" \")\n    .toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .every((word) => hay.includes(word));\n}\n\nfunction ProviderMark({ model, className }: { model: ModelPickerModel; className?: string }) {\n  if (model.icon) {\n    return (\n      <span\n        aria-hidden\n        className={cn(\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\", className)}\n      >\n        {model.icon}\n      </span>\n    );\n  }\n  const letter = (model.provider ?? model.name).trim().charAt(0).toUpperCase();\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"flex size-4 shrink-0 items-center justify-center rounded-kura-xs bg-kura-active font-semibold text-[9px] text-kura-fg-muted leading-none\",\n        className,\n      )}\n    >\n      {letter}\n    </span>\n  );\n}\n\nfunction Meter({ value, label, kind }: { value: number; label: string; kind: \"speed\" | \"cost\" }) {\n  return (\n    <span className=\"inline-flex items-center gap-1\" title={label}>\n      <span aria-hidden className=\"inline-flex items-end gap-[2px]\">\n        {[1, 2, 3].map((i) =>\n          kind === \"speed\" ? (\n            <span\n              key={i}\n              className={cn(\n                \"w-[3px] rounded-full\",\n                i === 1 ? \"h-1.5\" : i === 2 ? \"h-2\" : \"h-2.5\",\n                i <= value ? \"bg-kura-fg-muted\" : \"bg-kura-border-strong\",\n              )}\n            />\n          ) : (\n            <span\n              key={i}\n              className={cn(\n                \"font-kura-mono text-[10px] leading-none\",\n                i <= value ? \"text-kura-fg-muted\" : \"text-kura-border-strong\",\n              )}\n            >\n              $\n            </span>\n          ),\n        )}\n      </span>\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\nexport interface ModelPickerProps\n  extends Omit<React.ComponentProps<\"button\">, \"value\" | \"defaultValue\" | \"children\"> {\n  /** Every selectable model. Grouped by `provider` in first-seen order. */\n  models: readonly ModelPickerModel[];\n  /** Controlled selected model id. */\n  value?: string;\n  /** Initial model id when uncontrolled. Default: the first enabled model. */\n  defaultValue?: string;\n  onValueChange?: (id: string, model: ModelPickerModel) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Show the search field. Default: true when there are more than 6 models. */\n  searchable?: boolean;\n  /** Trigger text when nothing is selected. Default \"Select model\". */\n  placeholder?: string;\n  /** Default \"Search models…\". */\n  searchPlaceholder?: string;\n  /** Default \"No models match\". */\n  emptyMessage?: string;\n  /** Show the description footer for the highlighted model. Default true. */\n  showDetails?: boolean;\n  /** Popover side. Use \"top\" inside a composer at the bottom of the screen. Default \"bottom\". */\n  side?: \"top\" | \"bottom\";\n  /** Default \"start\". */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Trigger size. Default \"sm\" (28px), for toolbars. */\n  size?: \"sm\" | \"md\";\n}\n\n/**\n * Model selector: a quiet trigger that opens a searchable, grouped list with capability icons,\n * context size, speed and cost hints. Full keyboard support (arrows, Home/End, Enter, Escape)\n * with focus kept in the search field.\n */\nexport function ModelPicker({\n  models,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  searchable,\n  placeholder = \"Select model\",\n  searchPlaceholder = \"Search models…\",\n  emptyMessage = \"No models match\",\n  showDetails = true,\n  side = \"bottom\",\n  align = \"start\",\n  size = \"sm\",\n  disabled,\n  className,\n  ...props\n}: ModelPickerProps) {\n  const firstEnabled = models.find((m) => !m.disabled)?.id ?? \"\";\n  const [value, setValue] = useControllableState({\n    value: valueProp,\n    defaultValue: defaultValue ?? firstEnabled,\n    onChange: (id: string) => {\n      const model = models.find((m) => m.id === id);\n      if (model) onValueChange?.(id, model);\n    },\n  });\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  });\n  const [query, setQuery] = React.useState(\"\");\n  const [active, setActive] = React.useState<string | null>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const baseId = React.useId();\n  const listId = `${baseId}-list`;\n  const optionId = React.useCallback(\n    (id: string) => `${baseId}-opt-${id.replace(/[^a-zA-Z0-9_-]/g, \"_\")}`,\n    [baseId],\n  );\n  const showSearch = searchable ?? models.length > 6;\n\n  const selected = models.find((m) => m.id === value);\n  const filtered = React.useMemo(\n    () => (query.trim() ? models.filter((m) => matches(m, query)) : models),\n    [models, query],\n  );\n  const groups = React.useMemo(() => {\n    const map = new Map<string, ModelPickerModel[]>();\n    for (const m of filtered) {\n      const key = m.provider ?? \"\";\n      const list = map.get(key);\n      if (list) list.push(m);\n      else map.set(key, [m]);\n    }\n    return [...map.entries()];\n  }, [filtered]);\n  const flat = React.useMemo(() => groups.flatMap(([, list]) => list), [groups]);\n  const enabled = flat.filter((m) => !m.disabled);\n  const activeModel = flat.find((m) => m.id === active) ?? null;\n\n  // Reset the highlight to the selection when opening; keep it valid while filtering.\n  React.useEffect(() => {\n    if (!open) return;\n    setActive((current) => {\n      if (current && enabled.some((m) => m.id === current)) return current;\n      if (!query && enabled.some((m) => m.id === value)) return value;\n      return enabled[0]?.id ?? null;\n    });\n  }, [open, enabled, query, value]);\n\n  React.useEffect(() => {\n    if (!open || !active) return;\n    const el = typeof document === \"undefined\" ? null : document.getElementById(optionId(active));\n    el?.scrollIntoView?.({ block: \"nearest\" });\n  }, [active, open, optionId]);\n\n  const choose = (model: ModelPickerModel) => {\n    if (model.disabled) return;\n    setValue(model.id);\n    setOpen(false);\n  };\n\n  const move = (delta: number | \"first\" | \"last\") => {\n    if (enabled.length === 0) return;\n    const index = enabled.findIndex((m) => m.id === active);\n    let next: number;\n    if (delta === \"first\") next = 0;\n    else if (delta === \"last\") next = enabled.length - 1;\n    else next = index === -1 ? 0 : (index + delta + enabled.length) % enabled.length;\n    setActive(enabled[next]?.id ?? null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.nativeEvent.isComposing) return;\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(1);\n        break;\n      case \"ArrowUp\":\n        e.preventDefault();\n        move(-1);\n        break;\n      case \"Home\":\n        if (showSearch && query) return;\n        e.preventDefault();\n        move(\"first\");\n        break;\n      case \"End\":\n        if (showSearch && query) return;\n        e.preventDefault();\n        move(\"last\");\n        break;\n      case \"Enter\": {\n        e.preventDefault();\n        if (activeModel) choose(activeModel);\n        break;\n      }\n    }\n  };\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) setQuery(\"\");\n      }}\n    >\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          data-slot=\"model-picker\"\n          data-state={open ? \"open\" : \"closed\"}\n          disabled={disabled}\n          aria-label={selected ? `Model: ${selected.name}` : placeholder}\n          className={cn(\n            \"group/model-picker inline-flex min-w-0 shrink cursor-pointer select-none items-center gap-1.5 rounded-kura-lg font-kura-sans font-medium text-kura-fg-muted outline-none\",\n            \"transition-[background-color,color,transform] duration-(--kura-duration-fast) ease-kura-out\",\n            \"hover:not-disabled:bg-kura-hover hover:not-disabled:text-kura-fg data-[state=open]:bg-kura-hover data-[state=open]:text-kura-fg\",\n            \"focus-visible:shadow-kura-focus active:not-disabled:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:active:not-disabled:scale-100\",\n            size === \"sm\" ? \"h-8 px-2 text-[13px]\" : \"h-9 px-2.5 text-sm\",\n            className,\n          )}\n          {...props}\n        >\n          {selected && <ProviderMark model={selected} />}\n          <span className=\"truncate\">{selected?.name ?? placeholder}</span>\n          <ChevronDown\n            aria-hidden\n            className=\"size-3.5 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-base) ease-kura-out group-data-[state=open]/model-picker:rotate-180 motion-reduce:transition-none\"\n          />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent\n        side={side}\n        align={align}\n        sideOffset={6}\n        data-slot=\"model-picker-content\"\n        aria-label=\"Choose a model\"\n        className=\"flex w-[340px] flex-col overflow-hidden p-0\"\n        onOpenAutoFocus={(e) => {\n          e.preventDefault();\n          (showSearch ? inputRef.current : listRef.current)?.focus();\n        }}\n        onKeyDown={showSearch ? undefined : onKeyDown}\n      >\n        {showSearch && (\n          <div\n            data-slot=\"model-picker-search\"\n            className=\"flex h-10 shrink-0 items-center gap-2 px-3 shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\"\n          >\n            <Search aria-hidden className=\"size-3.5 shrink-0 text-kura-fg-subtle\" />\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={active ? optionId(active) : undefined}\n              aria-label=\"Search models\"\n              value={query}\n              placeholder={searchPlaceholder}\n              onChange={(e) => setQuery(e.currentTarget.value)}\n              onKeyDown={onKeyDown}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[13px] text-kura-fg outline-none placeholder:text-kura-fg-subtle\"\n            />\n          </div>\n        )}\n        <div\n          ref={listRef}\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Models\"\n          tabIndex={showSearch ? -1 : 0}\n          aria-activedescendant={!showSearch && active ? optionId(active) : undefined}\n          className=\"max-h-[min(20rem,var(--radix-popover-content-available-height,20rem))] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none [scrollbar-width:thin]\"\n        >\n          {flat.length === 0 && (\n            <div className=\"px-3 py-6 text-center text-[13px] text-kura-fg-subtle\">\n              {emptyMessage}\n            </div>\n          )}\n          {groups.map(([provider, list]) => {\n            const headingId = `${baseId}-group-${provider.replace(/\\W/g, \"_\") || \"default\"}`;\n            return (\n              <div\n                key={provider || \"__default\"}\n                role=\"group\"\n                aria-labelledby={provider ? headingId : undefined}\n                data-slot=\"model-picker-group\"\n                className=\"not-first:mt-1\"\n              >\n                {provider && (\n                  <div\n                    id={headingId}\n                    className=\"select-none px-2 pt-2 pb-1 font-medium text-[11px] text-kura-fg-subtle\"\n                  >\n                    {provider}\n                  </div>\n                )}\n                {list.map((model) => {\n                  const isSelected = model.id === value;\n                  const isActive = model.id === active;\n                  return (\n                    <div\n                      key={model.id}\n                      id={optionId(model.id)}\n                      role=\"option\"\n                      aria-selected={isSelected}\n                      aria-disabled={model.disabled || undefined}\n                      data-slot=\"model-picker-item\"\n                      data-active={isActive || undefined}\n                      data-disabled={model.disabled || undefined}\n                      onPointerMove={() =>\n                        !model.disabled && active !== model.id && setActive(model.id)\n                      }\n                      onClick={() => choose(model)}\n                      className={cn(\n                        \"flex cursor-pointer select-none items-center gap-2.5 rounded-kura-md px-2 py-1.5\",\n                        \"data-active:bg-kura-hover data-disabled:cursor-not-allowed data-disabled:opacity-45\",\n                      )}\n                    >\n                      <ProviderMark model={model} className=\"self-start mt-0.5\" />\n                      <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                        <div className=\"flex min-w-0 items-center gap-1.5\">\n                          <span className=\"truncate font-medium text-[13px] text-kura-fg leading-4\">\n                            {model.name}\n                          </span>\n                          {model.badge && (\n                            <span className=\"shrink-0 rounded-full bg-kura-active px-1.5 py-px font-medium text-[10px] text-kura-fg-muted leading-3.5\">\n                              {model.badge}\n                            </span>\n                          )}\n                        </div>\n                        <ModelMeta model={model} />\n                      </div>\n                      <Check\n                        aria-hidden\n                        className={cn(\n                          \"size-3.5 shrink-0 text-kura-fg transition-opacity duration-(--kura-duration-fast)\",\n                          isSelected ? \"opacity-100\" : \"opacity-0\",\n                        )}\n                      />\n                    </div>\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n        {showDetails && (\n          <div\n            data-slot=\"model-picker-details\"\n            aria-live=\"polite\"\n            className=\"flex h-12 shrink-0 items-center px-3 text-kura-fg-muted text-xs leading-4 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n          >\n            <p className=\"line-clamp-2\">\n              {activeModel?.description ?? (activeModel ? activeModel.name : \"\")}\n            </p>\n          </div>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction ModelMeta({ model }: { model: ModelPickerModel }) {\n  const parts: React.ReactNode[] = [];\n  if (model.contextWindow) {\n    parts.push(\n      <span key=\"ctx\" title={`${model.contextWindow.toLocaleString(\"en-US\")} token context window`}>\n        <span aria-hidden>{formatContextWindow(model.contextWindow)}</span>\n        <span className=\"sr-only\">{`${formatContextWindow(model.contextWindow)} context`}</span>\n      </span>,\n    );\n  }\n  if (model.speed)\n    parts.push(<Meter key=\"speed\" kind=\"speed\" value={model.speed} label={SPEED[model.speed]} />);\n  if (model.cost)\n    parts.push(<Meter key=\"cost\" kind=\"cost\" value={model.cost} label={COST[model.cost]} />);\n  const caps = model.capabilities ?? [];\n  if (parts.length === 0 && caps.length === 0) return null;\n  return (\n    <div\n      data-slot=\"model-picker-meta\"\n      className=\"flex items-center gap-2 font-kura-mono text-[11px] text-kura-fg-subtle tabular-nums leading-3\"\n    >\n      {parts.map((p, i) => (\n        <React.Fragment key={i}>\n          {i > 0 && (\n            <span aria-hidden className=\"text-kura-border-strong\">\n              ·\n            </span>\n          )}\n          {p}\n        </React.Fragment>\n      ))}\n      {caps.length > 0 && (\n        <span className=\"ml-auto flex items-center gap-1.5 text-kura-fg-subtle\">\n          {caps.map((c) => {\n            const { Icon, label } = CAPABILITIES[c];\n            return (\n              <span key={c} title={label} data-capability={c} className=\"inline-flex\">\n                <Icon className=\"size-3\" aria-hidden />\n                <span className=\"sr-only\">{label}</span>\n              </span>\n            );\n          })}\n        </span>\n      )}\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "particle-field",
      "name": "ParticleField",
      "category": "effects",
      "status": "beta",
      "description": "Drifting particle constellation that parts around the pointer and can converge into a ring or point to signal “generating”.",
      "whenToUse": [
        "An empty state or hero for a knowledge/graph/agent product where a quiet network motif fits.",
        "Signalling that something is being generated: flip `converge` while the model works, release when done.",
        "A subtle, interactive backdrop behind a short prompt or call to action."
      ],
      "whenNotToUse": [
        "Soft colour backgrounds — use mesh-gradient or aurora.",
        "Inline progress feedback — use thinking-indicator or shimmer-text.",
        "A voice agent's presence — use agent-orb.",
        "Behind dense content: lines and motion compete with text."
      ],
      "props": [
        {
          "name": "count",
          "type": "number",
          "description": "Number of particles. Default scales with the area (≈ one per 5,500 px², clamped 30–160). Links are O(n²); stay under ~180."
        },
        {
          "name": "linkDistance",
          "type": "number",
          "default": "110",
          "description": "Max px distance at which two particles are joined by a hairline."
        },
        {
          "name": "speed",
          "type": "number",
          "default": "1",
          "description": "Drift speed multiplier."
        },
        {
          "name": "interactive",
          "type": "boolean",
          "default": "true",
          "description": "Particles part around the pointer and link to it in the accent colour."
        },
        {
          "name": "converge",
          "type": "boolean",
          "default": "false",
          "description": "Gather the particles into `shape` in the accent colour (the generating signal). Set false to release them back into a drift."
        },
        {
          "name": "shape",
          "type": "\"ring\" | \"point\"",
          "default": "\"ring\"",
          "description": "ring: a slowly turning halo. point: a tight orbiting cluster."
        },
        {
          "name": "paused",
          "type": "boolean",
          "default": "false",
          "description": "Freeze the field on the current frame."
        },
        {
          "name": "maxDpr",
          "type": "number",
          "default": "2",
          "description": "Device-pixel-ratio cap."
        },
        {
          "name": "children",
          "type": "React.ReactNode",
          "description": "Content rendered on top, in normal flow."
        }
      ],
      "parts": [
        {
          "name": "ParticleField",
          "description": "Root (`relative isolate overflow-hidden text-kura-fg-subtle`). `data-state` = drifting | converging | still | fallback; `data-converge` mirrors the prop.",
          "dataSlot": "particle-field"
        },
        {
          "name": "Canvas",
          "description": "The 2D canvas behind the children (aria-hidden).",
          "dataSlot": "particle-field-canvas"
        },
        {
          "name": "Fallback",
          "description": "A static CSS dot grid shown when canvas 2D is unavailable.",
          "dataSlot": "particle-field-fallback"
        }
      ],
      "a11y": {
        "notes": [
          "Decorative: the canvas is aria-hidden and never takes pointer events from children.",
          "Converging is a visual signal only — pair it with text in a role=status region (e.g. “Generating…”)."
        ],
        "keyboard": [],
        "reducedMotion": "No drift, travel or pointer reaction: one still frame. Toggling `converge` jumps straight to the gathered (or scattered) layout without animating."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "mesh-gradient",
        "aurora",
        "thinking-indicator",
        "agent-orb",
        "grain"
      ],
      "tags": [
        "particles",
        "constellation",
        "network",
        "canvas",
        "background",
        "generating",
        "interactive"
      ],
      "examples": [
        {
          "name": "ParticleFieldMinimal",
          "title": "Drifting constellation",
          "description": "Size it and give it a background; particles use the text colour.",
          "kind": "minimal",
          "code": "import { ParticleField } from \"@kura-ui/react/particle-field\";\n\nexport function ParticleFieldMinimal() {\n  return (\n    <ParticleField className=\"h-64 w-full rounded-kura-xl bg-kura-surface-sunken shadow-kura-sm\" />\n  );\n}\n"
        },
        {
          "name": "ParticleFieldFull",
          "title": "Converge, shapes and pause",
          "description": "Toggle converge, switch ring/point, tune link distance and speed.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ParticleField, type ParticleFieldShape } from \"@kura-ui/react/particle-field\";\n\nexport function ParticleFieldFull() {\n  const [converge, setConverge] = React.useState(false);\n  const [shape, setShape] = React.useState<ParticleFieldShape>(\"ring\");\n  const [paused, setPaused] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-2xl flex-col gap-4\">\n      <ParticleField\n        converge={converge}\n        shape={shape}\n        paused={paused}\n        linkDistance={120}\n        speed={1.2}\n        className=\"aspect-[16/9] w-full rounded-kura-xl bg-kura-surface-sunken text-kura-fg-muted shadow-kura-md\"\n      />\n      <div className=\"flex flex-wrap items-center gap-1.5\">\n        <Button\n          size=\"sm\"\n          variant={converge ? \"accent\" : \"secondary\"}\n          aria-pressed={converge}\n          onClick={() => setConverge((c) => !c)}\n        >\n          {converge ? \"Release\" : \"Converge\"}\n        </Button>\n        {([\"ring\", \"point\"] as const).map((s) => (\n          <Button\n            key={s}\n            size=\"sm\"\n            variant={s === shape ? \"secondary\" : \"ghost\"}\n            aria-pressed={s === shape}\n            onClick={() => setShape(s)}\n            className=\"capitalize\"\n          >\n            {s}\n          </Button>\n        ))}\n        <Button size=\"sm\" variant=\"ghost\" className=\"ml-auto\" onClick={() => setPaused((p) => !p)}>\n          {paused ? \"Play\" : \"Pause\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ParticleFieldGenerating",
          "title": "Generating state",
          "description": "A Button starts a 4s generation: particles gather into a ring while ShimmerText reports progress.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\nimport { ParticleField } from \"@kura-ui/react/particle-field\";\n\nexport function ParticleFieldGenerating() {\n  const [generating, setGenerating] = React.useState(false);\n  React.useEffect(() => {\n    if (!generating) return;\n    const id = setTimeout(() => setGenerating(false), 4000);\n    return () => clearTimeout(id);\n  }, [generating]);\n  return (\n    <ParticleField\n      converge={generating}\n      className=\"flex h-80 w-full flex-col items-center justify-center gap-3 rounded-kura-2xl bg-kura-surface text-center shadow-kura-md\"\n    >\n      <div role=\"status\" aria-live=\"polite\" className=\"h-6 text-sm\">\n        {generating ? (\n          <ShimmerText>Generating your workspace…</ShimmerText>\n        ) : (\n          <span className=\"text-kura-fg-muted\">Describe a workspace and Kura sets it up.</span>\n        )}\n      </div>\n      <Button onClick={() => setGenerating(true)} disabled={generating}>\n        Generate\n      </Button>\n    </ParticleField>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Particle and link colour = the root's text colour; recolour with a class (`text-kura-fg-muted`). Converged particles and pointer links use `--kura-accent`.",
        "Give the root a height and a background; the canvas is transparent.",
        "Use `converge` for indeterminate generation (seconds, not milliseconds) and release it when output appears.",
        "It pauses automatically offscreen; keep one field per view."
      ],
      "title": "Particle Field",
      "importPath": "@kura-ui/react/particle-field",
      "exports": [
        "ParticleField",
        "ParticleFieldProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/particle-field",
      "markdownUrl": "https://kuro.abstergo.fyi/components/particle-field.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/particle-field.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/particle-field.json",
        "cli": "npx @kura-ui/cli add particle-field",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "count": {
            "description": "Number of particles. Default scales with the area (≈ one per 5,500 px², clamped 30–160). Links are O(n²); stay under ~180.",
            "x-ts-type": "number",
            "type": "number"
          },
          "linkDistance": {
            "description": "Max px distance at which two particles are joined by a hairline.",
            "x-ts-type": "number",
            "type": "number",
            "default": 110
          },
          "speed": {
            "description": "Drift speed multiplier.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "interactive": {
            "description": "Particles part around the pointer and link to it in the accent colour.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "converge": {
            "description": "Gather the particles into `shape` in the accent colour (the generating signal). Set false to release them back into a drift.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "shape": {
            "description": "ring: a slowly turning halo. point: a tight orbiting cluster.",
            "x-ts-type": "\"ring\" | \"point\"",
            "type": "string",
            "enum": [
              "point",
              "ring"
            ],
            "default": "ring"
          },
          "paused": {
            "description": "Freeze the field on the current frame.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "maxDpr": {
            "description": "Device-pixel-ratio cap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          },
          "children": {
            "description": "Content rendered on top, in normal flow.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/particle-field/index.ts",
          "type": "registry:ui",
          "content": "export { ParticleField, type ParticleFieldProps } from \"./particle-field\";\n"
        },
        {
          "target": "components/kura/particle-field/particle-field.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type ParticleFieldShape = \"ring\" | \"point\";\n\nexport interface ParticleFieldProps extends React.ComponentProps<\"div\"> {\n  /** Number of particles. Default: scales with the area (about one per 5,500 px², 30–160). */\n  count?: number;\n  /** Max distance in px at which two particles are linked by a line. Default 110. */\n  linkDistance?: number;\n  /** Drift speed multiplier. Default 1. */\n  speed?: number;\n  /** Particles gently part around the pointer and link to it. Default true. */\n  interactive?: boolean;\n  /** Gather the particles into `shape` — the “generating” signal. Release to let them drift. Default false. */\n  converge?: boolean;\n  /** What the particles gather into while converging. Default \"ring\". */\n  shape?: ParticleFieldShape;\n  /** Freeze the field. Default false. */\n  paused?: boolean;\n  /** Device-pixel-ratio cap. Default 2. */\n  maxDpr?: number;\n}\n\ninterface Particle {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  seed: number;\n}\n\nfunction rand(seed: number) {\n  const s = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;\n  return s - Math.floor(s);\n}\n\n/** Resolve a CSS colour (tokens included) to a string a canvas accepts. */\nfunction cssColor(host: Element, value: string): string {\n  const probe = document.createElement(\"span\");\n  probe.style.cssText = \"position:absolute;width:0;height:0;visibility:hidden;\";\n  probe.style.color = value;\n  host.appendChild(probe);\n  const c = getComputedStyle(probe).color;\n  probe.remove();\n  return c || \"gray\";\n}\n\n/**\n * A drifting constellation: particles wander, link to their neighbours with hairlines and part\n * around the pointer. Set `converge` and they gather into a slowly turning ring (or a point) in\n * the accent colour — a calm “generating” signal. Canvas 2D, DPR-capped, paused offscreen, a\n * single still frame under reduced motion. Particles use the root's text colour.\n */\nexport function ParticleField({\n  count,\n  linkDistance = 110,\n  speed = 1,\n  interactive = true,\n  converge = false,\n  shape = \"ring\",\n  paused = false,\n  maxDpr = 2,\n  className,\n  children,\n  ref,\n  ...props\n}: ParticleFieldProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const reducedMotion = useReducedMotion();\n  const [failed, setFailed] = React.useState(false);\n  const live = React.useRef({ count, linkDistance, speed, interactive, converge, shape, paused });\n  live.current = { count, linkDistance, speed, interactive, converge, shape, paused };\n  const kick = React.useRef<() => void>(() => {});\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const root = rootRef.current;\n    if (!canvas || !root) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) {\n      setFailed(true);\n      return;\n    }\n    setFailed(false);\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    const particles: Particle[] = [];\n    let fg = \"gray\";\n    let accent = \"teal\";\n    let amount = live.current.converge ? 1 : 0; // eased converge 0–1\n    let spin = 0;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    const pointer = { x: -1e4, y: -1e4, on: false };\n\n    const readColors = () => {\n      fg = getComputedStyle(root).color || \"gray\";\n      accent = cssColor(root, \"var(--kura-accent)\");\n    };\n    readColors();\n\n    // Density is area-based so small cards stay airy and heroes stay full.\n    const seedParticles = () => {\n      const target = live.current.count ?? Math.round(Math.min(160, Math.max(30, (w * h) / 5500)));\n      while (particles.length < target) {\n        const i = particles.length;\n        const a = rand(i + 0.1) * Math.PI * 2;\n        particles.push({\n          x: rand(i + 0.3) * w,\n          y: rand(i + 0.7) * h,\n          vx: Math.cos(a) * 0.25,\n          vy: Math.sin(a) * 0.25,\n          r: 0.8 + rand(i + 0.9) * 1.2,\n          seed: rand(i + 1.3),\n        });\n      }\n      particles.length = target;\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      dpr = Math.min(window.devicePixelRatio || 1, maxDpr);\n      w = Math.max(1, rect.width);\n      h = Math.max(1, rect.height);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      seedParticles();\n    };\n    resize();\n\n    const targetOf = (p: Particle, i: number) => {\n      const cx = w / 2;\n      const cy = h / 2;\n      const n = particles.length;\n      if (live.current.shape === \"point\") {\n        const a = p.seed * Math.PI * 2 + spin * (1.5 + p.seed);\n        const rad = 4 + (i % 9) * 2.2;\n        return { x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad };\n      }\n      const R = Math.min(w, h) * 0.24;\n      const a = (i / n) * Math.PI * 2 + spin;\n      const wob = 1 + 0.06 * Math.sin(i * 1.7 + spin * 3);\n      return { x: cx + Math.cos(a) * R * wob, y: cy + Math.sin(a) * R * wob };\n    };\n\n    const step = (dt: number) => {\n      const { speed: s, interactive: inter, converge: conv } = live.current;\n      amount += ((conv ? 1 : 0) - amount) * (1 - Math.exp(-dt * 2.2));\n      spin += dt * 0.35 * s;\n      const k = dt * 60;\n      particles.forEach((p, i) => {\n        if (amount > 0.001) {\n          const t = targetOf(p, i);\n          const pull = 0.02 * amount;\n          p.vx += (t.x - p.x) * pull * k;\n          p.vy += (t.y - p.y) * pull * k;\n          p.vx *= 1 - 0.12 * amount;\n          p.vy *= 1 - 0.12 * amount;\n        }\n        // Wander: a slow, per-particle turning drift.\n        const ang = p.seed * 20 + spin * (0.6 + p.seed);\n        p.vx += Math.cos(ang) * 0.004 * k * (1 - amount);\n        p.vy += Math.sin(ang) * 0.004 * k * (1 - amount);\n        if (inter && pointer.on) {\n          const dx = p.x - pointer.x;\n          const dy = p.y - pointer.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < 120 * 120 && d2 > 1) {\n            const f = (1 - Math.sqrt(d2) / 120) * 0.12 * (1 - amount * 0.7);\n            p.vx += (dx / Math.sqrt(d2)) * f * k;\n            p.vy += (dy / Math.sqrt(d2)) * f * k;\n          }\n        }\n        const v = Math.hypot(p.vx, p.vy);\n        const max = (0.35 + amount * 3) * s;\n        if (v > max) {\n          p.vx = (p.vx / v) * max;\n          p.vy = (p.vy / v) * max;\n        }\n        p.x += p.vx * k * s;\n        p.y += p.vy * k * s;\n        if (amount < 0.5) {\n          if (p.x < -10) p.x = w + 10;\n          if (p.x > w + 10) p.x = -10;\n          if (p.y < -10) p.y = h + 10;\n          if (p.y > h + 10) p.y = -10;\n        }\n      });\n    };\n\n    const draw = () => {\n      const link = live.current.linkDistance;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineWidth = 0.75;\n      const n = particles.length;\n      for (let pass = 0; pass < 2; pass++) {\n        const weight = pass === 0 ? 1 - amount : amount;\n        if (weight < 0.01) continue;\n        ctx.strokeStyle = pass === 0 ? fg : accent;\n        for (let i = 0; i < n; i++) {\n          const a = particles[i]!;\n          for (let j = i + 1; j < n; j++) {\n            const b = particles[j]!;\n            const dx = a.x - b.x;\n            const dy = a.y - b.y;\n            const d2 = dx * dx + dy * dy;\n            if (d2 > link * link) continue;\n            const t = 1 - Math.sqrt(d2) / link;\n            ctx.globalAlpha = t * t * 0.6 * weight;\n            ctx.beginPath();\n            ctx.moveTo(a.x, a.y);\n            ctx.lineTo(b.x, b.y);\n            ctx.stroke();\n          }\n          if (pass === 0 && live.current.interactive && pointer.on) {\n            const dx = a.x - pointer.x;\n            const dy = a.y - pointer.y;\n            const d = Math.hypot(dx, dy);\n            if (d < link * 1.4) {\n              ctx.strokeStyle = accent;\n              ctx.globalAlpha = (1 - d / (link * 1.4)) ** 2 * 0.6;\n              ctx.beginPath();\n              ctx.moveTo(a.x, a.y);\n              ctx.lineTo(pointer.x, pointer.y);\n              ctx.stroke();\n              ctx.strokeStyle = fg;\n            }\n          }\n        }\n      }\n      for (const p of particles) {\n        ctx.globalAlpha = 0.55 + 0.45 * p.seed;\n        ctx.fillStyle = amount > 0.5 ? accent : fg;\n        ctx.beginPath();\n        ctx.arc(p.x, p.y, p.r * (1 + amount * 0.3), 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const still = () => {\n      // Reduced motion: jump straight to the end state, no travel.\n      amount = live.current.converge ? 1 : 0;\n      if (amount === 1) {\n        particles.forEach((p, i) => {\n          const t = targetOf(p, i);\n          p.x = t.x;\n          p.y = t.y;\n        });\n      }\n      draw();\n    };\n\n    const frame = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      step(dt);\n      draw();\n      raf = visible && !live.current.paused ? requestAnimationFrame(frame) : 0;\n    };\n\n    const isStill = () => window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false;\n    kick.current = () => {\n      if (isStill()) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        still();\n        return;\n      }\n      if (live.current.paused) {\n        draw();\n        return;\n      }\n      if (raf || !visible) return;\n      last = 0;\n      raf = requestAnimationFrame(frame);\n    };\n    kick.current();\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (!raf) kick.current();\n    });\n    ro.observe(root);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry?.isIntersecting ?? true;\n      if (visible) kick.current();\n    });\n    io.observe(root);\n    const mo = new MutationObserver(() => {\n      readColors();\n      if (!raf) kick.current();\n    });\n    const moOpts = { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] };\n    mo.observe(document.documentElement, moOpts);\n    mo.observe(root, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n\n    const onMove = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      pointer.x = e.clientX - r.left;\n      pointer.y = e.clientY - r.top;\n      pointer.on =\n        pointer.x >= 0 && pointer.y >= 0 && pointer.x <= r.width && pointer.y <= r.height;\n    };\n    window.addEventListener(\"pointermove\", onMove, { passive: true });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      window.removeEventListener(\"pointermove\", onMove);\n    };\n  }, [maxDpr]);\n\n  // Restart or redraw when behaviour changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: kick reads the latest props from `live`.\n  React.useEffect(() => {\n    kick.current();\n  }, [paused, converge, shape, reducedMotion, count]);\n\n  const state = failed\n    ? \"fallback\"\n    : reducedMotion || paused\n      ? \"still\"\n      : converge\n        ? \"converging\"\n        : \"drifting\";\n\n  return (\n    <div\n      ref={mergeRefs(rootRef, ref)}\n      data-slot=\"particle-field\"\n      data-state={state}\n      data-converge={converge}\n      className={cn(\"relative isolate overflow-hidden text-kura-fg-subtle\", className)}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        data-slot=\"particle-field-canvas\"\n        className={cn(\"-z-10 absolute inset-0 block size-full\", failed && \"hidden\")}\n      />\n      {failed && (\n        <div\n          aria-hidden\n          data-slot=\"particle-field-fallback\"\n          className=\"-z-10 absolute inset-0 bg-[radial-gradient(currentColor_1px,transparent_1.5px)] bg-size-[28px_28px] opacity-30\"\n        />\n      )}\n      {children}\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "popover",
      "name": "Popover",
      "category": "primitives",
      "status": "stable",
      "description": "Floating translucent panel anchored to a trigger, with focus management and origin-aware motion.",
      "whenToUse": [
        "Small interactive panels: settings for a composer, a model's details, a share menu, a filter form.",
        "Content anchored to a control that should not navigate away."
      ],
      "whenNotToUse": [
        "Plain hover labels — use tooltip.",
        "Choosing a model — use model-picker. Slash commands — use slash-command-menu. Global search — use command-palette.",
        "Blocking decisions — use a dialog or approval-prompt."
      ],
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "description": "Controlled open state."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "description": "Initial open state when uncontrolled."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the open state changes."
        },
        {
          "name": "modal",
          "type": "boolean",
          "default": "false",
          "description": "Trap focus and block outside interaction."
        }
      ],
      "parts": [
        {
          "name": "Popover",
          "description": "Root; owns open state.",
          "dataSlot": "popover"
        },
        {
          "name": "PopoverTrigger",
          "description": "Toggles the popover. Use asChild with a Button.",
          "dataSlot": "popover-trigger"
        },
        {
          "name": "PopoverAnchor",
          "description": "Optional alternative positioning anchor.",
          "dataSlot": "popover-anchor"
        },
        {
          "name": "PopoverContent",
          "description": "The floating panel (portalled). Accepts side, align, sideOffset.",
          "dataSlot": "popover-content",
          "props": [
            {
              "name": "inline",
              "type": "boolean",
              "default": "false",
              "description": "Render without a portal (inside dialogs)."
            }
          ]
        },
        {
          "name": "PopoverClose",
          "description": "Closes the popover. Use asChild with a Button.",
          "dataSlot": "popover-close"
        }
      ],
      "a11y": {
        "role": "dialog",
        "notes": [
          "Trigger gets aria-expanded and aria-controls; focus moves into the content on open and returns on close.",
          "Give the content an accessible name (aria-label or a heading with aria-labelledby)."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Toggle from the trigger"
          },
          {
            "keys": "Escape",
            "action": "Close and return focus"
          },
          {
            "keys": "Tab",
            "action": "Move through content"
          }
        ],
        "reducedMotion": "Scale and travel are removed; content fades."
      },
      "dependencies": {
        "npm": [
          "radix-ui"
        ],
        "kura": []
      },
      "related": [
        "tooltip",
        "model-picker",
        "slash-command-menu"
      ],
      "tags": [
        "popover",
        "overlay",
        "floating",
        "panel",
        "menu",
        "dropdown"
      ],
      "examples": [
        {
          "name": "PopoverMinimal",
          "title": "Basic",
          "kind": "minimal",
          "code": "import { Button } from \"@kura-ui/react/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@kura-ui/react/popover\";\n\nexport function PopoverMinimal() {\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button variant=\"secondary\">Open</Button>\n      </PopoverTrigger>\n      <PopoverContent aria-label=\"About\">Popovers hold small, interactive panels.</PopoverContent>\n    </Popover>\n  );\n}\n"
        },
        {
          "name": "PopoverFull",
          "title": "Controlled settings panel",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Settings2 } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Popover, PopoverClose, PopoverContent, PopoverTrigger } from \"@kura-ui/react/popover\";\n\nexport function PopoverFull() {\n  const [open, setOpen] = React.useState(false);\n  const [temperature, setTemperature] = React.useState(0.7);\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button variant=\"secondary\">\n          <Settings2 /> Generation settings\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        side=\"bottom\"\n        align=\"start\"\n        aria-labelledby=\"gen-settings-title\"\n        className=\"w-64\"\n      >\n        <div className=\"flex flex-col gap-3\">\n          <h3 id=\"gen-settings-title\" className=\"font-medium text-sm\">\n            Generation\n          </h3>\n          <label className=\"flex flex-col gap-1.5 text-kura-fg-muted text-xs\">\n            <span className=\"flex justify-between\">\n              Temperature{\" \"}\n              <span className=\"text-kura-fg tabular-nums\">{temperature.toFixed(1)}</span>\n            </span>\n            <input\n              type=\"range\"\n              min={0}\n              max={2}\n              step={0.1}\n              value={temperature}\n              onChange={(e) => setTemperature(Number(e.target.value))}\n              className=\"accent-kura-accent\"\n            />\n          </label>\n          <PopoverClose asChild>\n            <Button size=\"sm\">Done</Button>\n          </PopoverClose>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
        },
        {
          "name": "PopoverShareChat",
          "title": "Share a chat",
          "description": "A chat header whose Share button opens a visibility picker and a copy-link row.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Check, Globe, Link, Lock, Share } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@kura-ui/react/popover\";\n\nconst visibilityOptions = [\n  {\n    id: \"private\",\n    label: \"Only you\",\n    description: \"Nobody else can open this chat.\",\n    icon: <Lock />,\n  },\n  {\n    id: \"link\",\n    label: \"Anyone with the link\",\n    description: \"Can read the chat, not continue it.\",\n    icon: <Globe />,\n  },\n] as const;\n\nexport function PopoverShareChat() {\n  const [visibility, setVisibility] = React.useState<Visibility>(\"private\");\n  const [copied, setCopied] = React.useState(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n  const shared = visibility === \"link\";\n\n  return (\n    <div className=\"flex w-full max-w-xl items-center justify-between gap-4 rounded-kura-xl bg-kura-surface py-2.5 pr-2.5 pl-4 shadow-kura-sm\">\n      <div className=\"flex min-w-0 flex-col\">\n        <h3 className=\"truncate font-medium text-kura-fg text-sm\">Q3 churn analysis</h3>\n        <p className=\"truncate text-kura-fg-subtle text-xs\">\n          14 messages · {shared ? \"Shared with link\" : \"Private\"}\n        </p>\n      </div>\n      <Popover onOpenChange={(open) => !open && setCopied(false)}>\n        <PopoverTrigger asChild>\n          <Button variant=\"secondary\" size=\"sm\">\n            {shared ? <Globe /> : <Share />}\n            Share\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent align=\"end\" aria-labelledby=\"share-chat-title\" className=\"w-80 p-0\">\n          <div className=\"flex flex-col gap-3 p-3\">\n            <div className=\"flex flex-col gap-0.5 px-1 pt-0.5\">\n              <h4 id=\"share-chat-title\" className=\"font-medium text-sm\">\n                Share this chat\n              </h4>\n              <p className=\"text-kura-fg-muted text-xs\">\n                Messages you send after sharing stay private.\n              </p>\n            </div>\n            <fieldset className=\"flex flex-col gap-0.5\">\n              <legend className=\"sr-only\">Who can view</legend>\n              {visibilityOptions.map((option) => (\n                <label\n                  key={option.id}\n                  className=\"group flex cursor-pointer items-center gap-3 rounded-kura-md p-2 transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover has-checked:bg-kura-active has-focus-visible:shadow-kura-focus\"\n                >\n                  <input\n                    type=\"radio\"\n                    name=\"share-visibility\"\n                    value={option.id}\n                    checked={visibility === option.id}\n                    onChange={() => setVisibility(option.id)}\n                    className=\"sr-only\"\n                  />\n                  <span className=\"flex size-8 shrink-0 items-center justify-center rounded-kura-sm bg-kura-surface text-kura-fg-muted shadow-kura-xs group-has-checked:text-kura-fg [&_svg]:size-4\">\n                    {option.icon}\n                  </span>\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    <span className=\"font-medium text-[13px] text-kura-fg\">{option.label}</span>\n                    <span className=\"text-kura-fg-muted text-xs\">{option.description}</span>\n                  </span>\n                  <Check\n                    aria-hidden\n                    className=\"size-4 shrink-0 text-kura-fg opacity-0 transition-opacity duration-(--kura-duration-fast) group-has-checked:opacity-100\"\n                  />\n                </label>\n              ))}\n            </fieldset>\n          </div>\n          <div className=\"flex items-center gap-2 border-kura-border-subtle border-t p-3\">\n            <input\n              readOnly\n              aria-label=\"Share link\"\n              value={shared ? \"kura.chat/s/q3-churn-7f2a\" : \"Link sharing is off\"}\n              disabled={!shared}\n              onFocus={(e) => e.currentTarget.select()}\n              className=\"h-7 min-w-0 flex-1 rounded-kura-sm bg-kura-surface-sunken px-2 font-kura-mono text-kura-fg-muted text-xs shadow-kura-xs outline-none focus-visible:shadow-kura-focus disabled:font-kura-sans disabled:text-kura-fg-subtle\"\n            />\n            <Button\n              size=\"sm\"\n              disabled={!shared}\n              onClick={() => {\n                setCopied(true);\n                clearTimeout(timer.current);\n                timer.current = setTimeout(() => setCopied(false), 1600);\n              }}\n              className=\"w-26\"\n            >\n              {copied ? <Check /> : <Link />}\n              {copied ? \"Copied\" : \"Copy link\"}\n            </Button>\n          </div>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Always use `<PopoverTrigger asChild><Button …/></PopoverTrigger>` to avoid nested buttons."
      ],
      "title": "Popover",
      "importPath": "@kura-ui/react/popover",
      "exports": [
        "Popover",
        "PopoverAnchor",
        "PopoverAnchorProps",
        "PopoverClose",
        "PopoverCloseProps",
        "PopoverContent",
        "PopoverContentProps",
        "PopoverProps",
        "PopoverTrigger",
        "PopoverTriggerProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/popover",
      "markdownUrl": "https://kuro.abstergo.fyi/components/popover.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/popover.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/popover.json",
        "cli": "npx @kura-ui/cli add popover",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "open": {
            "description": "Controlled open state.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultOpen": {
            "description": "Initial open state when uncontrolled.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onOpenChange": {
            "description": "Called when the open state changes.",
            "x-ts-type": "(open: boolean) => void"
          },
          "modal": {
            "description": "Trap focus and block outside interaction.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/popover/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Popover,\n  PopoverAnchor,\n  type PopoverAnchorProps,\n  PopoverClose,\n  type PopoverCloseProps,\n  PopoverContent,\n  type PopoverContentProps,\n  type PopoverProps,\n  PopoverTrigger,\n  type PopoverTriggerProps,\n} from \"./popover\";\n"
        },
        {
          "target": "components/kura/popover/popover.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Popover as PopoverPrimitive } from \"radix-ui\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PopoverProps extends React.ComponentProps<typeof PopoverPrimitive.Root> {}\n\n/**\n * Root. Controlled via `open` + `onOpenChange` or uncontrolled via `defaultOpen`.\n * Compose with PopoverTrigger and PopoverContent.\n */\nexport function Popover(props: PopoverProps) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />;\n}\n\nexport interface PopoverTriggerProps\n  extends React.ComponentProps<typeof PopoverPrimitive.Trigger> {}\n\nexport function PopoverTrigger(props: PopoverTriggerProps) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\nexport interface PopoverAnchorProps extends React.ComponentProps<typeof PopoverPrimitive.Anchor> {}\n\nexport function PopoverAnchor(props: PopoverAnchorProps) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />;\n}\n\nexport interface PopoverCloseProps extends React.ComponentProps<typeof PopoverPrimitive.Close> {}\n\nexport function PopoverClose(props: PopoverCloseProps) {\n  return <PopoverPrimitive.Close data-slot=\"popover-close\" {...props} />;\n}\n\nexport interface PopoverContentProps extends React.ComponentProps<typeof PopoverPrimitive.Content> {\n  /** Render without a portal (e.g. inside a dialog that traps focus). Default false. */\n  inline?: boolean;\n}\n\n/**\n * Floating surface. Translucent overlay with a hairline ring, origin-aware scale+fade.\n * Focus moves inside on open and returns to the trigger on close.\n */\nexport function PopoverContent({\n  className,\n  align = \"center\",\n  sideOffset = 8,\n  collisionPadding = 8,\n  inline = false,\n  ...props\n}: PopoverContentProps) {\n  const content = (\n    <PopoverPrimitive.Content\n      data-slot=\"popover-content\"\n      align={align}\n      sideOffset={sideOffset}\n      collisionPadding={collisionPadding}\n      className={cn(\n        \"z-50 w-72 max-w-[calc(100vw-16px)] rounded-kura-lg bg-kura-overlay p-3 font-kura-sans text-kura-fg text-sm shadow-kura-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n        \"origin-(--radix-popover-content-transform-origin)\",\n        \"data-[state=open]:animate-kura-pop-in data-[state=closed]:animate-kura-pop-out\",\n        className,\n      )}\n      {...props}\n    />\n  );\n  return inline ? content : <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>;\n}\n"
        }
      ]
    },
    {
      "slug": "prompt-suggestions",
      "name": "PromptSuggestions",
      "category": "input",
      "status": "beta",
      "description": "Starter prompts for empty chat states — chips, cards or a list that rise in with a short stagger and fill the composer when clicked.",
      "whenToUse": [
        "The empty state of a chat or agent: show 3–6 example prompts the user can start from.",
        "Follow-up suggestions under an answer (“Make it shorter”, “Add sources”) using the chips variant.",
        "Onboarding that teaches what the assistant can do, with icons and one-line descriptions."
      ],
      "whenNotToUse": [
        "Commands typed inline in the prompt — use slash-command-menu.",
        "Global actions and navigation — use command-palette.",
        "More than ~8 items or anything that needs search — use command-palette or a list."
      ],
      "props": [
        {
          "name": "suggestions",
          "type": "readonly PromptSuggestion[]",
          "required": true,
          "description": "Items: { id?, title, description?, icon?, prompt? }. `prompt` defaults to title + description."
        },
        {
          "name": "onSelect",
          "type": "(prompt: string, suggestion: PromptSuggestion) => void",
          "description": "Called with the prompt text on click — typically sets the composer value."
        },
        {
          "name": "variant",
          "type": "\"chips\" | \"cards\" | \"list\"",
          "default": "\"cards\"",
          "description": "chips = wrapping pills (titles only), cards = two-column tiles with icon and description, list = hairline-separated rows."
        },
        {
          "name": "stagger",
          "type": "number",
          "default": "0.04",
          "description": "Seconds between items entering (30–50ms reads best). Capped after 8 items."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Suggestions\"",
          "description": "Accessible name of the list."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "default": "false",
          "description": "Disable every suggestion."
        }
      ],
      "parts": [
        {
          "name": "PromptSuggestions",
          "description": "<ul> root; data-variant = chips | cards | list.",
          "dataSlot": "prompt-suggestions"
        },
        {
          "name": "PromptSuggestionsItem",
          "description": "Each suggestion is a native <button>; an arrow fades in on hover/focus (cards, list).",
          "dataSlot": "prompt-suggestions-item"
        },
        {
          "name": "PromptSuggestionsIcon",
          "description": "Optional icon; a 28px tile in cards.",
          "dataSlot": "prompt-suggestions-icon"
        }
      ],
      "a11y": {
        "notes": [
          "A labelled <ul> of native buttons; each button's name is its title (plus description).",
          "Icons and the hover arrow are aria-hidden.",
          "After filling a composer, move focus to its textarea so the user can edit and send (see the composition example)."
        ],
        "keyboard": [
          {
            "keys": "Tab / Shift+Tab",
            "action": "Move between suggestions"
          },
          {
            "keys": "Enter / Space",
            "action": "Select a suggestion"
          }
        ],
        "reducedMotion": "Items fade in together-ish without rising or blurring; hover arrows and press scales do not move."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "motion"
        ],
        "kura": []
      },
      "related": [
        "composer",
        "slash-command-menu",
        "command-palette"
      ],
      "tags": [
        "suggestions",
        "starters",
        "empty-state",
        "prompts",
        "chips",
        "onboarding",
        "stagger"
      ],
      "examples": [
        {
          "name": "PromptSuggestionsMinimal",
          "title": "Chips",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport { PromptSuggestions } from \"@kura-ui/react/prompt-suggestions\";\n\nexport function PromptSuggestionsMinimal() {\n  return (\n    <PromptSuggestions\n      variant=\"chips\"\n      suggestions={[\n        { title: \"Summarize a PDF\" },\n        { title: \"Write a SQL query\" },\n        { title: \"Plan a sprint\" },\n        { title: \"Explain a regex\" },\n      ]}\n      onSelect={(prompt) => console.log(prompt)}\n    />\n  );\n}\n"
        },
        {
          "name": "PromptSuggestionsFull",
          "title": "Cards with icons and refresh",
          "description": "Swap the set to see items leave and stagger back in.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { BookOpen, Bug, Calendar, ChartLine, Mail, Plane, RefreshCw, Scale, Telescope } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { type PromptSuggestion, PromptSuggestions } from \"@kura-ui/react/prompt-suggestions\";\n\nconst sets: PromptSuggestion[][] = [\n  [\n    {\n      id: \"trip\",\n      title: \"Plan a trip\",\n      description: \"Four slow days in Kyoto in early April\",\n      icon: <Plane />,\n    },\n    {\n      id: \"metrics\",\n      title: \"Analyze metrics\",\n      description: \"Why did weekly actives dip after the release?\",\n      icon: <ChartLine />,\n    },\n    {\n      id: \"email\",\n      title: \"Draft an email\",\n      description: \"Decline a meeting without closing the door\",\n      icon: <Mail />,\n    },\n    {\n      id: \"bug\",\n      title: \"Debug a crash\",\n      description: \"TypeError: cannot read properties of undefined\",\n      icon: <Bug />,\n    },\n  ],\n  [\n    {\n      id: \"learn\",\n      title: \"Teach me\",\n      description: \"How transformers attend, with a tiny example\",\n      icon: <BookOpen />,\n    },\n    {\n      id: \"compare\",\n      title: \"Compare options\",\n      description: \"Postgres vs. SQLite for a local-first app\",\n      icon: <Scale />,\n    },\n    {\n      id: \"research\",\n      title: \"Research\",\n      description: \"What changed in the EU AI Act this year\",\n      icon: <Telescope />,\n    },\n    {\n      id: \"week\",\n      title: \"Plan my week\",\n      description: \"Fit deep work around six meetings\",\n      icon: <Calendar />,\n    },\n  ],\n];\n\nexport function PromptSuggestionsFull() {\n  const [set, setSet] = React.useState(0);\n  const [picked, setPicked] = React.useState<string | null>(null);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-3\">\n      <PromptSuggestions\n        variant=\"cards\"\n        label=\"Try one of these\"\n        stagger={0.05}\n        suggestions={sets[set] ?? []}\n        onSelect={(prompt) => setPicked(prompt)}\n      />\n      <div className=\"flex items-center justify-between gap-3 px-1\">\n        <p className=\"min-w-0 truncate text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n          {picked ? `Prompt: ${picked}` : \"Pick a card\"}\n        </p>\n        <Button variant=\"ghost\" size=\"xs\" onClick={() => setSet((s) => (s + 1) % sets.length)}>\n          <RefreshCw />\n          More ideas\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "PromptSuggestionsEmptyChat",
          "title": "Empty chat filling a composer",
          "description": "Selecting a row fills the composer and focuses it for editing.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { ChartLine, FlaskConical, Mail } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  Composer,\n  ComposerActions,\n  ComposerAttachButton,\n  ComposerInput,\n  ComposerSubmit,\n  ComposerToolbar,\n  ComposerTools,\n} from \"@kura-ui/react/composer\";\nimport { type PromptSuggestion, PromptSuggestions } from \"@kura-ui/react/prompt-suggestions\";\n\nconst starters: PromptSuggestion[] = [\n  { title: \"Summarize this week's customer feedback\", icon: <ChartLine /> },\n  { title: \"Write tests for the billing service\", icon: <FlaskConical /> },\n  { title: \"Draft a launch announcement\", icon: <Mail /> },\n];\n\nexport function PromptSuggestionsEmptyChat() {\n  const [value, setValue] = React.useState(\"\");\n  const input = React.useRef<HTMLTextAreaElement>(null);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-5\">\n      <p className=\"text-balance text-center font-medium text-2xl text-kura-fg tracking-tight\">\n        What should we work on?\n      </p>\n      <Composer value={value} onValueChange={setValue} onSubmit={() => {}}>\n        <ComposerInput ref={input} placeholder=\"Ask anything…\" />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <PromptSuggestions\n        variant=\"list\"\n        suggestions={starters}\n        onSelect={(prompt) => {\n          setValue(prompt);\n          input.current?.focus();\n        }}\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Give items stable `id`s; changing the array with new ids animates the swap (old items fade out, new ones stagger in).",
        "Keep titles under ~6 words; put detail in `description` (cards/list only — chips show the title).",
        "To fill a Composer: control its `value`, call setValue(prompt) in onSelect, then focus the ComposerInput ref.",
        "Hide the suggestions once the conversation has messages; they are an empty-state affordance."
      ],
      "title": "Prompt Suggestions",
      "importPath": "@kura-ui/react/prompt-suggestions",
      "exports": [
        "PromptSuggestion",
        "PromptSuggestions",
        "PromptSuggestionsProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/prompt-suggestions",
      "markdownUrl": "https://kuro.abstergo.fyi/components/prompt-suggestions.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/prompt-suggestions.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/prompt-suggestions.json",
        "cli": "npx @kura-ui/cli add prompt-suggestions",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-motion",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "suggestions": {
            "description": "Items: { id?, title, description?, icon?, prompt? }. `prompt` defaults to title + description.",
            "x-ts-type": "readonly PromptSuggestion[]",
            "type": "array"
          },
          "onSelect": {
            "description": "Called with the prompt text on click — typically sets the composer value.",
            "x-ts-type": "(prompt: string, suggestion: PromptSuggestion) => void"
          },
          "variant": {
            "description": "chips = wrapping pills (titles only), cards = two-column tiles with icon and description, list = hairline-separated rows.",
            "x-ts-type": "\"chips\" | \"cards\" | \"list\"",
            "type": "string",
            "enum": [
              "cards",
              "chips",
              "list"
            ],
            "default": "cards"
          },
          "stagger": {
            "description": "Seconds between items entering (30–50ms reads best). Capped after 8 items.",
            "x-ts-type": "number",
            "type": "number",
            "default": 0.04
          },
          "label": {
            "description": "Accessible name of the list.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Suggestions"
          },
          "disabled": {
            "description": "Disable every suggestion.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [
          "suggestions"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/prompt-suggestions/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type PromptSuggestion,\n  PromptSuggestions,\n  type PromptSuggestionsProps,\n} from \"./prompt-suggestions\";\n"
        },
        {
          "target": "components/kura/prompt-suggestions/prompt-suggestions.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ArrowUpRight } from \"lucide-react\";\nimport { AnimatePresence, motion, type Variants } from \"motion/react\";\nimport type * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { springs } from \"@/lib/kura/motion\";\n\nexport interface PromptSuggestion {\n  /** Stable id; defaults to the title. Changing ids re-runs the enter animation for that item. */\n  id?: string;\n  /** Short label, e.g. \"Plan a trip\". */\n  title: string;\n  /** Secondary line (cards and list only), e.g. \"to Kyoto in April\". */\n  description?: string;\n  /** 16px icon. */\n  icon?: React.ReactNode;\n  /** Text passed to `onSelect`. Defaults to title + \" \" + description. */\n  prompt?: string;\n}\n\nexport interface PromptSuggestionsProps\n  extends Omit<React.ComponentProps<\"ul\">, \"children\" | \"onSelect\"> {\n  suggestions: readonly PromptSuggestion[];\n  /** Called with the prompt text when a suggestion is clicked. */\n  onSelect?: (prompt: string, suggestion: PromptSuggestion) => void;\n  /** `chips` = pills in a wrapping row, `cards` = two-column tiles, `list` = rows. Default \"cards\". */\n  variant?: \"chips\" | \"cards\" | \"list\";\n  /** Seconds between items entering. Default 0.04. Capped after 8 items. */\n  stagger?: number;\n  /** Accessible name. Default \"Suggestions\". */\n  label?: string;\n  disabled?: boolean;\n}\n\nconst promptOf = (s: PromptSuggestion) =>\n  s.prompt ?? [s.title, s.description].filter(Boolean).join(\" \");\n\n/**\n * Starter prompts for an empty chat: chips, cards or a list that rise in with a short stagger\n * and hand their prompt to `onSelect` (e.g. to fill a composer).\n */\nexport function PromptSuggestions({\n  suggestions,\n  onSelect,\n  variant = \"cards\",\n  stagger = 0.04,\n  label = \"Suggestions\",\n  disabled = false,\n  className,\n  ...props\n}: PromptSuggestionsProps) {\n  const reduced = useReducedMotion();\n\n  const item: Variants = reduced\n    ? { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: 0.2 } } }\n    : {\n        hidden: { opacity: 0, y: 6, filter: \"blur(2px)\" },\n        shown: (i: number) => ({\n          opacity: 1,\n          y: 0,\n          filter: \"blur(0px)\",\n          transition: { ...springs.smooth, delay: Math.min(i, 8) * stagger },\n        }),\n      };\n\n  return (\n    <ul\n      data-slot=\"prompt-suggestions\"\n      data-variant={variant}\n      aria-label={label}\n      className={cn(\n        \"font-kura-sans\",\n        variant === \"chips\" && \"flex flex-wrap justify-center gap-2\",\n        variant === \"cards\" && \"grid grid-cols-1 gap-2 sm:grid-cols-2\",\n        variant === \"list\" && \"flex flex-col\",\n        className,\n      )}\n      {...props}\n    >\n      <AnimatePresence initial mode=\"popLayout\">\n        {suggestions.map((s, i) => {\n          const key = s.id ?? s.title;\n          return (\n            <motion.li\n              key={key}\n              custom={i}\n              variants={item}\n              initial=\"hidden\"\n              animate=\"shown\"\n              exit={{ opacity: 0, transition: { duration: 0.09 } }}\n              className={cn(\n                \"flex\",\n                variant === \"list\" && \"not-last:shadow-[0_1px_0_var(--kura-border-subtle)]\",\n              )}\n            >\n              <button\n                type=\"button\"\n                data-slot=\"prompt-suggestions-item\"\n                disabled={disabled}\n                onClick={() => onSelect?.(promptOf(s), s)}\n                className={cn(\n                  \"group/suggestion relative flex w-full min-w-0 cursor-pointer items-center text-left outline-none\",\n                  \"transition-[background-color,color,box-shadow,transform] duration-(--kura-duration-fast) ease-kura-out\",\n                  \"focus-visible:shadow-kura-focus disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transform-none\",\n                  variant === \"chips\" &&\n                    \"h-8 gap-1.5 rounded-full bg-kura-surface px-3 text-[13px] text-kura-fg-muted shadow-kura-xs hover:not-disabled:bg-kura-surface-sunken hover:not-disabled:text-kura-fg active:not-disabled:scale-[0.97] [&_[data-slot=prompt-suggestions-icon]_svg]:size-3.5\",\n                  variant === \"cards\" &&\n                    \"flex-col items-start gap-2.5 rounded-kura-xl bg-kura-surface p-3.5 shadow-kura-sm hover:not-disabled:shadow-kura-md active:not-disabled:scale-[0.985]\",\n                  variant === \"list\" &&\n                    \"gap-3 rounded-kura-md px-2 py-2.5 text-kura-fg-muted text-sm hover:not-disabled:bg-kura-hover hover:not-disabled:text-kura-fg\",\n                )}\n              >\n                {s.icon && (\n                  <span\n                    data-slot=\"prompt-suggestions-icon\"\n                    aria-hidden\n                    className={cn(\n                      \"flex shrink-0 items-center justify-center text-kura-fg-subtle transition-colors duration-(--kura-duration-fast) group-hover/suggestion:text-kura-fg-muted\",\n                      variant === \"cards\" &&\n                        \"size-7 rounded-kura-md bg-kura-hover shadow-[inset_0_0_0_1px_var(--kura-border-subtle)] [&_svg]:size-3.5\",\n                      variant === \"list\" && \"[&_svg]:size-4\",\n                    )}\n                  >\n                    {s.icon}\n                  </span>\n                )}\n                {variant === \"chips\" ? (\n                  <span className=\"truncate\">{s.title}</span>\n                ) : (\n                  <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                    <span\n                      className={cn(\n                        \"truncate font-medium\",\n                        variant === \"cards\" ? \"text-kura-fg text-sm\" : \"text-inherit\",\n                      )}\n                    >\n                      {s.title}\n                    </span>\n                    {s.description && (\n                      <span\n                        className={cn(\n                          \"text-kura-fg-subtle\",\n                          variant === \"cards\"\n                            ? \"line-clamp-2 text-xs leading-4\"\n                            : \"truncate text-xs\",\n                        )}\n                      >\n                        {s.description}\n                      </span>\n                    )}\n                  </span>\n                )}\n                {variant !== \"chips\" && (\n                  <ArrowUpRight\n                    aria-hidden\n                    className={cn(\n                      \"size-3.5 shrink-0 text-kura-fg-subtle opacity-0 transition-[opacity,transform] duration-(--kura-duration-base) ease-kura-out\",\n                      \"-translate-x-0.5 translate-y-0.5 group-hover/suggestion:translate-x-0 group-hover/suggestion:translate-y-0 group-hover/suggestion:opacity-100 group-focus-visible/suggestion:opacity-100\",\n                      \"motion-reduce:translate-x-0 motion-reduce:translate-y-0\",\n                      variant === \"cards\" && \"absolute top-3.5 right-3.5\",\n                    )}\n                  />\n                )}\n              </button>\n            </motion.li>\n          );\n        })}\n      </AnimatePresence>\n    </ul>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "reasoning",
      "name": "Reasoning",
      "category": "agent",
      "status": "beta",
      "description": "Collapsible thinking trace: a shimmering “Thinking… 12s” label while active that settles into “Thought for 14s”, revealing muted reasoning text.",
      "whenToUse": [
        "Show a reasoning model's chain of thought / thinking blocks (Claude extended thinking, o-series reasoning summaries) above the answer.",
        "Indicate that the model is thinking, with elapsed time, before the first answer token arrives.",
        "Keep a finished trace available but out of the way (collapsed to one line)."
      ],
      "whenNotToUse": [
        "A bare “Thinking…” label with no trace to reveal — use shimmer-text (or thinking-indicator).",
        "A sequence of discrete agent steps with states — use agent-timeline.",
        "A single tool invocation with arguments and results — use tool-call."
      ],
      "props": [
        {
          "name": "active",
          "type": "boolean",
          "default": "false",
          "description": "The model is still thinking: the label shimmers and an elapsed timer ticks. Turning it false settles the label and (with autoCollapse) collapses the trace."
        },
        {
          "name": "duration",
          "type": "number",
          "description": "Seconds spent thinking, if you know it (e.g. stored with the message). Otherwise measured while `active`."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Expanded (controlled)."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "description": "Initial expanded state when uncontrolled. Defaults to `active`: open while thinking, closed for finished traces."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the trace is expanded/collapsed (by the reader or by auto-collapse)."
        },
        {
          "name": "autoCollapse",
          "type": "boolean",
          "default": "true",
          "description": "Collapse when `active` turns false — skipped if the reader toggled it while it was active."
        },
        {
          "name": "formatLabel",
          "type": "(state: { active: boolean; seconds: number }) => React.ReactNode",
          "description": "Custom label (i18n, “Planning…”). Default “Thinking… 12s” / “Thought for 14s” / “Thought for a moment”."
        }
      ],
      "parts": [
        {
          "name": "Reasoning",
          "description": "Root. `data-status=\"active\" | \"done\"`, `data-state=\"open\" | \"closed\"`.",
          "dataSlot": "reasoning"
        },
        {
          "name": "Trigger",
          "description": "Button with the label and a chevron that rotates 90° when open (aria-expanded, aria-controls).",
          "dataSlot": "reasoning-trigger"
        },
        {
          "name": "Label",
          "description": "ShimmerText; shimmers while active, muted when done. Seconds are tabular.",
          "dataSlot": "reasoning-label"
        },
        {
          "name": "Content",
          "description": "Muted, pre-wrapped trace behind a left hairline. Animates grid-template-rows 0fr↔1fr (interruptible) and is inert when closed.",
          "dataSlot": "reasoning-content"
        }
      ],
      "a11y": {
        "notes": [
          "The trigger is a real button with aria-expanded/aria-controls.",
          "A visually hidden status region says “Thinking” and then “Thought for 14s” — the ticking seconds are never announced.",
          "When collapsed, the trace is `inert`: out of the tab order and the accessibility tree."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Expand or collapse the trace"
          }
        ],
        "reducedMotion": "The shimmer becomes static muted text, the chevron snaps, and the trace only fades (no height travel or blur)."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "shimmer-text"
        ]
      },
      "related": [
        "shimmer-text",
        "thinking-indicator",
        "message",
        "streaming-text",
        "agent-timeline"
      ],
      "tags": [
        "reasoning",
        "thinking",
        "chain-of-thought",
        "collapsible",
        "trace",
        "llm",
        "timer"
      ],
      "examples": [
        {
          "name": "ReasoningMinimal",
          "title": "Finished trace",
          "description": "A collapsed “Thought for 14s” trace.",
          "kind": "minimal",
          "code": "import { Reasoning } from \"@kura-ui/react/reasoning\";\n\nconst TRACE =\n  \"The user wants the like button to feel instant. The lag comes from waiting on the server round-trip before re-rendering.\\n\\nOptions: optimistic local state with manual rollback, or useOptimistic inside a transition. useOptimistic handles the rollback automatically if the action throws, and keeps the UI consistent with concurrent updates.\\n\\nI should recommend useOptimistic and show the minimal pattern, then mention the error case.\";\n\nexport function ReasoningMinimal() {\n  return (\n    <Reasoning duration={14} className=\"w-full max-w-lg\">\n      {TRACE}\n    </Reasoning>\n  );\n}\n"
        },
        {
          "name": "ReasoningFull",
          "title": "Live thinking that auto-collapses",
          "description": "Streams the trace with StreamingText while active (controlled open), then collapses to “Thought for Ns”.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { StreamingText } from \"@kura-ui/react/streaming-text\";\nimport { Reasoning } from \"@kura-ui/react/reasoning\";\n\nconst TRACE =\n  \"The user wants the like button to feel instant. The lag comes from waiting on the server round-trip before re-rendering.\\n\\nOptions: optimistic local state with manual rollback, or useOptimistic inside a transition. useOptimistic handles the rollback automatically if the action throws, and keeps the UI consistent with concurrent updates.\\n\\nI should recommend useOptimistic and show the minimal pattern, then mention the error case.\";\n\nasync function* thoughts(text: string): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; i += 2) {\n    yield words.slice(i, i + 2).join(\"\");\n    await new Promise((r) => setTimeout(r, 70 + Math.random() * 60));\n  }\n}\n\nexport function ReasoningFull() {\n  const [run, setRun] = React.useState(0);\n  const [active, setActive] = React.useState(true);\n  const [open, setOpen] = React.useState(true);\n  const [stream, setStream] = React.useState(() => thoughts(TRACE));\n\n  const replay = () => {\n    setRun((r) => r + 1);\n    setStream(thoughts(TRACE));\n    setActive(true);\n    setOpen(true);\n  };\n\n  return (\n    <div className=\"flex w-full max-w-lg flex-col items-start gap-4\">\n      <Reasoning\n        key={run}\n        active={active}\n        open={open}\n        onOpenChange={setOpen}\n        autoCollapse\n        className=\"w-full\"\n      >\n        <StreamingText stream={stream} caret={false} onComplete={() => setActive(false)} />\n      </Reasoning>\n      <Button variant=\"secondary\" size=\"sm\" onClick={replay}>\n        <RotateCcw /> Think again\n      </Button>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ReasoningInMessage",
          "title": "Above an assistant answer",
          "description": "Inside a Message, with a custom label.",
          "kind": "composition",
          "code": "import * as React from \"react\";\nimport { Message, MessageAvatar } from \"@kura-ui/react/message\";\nimport { Reasoning } from \"@kura-ui/react/reasoning\";\n\nconst TRACE =\n  \"The user wants the like button to feel instant. The lag comes from waiting on the server round-trip before re-rendering.\\n\\nOptions: optimistic local state with manual rollback, or useOptimistic inside a transition. useOptimistic handles the rollback automatically if the action throws, and keeps the UI consistent with concurrent updates.\\n\\nI should recommend useOptimistic and show the minimal pattern, then mention the error case.\";\n\nexport function ReasoningInMessage() {\n  return (\n    <Message\n      from=\"assistant\"\n      name=\"Kura\"\n      avatar={<MessageAvatar name=\"Kura\" />}\n      className=\"max-w-xl\"\n    >\n      <Reasoning\n        duration={83}\n        formatLabel={({ active, seconds }) =>\n          active ? \"Planning…\" : `Planned for ${Math.round(seconds / 60)} min`\n        }\n      >\n        {TRACE}\n      </Reasoning>\n      <p>\n        Use <code>useOptimistic</code>: render the new count immediately, then let React reconcile\n        when the server action resolves. If it throws, the optimistic value is dropped for you.\n      </p>\n    </Message>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Keep the component mounted from the first thinking token to the end; flip `active` instead of swapping components, so the timer and collapse work.",
        "Persist the measured duration with the message and pass it as `duration` when re-rendering history.",
        "Put a StreamingText (caret={false}) inside for a live trace; plain text or markdown for a stored one."
      ],
      "title": "Reasoning",
      "importPath": "@kura-ui/react/reasoning",
      "exports": [
        "Reasoning",
        "ReasoningProps",
        "formatSeconds"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/reasoning",
      "markdownUrl": "https://kuro.abstergo.fyi/components/reasoning.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/reasoning.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/reasoning.json",
        "cli": "npx @kura-ui/cli add reasoning",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "shimmer-text",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "active": {
            "description": "The model is still thinking: the label shimmers and an elapsed timer ticks. Turning it false settles the label and (with autoCollapse) collapses the trace.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "duration": {
            "description": "Seconds spent thinking, if you know it (e.g. stored with the message). Otherwise measured while `active`.",
            "x-ts-type": "number",
            "type": "number"
          },
          "open": {
            "description": "Expanded (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultOpen": {
            "description": "Initial expanded state when uncontrolled. Defaults to `active`: open while thinking, closed for finished traces.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onOpenChange": {
            "description": "Called when the trace is expanded/collapsed (by the reader or by auto-collapse).",
            "x-ts-type": "(open: boolean) => void"
          },
          "autoCollapse": {
            "description": "Collapse when `active` turns false — skipped if the reader toggled it while it was active.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "formatLabel": {
            "description": "Custom label (i18n, “Planning…”). Default “Thinking… 12s” / “Thought for 14s” / “Thought for a moment”.",
            "x-ts-type": "(state: { active: boolean; seconds: number }) => React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/reasoning/index.ts",
          "type": "registry:ui",
          "content": "export { formatSeconds, Reasoning, type ReasoningProps } from \"./reasoning\";\n"
        },
        {
          "target": "components/kura/reasoning/reasoning.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport interface ReasoningProps extends React.ComponentProps<\"div\"> {\n  /** The model is still thinking: the label shimmers and an elapsed timer ticks. Default false. */\n  active?: boolean;\n  /** Seconds spent thinking, if you know it (e.g. from the API). Otherwise measured while `active`. */\n  duration?: number;\n  /** Expanded (controlled). */\n  open?: boolean;\n  /** Initial expanded state when uncontrolled. Default: `active` (open while thinking). */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Collapse automatically when `active` turns false — unless the reader toggled it meanwhile. Default true. */\n  autoCollapse?: boolean;\n  /** Custom label, e.g. for i18n. Default “Thinking… 12s” / “Thought for 14s”. */\n  formatLabel?: (state: { active: boolean; seconds: number }) => React.ReactNode;\n}\n\nexport function formatSeconds(seconds: number): string {\n  const s = Math.max(0, Math.round(seconds));\n  if (s < 60) return `${s}s`;\n  const m = Math.floor(s / 60);\n  return s % 60 ? `${m}m ${s % 60}s` : `${m}m`;\n}\n\n/**\n * Collapsible “thinking” trace. While `active` it shows a shimmering “Thinking… 12s” label and\n * the reasoning streaming in; when done it collapses to “Thought for 14s”. Expanding reveals the\n * muted trace with an interruptible height transition.\n */\nexport function Reasoning({\n  active = false,\n  duration,\n  open: openProp,\n  defaultOpen,\n  onOpenChange,\n  autoCollapse = true,\n  formatLabel,\n  className,\n  children,\n  ...props\n}: ReasoningProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen ?? active,\n    onChange: onOpenChange,\n  });\n\n  // Elapsed time: measured from when `active` became true.\n  const startedAt = React.useRef<number | null>(null);\n  const [elapsed, setElapsed] = React.useState(0);\n  const elapsedRef = React.useRef(elapsed);\n  elapsedRef.current = elapsed;\n  React.useEffect(() => {\n    if (!active) return;\n    // Resume from the time already spent (e.g. a paused-then-resumed agent).\n    startedAt.current = Date.now() - elapsedRef.current * 1000;\n    const id = setInterval(() => {\n      if (startedAt.current != null) setElapsed((Date.now() - startedAt.current) / 1000);\n    }, 250);\n    return () => {\n      clearInterval(id);\n      if (startedAt.current != null) setElapsed((Date.now() - startedAt.current) / 1000);\n    };\n  }, [active]);\n  const seconds = duration ?? elapsed;\n\n  // Auto-collapse when thinking ends, unless the reader took control during it.\n  const touched = React.useRef(false);\n  const wasActive = React.useRef(active);\n  React.useEffect(() => {\n    if (wasActive.current && !active && autoCollapse && !touched.current) setOpen(false);\n    if (!wasActive.current && active) touched.current = false;\n    wasActive.current = active;\n  }, [active, autoCollapse, setOpen]);\n\n  const id = React.useId();\n  const contentId = `${id}-content`;\n  const label = formatLabel ? (\n    formatLabel({ active, seconds })\n  ) : active ? (\n    <>\n      Thinking… <span className=\"tabular-nums\">{formatSeconds(seconds)}</span>\n    </>\n  ) : seconds >= 1 ? (\n    <>\n      Thought for <span className=\"tabular-nums\">{formatSeconds(seconds)}</span>\n    </>\n  ) : (\n    \"Thought for a moment\"\n  );\n\n  return (\n    <div\n      data-slot=\"reasoning\"\n      data-status={active ? \"active\" : \"done\"}\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\"flex flex-col font-kura-sans\", className)}\n      {...props}\n    >\n      <button\n        type=\"button\"\n        data-slot=\"reasoning-trigger\"\n        aria-expanded={open}\n        aria-controls={contentId}\n        onClick={() => {\n          touched.current = true;\n          setOpen(!open);\n        }}\n        className={cn(\n          \"group/trigger -mx-1.5 inline-flex h-7 w-fit cursor-pointer items-center gap-1 rounded-kura-sm px-1.5 text-kura-fg-muted text-sm outline-none\",\n          \"transition-[color,background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus\",\n        )}\n      >\n        <ShimmerText active={active} data-slot=\"reasoning-label\">\n          {label}\n        </ShimmerText>\n        <ChevronRight\n          aria-hidden\n          className={cn(\n            \"size-3.5 text-kura-fg-subtle transition-[rotate,color] duration-(--kura-duration-base) ease-kura-out group-hover/trigger:text-kura-fg-muted motion-reduce:transition-none\",\n            open && \"rotate-90\",\n          )}\n        />\n      </button>\n      <span className=\"sr-only\" role=\"status\">\n        {active ? \"Thinking\" : `Thought for ${formatSeconds(seconds)}`}\n      </span>\n      <div\n        id={contentId}\n        data-slot=\"reasoning-content\"\n        inert={!open || undefined}\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity,filter] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-opacity\",\n          open\n            ? \"grid-rows-[1fr] opacity-100\"\n            : \"grid-rows-[0fr] opacity-0 blur-[2px] motion-reduce:blur-none\",\n        )}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div className=\"mt-1.5 ml-[3px] whitespace-pre-wrap border-kura-border border-l-2 py-0.5 pl-3.5 text-kura-fg-muted text-sm leading-relaxed [&_[data-slot=streaming-text]]:text-kura-fg-muted [&_[data-slot=streaming-text]]:text-sm\">\n            {children}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "shader-canvas",
      "name": "ShaderCanvas",
      "category": "effects",
      "status": "beta",
      "description": "Minimal WebGL2 fragment-shader runner with time/resolution/pointer uniforms, DPR capping, offscreen pausing, reduced-motion still frame and CSS fallback.",
      "whenToUse": [
        "Building any GPU visual: mesh gradients, auroras, grain, glows, orbs, generative backgrounds.",
        "A custom decorative shader behind a hero, card or voice surface."
      ],
      "whenNotToUse": [
        "A ready-made effect exists — use mesh-gradient, agent-orb, grain or border-beam.",
        "Static gradients — plain CSS is cheaper.",
        "Anything that conveys information — it is aria-hidden and may fall back to a static gradient."
      ],
      "props": [
        {
          "name": "fragmentShader",
          "type": "string",
          "required": true,
          "description": "GLSL ES 3.00 source. Without a #version line, a header declaring u_time, u_resolution, u_pointer and `out vec4 fragColor` is prepended."
        },
        {
          "name": "uniforms",
          "type": "Record<string, UniformInput>",
          "description": "Custom uniforms: number→float, [x,y]→vec2, [x,y,z]→vec3, [x,y,z,w]→vec4, or a function of time evaluated every frame. Declare them in the shader."
        },
        {
          "name": "paused",
          "type": "boolean",
          "default": "false",
          "description": "Freeze time on the current frame."
        },
        {
          "name": "speed",
          "type": "number",
          "default": "1",
          "description": "Time multiplier."
        },
        {
          "name": "maxDpr",
          "type": "number",
          "default": "2",
          "description": "Device-pixel-ratio cap."
        },
        {
          "name": "pauseWhenOffscreen",
          "type": "boolean",
          "default": "true",
          "description": "Stop rendering when not visible."
        },
        {
          "name": "staticTime",
          "type": "number",
          "default": "1.5",
          "description": "u_time used for the reduced-motion still frame."
        },
        {
          "name": "respectReducedMotion",
          "type": "boolean",
          "default": "true",
          "description": "Render a single still frame when the user prefers reduced motion."
        },
        {
          "name": "fallback",
          "type": "React.ReactNode",
          "description": "Shown when WebGL2 is unavailable or the shader fails. Default: a soft token gradient."
        },
        {
          "name": "onError",
          "type": "(message: string) => void",
          "description": "Compile/link error callback."
        }
      ],
      "parts": [
        {
          "name": "ShaderCanvas",
          "description": "Container div (size it with classes); `data-state` = running | paused | static | fallback.",
          "dataSlot": "shader-canvas"
        }
      ],
      "a11y": {
        "notes": [
          "Decorative: the container is aria-hidden. Never put meaning only in the shader.",
          "Respects prefers-reduced-motion with a single still frame."
        ],
        "reducedMotion": "Renders one frame at `staticTime` and stops the animation loop."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "mesh-gradient",
        "agent-orb",
        "grain",
        "border-beam"
      ],
      "tags": [
        "webgl",
        "shader",
        "glsl",
        "gpu",
        "background",
        "gradient",
        "effect",
        "canvas"
      ],
      "examples": [
        {
          "name": "ShaderCanvasMinimal",
          "title": "Gradient in a few lines",
          "kind": "minimal",
          "code": "import { ShaderCanvas } from \"@kura-ui/react/shader-canvas\";\n\nconst gradient = /* glsl */ `\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  vec3 a = vec3(0.07, 0.44, 0.38);\n  vec3 b = vec3(0.93, 0.92, 0.88);\n  float t = 0.5 + 0.5 * sin(u_time * 0.6 + uv.x * 3.0 + uv.y * 2.0);\n  fragColor = vec4(mix(a, b, t), 1.0);\n}`;\n\nexport function ShaderCanvasMinimal() {\n  return (\n    <ShaderCanvas fragmentShader={gradient} className=\"h-48 w-full max-w-md rounded-kura-xl\" />\n  );\n}\n"
        },
        {
          "name": "ShaderCanvasFull",
          "title": "Silk with custom uniforms",
          "description": "Custom color uniforms, speed control, pause and pointer response.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShaderCanvas } from \"@kura-ui/react/shader-canvas\";\n\nconst silk = /* glsl */ `\nuniform vec3 u_colorA;\nuniform vec3 u_colorB;\nuniform vec3 u_colorC;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);\n}\nfloat fbm(vec2 p) {\n  float v = 0.0, a = 0.5;\n  for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.02; a *= 0.5; }\n  return v;\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  vec2 p = uv * vec2(u_resolution.x / u_resolution.y, 1.0) * 2.2;\n  float t = u_time * 0.12;\n  vec2 q = vec2(fbm(p + t), fbm(p - t + 3.1));\n  vec2 r = vec2(fbm(p + 2.0 * q + vec2(1.7, 9.2) + t), fbm(p + 2.0 * q + vec2(8.3, 2.8)));\n  r += (u_pointer - 0.5) * 0.35;\n  float f = fbm(p + 2.5 * r);\n  vec3 col = mix(u_colorA, u_colorB, smoothstep(0.2, 0.8, f));\n  col = mix(col, u_colorC, smoothstep(0.55, 1.0, length(q)));\n  col += 0.04 * (hash(gl_FragCoord.xy + u_time) - 0.5); // grain\n  fragColor = vec4(col, 1.0);\n}`;\n\nexport function ShaderCanvasFull() {\n  const [paused, setPaused] = React.useState(false);\n  const [speed, setSpeed] = React.useState(1);\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-3\">\n      <ShaderCanvas\n        fragmentShader={silk}\n        uniforms={{\n          u_colorA: [0.05, 0.09, 0.08],\n          u_colorB: [0.12, 0.55, 0.47],\n          u_colorC: [0.92, 0.9, 0.84],\n        }}\n        paused={paused}\n        speed={speed}\n        maxDpr={1.5}\n        staticTime={4}\n        className=\"h-64 w-full rounded-kura-xl shadow-kura-md\"\n      />\n      <div className=\"flex items-center gap-2\">\n        <Button size=\"sm\" variant=\"secondary\" onClick={() => setPaused((p) => !p)}>\n          {paused ? \"Play\" : \"Pause\"}\n        </Button>\n        <Button size=\"sm\" variant=\"ghost\" onClick={() => setSpeed((s) => (s >= 4 ? 0.5 : s * 2))}>\n          Speed ×{speed}\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ShaderCanvasFeatureCard",
          "title": "Feature card with a live band",
          "description": "A ShaderCanvas header behind a glass chip, with copy, stats and Buttons on a token surface.",
          "kind": "composition",
          "code": "import { ArrowRight, Telescope } from \"lucide-react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShaderCanvas } from \"@kura-ui/react/shader-canvas\";\n\nconst ribbons = /* glsl */ `\nuniform vec3 u_deep;\nuniform vec3 u_jade;\nuniform vec3 u_mist;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  float aspect = u_resolution.x / u_resolution.y;\n  float x = uv.x * aspect;\n  float t = u_time * 0.2;\n\n  // Deep base with a slow jade bloom that leans toward the pointer.\n  vec2 bloom = vec2(0.5 + 0.25 * sin(t * 0.7), 0.35) * 0.6 + u_pointer * 0.4;\n  float halo = smoothstep(0.9, 0.0, length((uv - bloom) * vec2(aspect * 0.6, 1.0)));\n  vec3 col = mix(u_deep, u_jade * 0.7, halo * 0.75);\n\n  // Three silk ribbons drifting across the band.\n  float glow = 0.0;\n  for (int i = 0; i < 3; i++) {\n    float fi = float(i);\n    float y = 0.5 + 0.2 * sin(x * (0.9 + fi * 0.3) + t * (1.0 + fi * 0.35) + fi * 2.1)\n                  + 0.07 * sin(x * 2.6 - t * 1.6 + fi * 1.3);\n    float d = abs(uv.y - y);\n    glow += (0.012 + 0.006 * fi) / (d + 0.012) * (0.9 - fi * 0.2);\n  }\n  col = mix(col, u_jade, clamp(glow * 0.45, 0.0, 1.0));\n  col = mix(col, u_mist, smoothstep(1.2, 3.0, glow));\n  col += 0.025 * (hash(gl_FragCoord.xy + u_time) - 0.5); // grain\n  fragColor = vec4(col, 1.0);\n}`;\n\nconst silk = /* glsl */ `\nuniform vec3 u_colorA;\nuniform vec3 u_colorB;\nuniform vec3 u_colorC;\n\nfloat hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);\n}\nfloat fbm(vec2 p) {\n  float v = 0.0, a = 0.5;\n  for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.02; a *= 0.5; }\n  return v;\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  vec2 p = uv * vec2(u_resolution.x / u_resolution.y, 1.0) * 2.2;\n  float t = u_time * 0.12;\n  vec2 q = vec2(fbm(p + t), fbm(p - t + 3.1));\n  vec2 r = vec2(fbm(p + 2.0 * q + vec2(1.7, 9.2) + t), fbm(p + 2.0 * q + vec2(8.3, 2.8)));\n  r += (u_pointer - 0.5) * 0.35;\n  float f = fbm(p + 2.5 * r);\n  vec3 col = mix(u_colorA, u_colorB, smoothstep(0.2, 0.8, f));\n  col = mix(col, u_colorC, smoothstep(0.55, 1.0, length(q)));\n  col += 0.04 * (hash(gl_FragCoord.xy + u_time) - 0.5); // grain\n  fragColor = vec4(col, 1.0);\n}`;\n\nexport function ShaderCanvasFeatureCard() {\n  return (\n    <article className=\"relative isolate flex w-full max-w-md flex-col overflow-hidden rounded-kura-2xl bg-kura-surface shadow-kura-md\">\n      <div className=\"relative h-44\">\n        <ShaderCanvas\n          fragmentShader={ribbons}\n          uniforms={{\n            u_deep: [0.035, 0.09, 0.08],\n            u_jade: [0.12, 0.6, 0.5],\n            u_mist: [0.86, 0.95, 0.9],\n          }}\n          maxDpr={1.5}\n          staticTime={6}\n          className=\"absolute inset-0\"\n        />\n        <span className=\"absolute top-3 left-3 inline-flex items-center gap-1.5 rounded-full bg-kura-overlay px-2.5 py-1 font-medium text-kura-fg text-xs shadow-kura-xs backdrop-blur-xl\">\n          <Telescope className=\"size-3.5\" aria-hidden />\n          Deep research\n        </span>\n      </div>\n      <div className=\"flex flex-col gap-4 p-5\">\n        <div className=\"flex flex-col gap-1.5\">\n          <h3 className=\"font-medium text-kura-fg text-lg tracking-tight\">\n            Hand off the reading, keep the judgement\n          </h3>\n          <p className=\"text-kura-fg-muted text-sm leading-relaxed\">\n            Kura searches, reads and cross-checks dozens of sources, then brings back a cited brief\n            you can question line by line.\n          </p>\n        </div>\n        <dl className=\"flex gap-6 font-kura-mono text-xs tabular-nums\">\n          <div className=\"flex flex-col gap-0.5\">\n            <dt className=\"text-kura-fg-subtle\">Typical run</dt>\n            <dd className=\"text-kura-fg\">4–6 min</dd>\n          </div>\n          <div className=\"flex flex-col gap-0.5\">\n            <dt className=\"text-kura-fg-subtle\">Sources</dt>\n            <dd className=\"text-kura-fg\">30–60</dd>\n          </div>\n          <div className=\"flex flex-col gap-0.5\">\n            <dt className=\"text-kura-fg-subtle\">Included</dt>\n            <dd className=\"text-kura-fg\">25 / month</dd>\n          </div>\n        </dl>\n        <div className=\"flex items-center gap-2\">\n          <Button>\n            Start a brief\n            <ArrowRight />\n          </Button>\n          <Button variant=\"ghost\">See an example</Button>\n        </div>\n      </div>\n    </article>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Give the container an explicit size (e.g. `className=\"h-64 w-full rounded-kura-xl\"`); the canvas fills it.",
        "Animate custom uniforms with function values `(t) => …` or by mutating an object you pass — not React state per frame.",
        "Colors in shaders cannot read CSS variables; pass them as vec3 uniforms (linear 0–1 RGB)."
      ],
      "title": "Shader Canvas",
      "importPath": "@kura-ui/react/shader-canvas",
      "exports": [
        "ShaderCanvas",
        "ShaderCanvasProps",
        "UniformInput",
        "UniformValue"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/shader-canvas",
      "markdownUrl": "https://kuro.abstergo.fyi/components/shader-canvas.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/shader-canvas.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/shader-canvas.json",
        "cli": "npx @kura-ui/cli add shader-canvas",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-merge-refs",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "fragmentShader": {
            "description": "GLSL ES 3.00 source. Without a #version line, a header declaring u_time, u_resolution, u_pointer and `out vec4 fragColor` is prepended.",
            "x-ts-type": "string",
            "type": "string"
          },
          "uniforms": {
            "description": "Custom uniforms: number→float, [x,y]→vec2, [x,y,z]→vec3, [x,y,z,w]→vec4, or a function of time evaluated every frame. Declare them in the shader.",
            "x-ts-type": "Record<string, UniformInput>"
          },
          "paused": {
            "description": "Freeze time on the current frame.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "speed": {
            "description": "Time multiplier.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1
          },
          "maxDpr": {
            "description": "Device-pixel-ratio cap.",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          },
          "pauseWhenOffscreen": {
            "description": "Stop rendering when not visible.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "staticTime": {
            "description": "u_time used for the reduced-motion still frame.",
            "x-ts-type": "number",
            "type": "number",
            "default": 1.5
          },
          "respectReducedMotion": {
            "description": "Render a single still frame when the user prefers reduced motion.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "fallback": {
            "description": "Shown when WebGL2 is unavailable or the shader fails. Default: a soft token gradient.",
            "x-ts-type": "React.ReactNode"
          },
          "onError": {
            "description": "Compile/link error callback.",
            "x-ts-type": "(message: string) => void"
          }
        },
        "required": [
          "fragmentShader"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/shader-canvas/index.ts",
          "type": "registry:ui",
          "content": "export {\n  ShaderCanvas,\n  type ShaderCanvasProps,\n  type UniformInput,\n  type UniformValue,\n} from \"./shader-canvas\";\n"
        },
        {
          "target": "components/kura/shader-canvas/shader-canvas.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type UniformValue =\n  | number\n  | readonly [number, number]\n  | readonly [number, number, number]\n  | readonly [number, number, number, number];\n\n/** Static value, or a function evaluated every frame (read refs/analysers here, not React state). */\nexport type UniformInput = UniformValue | ((time: number) => UniformValue);\n\nexport interface ShaderCanvasProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onError\"> {\n  /**\n   * GLSL ES 3.00 fragment shader. If it does not start with `#version`, this header is prepended:\n   * `#version 300 es`, `precision highp float;`, `uniform float u_time; uniform vec2 u_resolution;\n   * uniform vec2 u_pointer;`, `out vec4 fragColor;` — so you only write `void main() { … }`.\n   */\n  fragmentShader: string;\n  /**\n   * Custom uniforms by name. number → float, [x,y] → vec2, [x,y,z] → vec3, [x,y,z,w] → vec4.\n   * Declare them in the shader yourself. Values are read every frame, so updating this object\n   * does not recompile the program.\n   */\n  uniforms?: Record<string, UniformInput>;\n  /** Freeze time (keeps the last frame). */\n  paused?: boolean;\n  /** Time multiplier. Default 1. */\n  speed?: number;\n  /** Cap device-pixel-ratio for performance. Default 2. */\n  maxDpr?: number;\n  /** Stop rendering while offscreen (IntersectionObserver). Default true. */\n  pauseWhenOffscreen?: boolean;\n  /** `u_time` (seconds) used for the single static frame under reduced motion. Default 1.5. */\n  staticTime?: number;\n  /** Honour prefers-reduced-motion by rendering one static frame. Default true. */\n  respectReducedMotion?: boolean;\n  /** Rendered instead of the canvas when WebGL2 is unavailable or the shader fails to compile. Default: a soft token gradient. */\n  fallback?: React.ReactNode;\n  /** Called with the compile/link error message. Without it, the error is logged to the console. */\n  onError?: (message: string) => void;\n}\n\nconst VERTEX = `#version 300 es\nin vec2 a_position;\nvoid main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;\n\nconst HEADER = `#version 300 es\nprecision highp float;\nuniform float u_time;\nuniform vec2 u_resolution;\nuniform vec2 u_pointer;\nout vec4 fragColor;\n`;\n\nfunction compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | string {\n  const shader = gl.createShader(type);\n  if (!shader) return \"createShader failed\";\n  gl.shaderSource(shader, src);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    const log = gl.getShaderInfoLog(shader) ?? \"unknown compile error\";\n    gl.deleteShader(shader);\n    return log;\n  }\n  return shader;\n}\n\nfunction setUniform(gl: WebGL2RenderingContext, loc: WebGLUniformLocation, v: UniformValue) {\n  if (typeof v === \"number\") gl.uniform1f(loc, v);\n  else if (v.length === 2) gl.uniform2f(loc, v[0], v[1]);\n  else if (v.length === 3) gl.uniform3f(loc, v[0], v[1], v[2]);\n  else gl.uniform4f(loc, v[0], v[1], v[2], v[3]);\n}\n\n/** Default fallback: a quiet gradient built from Kura tokens. */\nfunction DefaultFallback() {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-0 bg-[radial-gradient(120%_80%_at_30%_20%,var(--kura-accent-subtle),transparent_60%),radial-gradient(100%_70%_at_80%_90%,var(--kura-gray-4),transparent_70%)]\"\n    />\n  );\n}\n\n/**\n * Minimal WebGL2 fragment-shader runner — the base for every Kura shader effect (mesh gradients,\n * auroras, grain, orbs). Handles DPR, resize, pointer, offscreen pausing, reduced motion, context\n * loss and a CSS fallback. Decorative by default (`aria-hidden`).\n */\nexport function ShaderCanvas({\n  fragmentShader,\n  uniforms,\n  paused = false,\n  speed = 1,\n  maxDpr = 2,\n  pauseWhenOffscreen = true,\n  staticTime = 1.5,\n  respectReducedMotion = true,\n  fallback,\n  onError,\n  className,\n  style,\n  ref,\n  ...props\n}: ShaderCanvasProps) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const kickRef = React.useRef<() => void>(() => {});\n  const [failed, setFailed] = React.useState(false);\n  const reducedMotion = useReducedMotion();\n  const isStatic = respectReducedMotion && reducedMotion;\n\n  // Latest values, read inside the render loop without restarting it.\n  const live = React.useRef({ uniforms, paused, speed, isStatic, staticTime, onError });\n  live.current = { uniforms, paused, speed, isStatic, staticTime, onError };\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n    const gl = canvas.getContext(\"webgl2\", { antialias: false, premultipliedAlpha: true });\n    if (!gl) {\n      setFailed(true);\n      return;\n    }\n    setFailed(false);\n\n    const report = (msg: string) => {\n      if (!live.current.onError) console.error(`[kura/shader-canvas] ${msg}`);\n      live.current.onError?.(msg);\n      setFailed(true);\n    };\n\n    const fragSrc = fragmentShader.trimStart().startsWith(\"#version\")\n      ? fragmentShader\n      : HEADER + fragmentShader;\n    const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);\n    const fs = compile(gl, gl.FRAGMENT_SHADER, fragSrc);\n    if (typeof vs === \"string\" || typeof fs === \"string\") {\n      report(typeof fs === \"string\" ? fs : (vs as string));\n      return;\n    }\n    const program = gl.createProgram();\n    if (!program) return report(\"createProgram failed\");\n    gl.attachShader(program, vs);\n    gl.attachShader(program, fs);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      return report(gl.getProgramInfoLog(program) ?? \"link failed\");\n    }\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL method, not a React hook\n    gl.useProgram(program);\n\n    const buffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n    const loc = gl.getAttribLocation(program, \"a_position\");\n    gl.enableVertexAttribArray(loc);\n    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\n\n    const uTime = gl.getUniformLocation(program, \"u_time\");\n    const uRes = gl.getUniformLocation(program, \"u_resolution\");\n    const uPointer = gl.getUniformLocation(program, \"u_pointer\");\n    const customLocs = new Map<string, WebGLUniformLocation | null>();\n\n    const pointer = { x: 0.5, y: 0.5 };\n    let visible = true;\n    let raf = 0;\n    let last = performance.now();\n    let time = 0;\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);\n      const { width, height } = container.getBoundingClientRect();\n      const w = Math.max(1, Math.round(width * dpr));\n      const h = Math.max(1, Math.round(height * dpr));\n      if (canvas.width !== w || canvas.height !== h) {\n        canvas.width = w;\n        canvas.height = h;\n        gl.viewport(0, 0, w, h);\n      }\n    };\n\n    const draw = (t: number) => {\n      resize();\n      if (uTime) gl.uniform1f(uTime, t);\n      if (uRes) gl.uniform2f(uRes, canvas.width, canvas.height);\n      if (uPointer) gl.uniform2f(uPointer, pointer.x, pointer.y);\n      const custom = live.current.uniforms;\n      if (custom) {\n        for (const name in custom) {\n          if (!customLocs.has(name)) customLocs.set(name, gl.getUniformLocation(program, name));\n          const l = customLocs.get(name);\n          const input = custom[name];\n          if (!l || input === undefined) continue;\n          setUniform(gl, l, typeof input === \"function\" ? input(t) : input);\n        }\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    const frame = (now: number) => {\n      const dt = Math.min(0.1, (now - last) / 1000);\n      last = now;\n      const { paused: p, speed: s, isStatic: st, staticTime: stT } = live.current;\n      if (st) {\n        draw(stT);\n      } else {\n        if (!p) time += dt * s;\n        draw(time);\n      }\n      raf = visible && !st && !p ? requestAnimationFrame(frame) : 0;\n    };\n\n    const kick = () => {\n      if (raf) return;\n      last = performance.now();\n      raf = requestAnimationFrame(frame);\n    };\n    kickRef.current = kick;\n    kick();\n\n    const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(() => kick()) : null;\n    ro?.observe(container);\n\n    const io =\n      pauseWhenOffscreen && typeof IntersectionObserver !== \"undefined\"\n        ? new IntersectionObserver(([entry]) => {\n            visible = entry?.isIntersecting ?? true;\n            if (visible) kick();\n          })\n        : null;\n    io?.observe(container);\n\n    const onPointer = (e: PointerEvent) => {\n      const r = container.getBoundingClientRect();\n      pointer.x = (e.clientX - r.left) / Math.max(1, r.width);\n      pointer.y = 1 - (e.clientY - r.top) / Math.max(1, r.height);\n    };\n    window.addEventListener(\"pointermove\", onPointer, { passive: true });\n\n    const onLost = (e: Event) => {\n      e.preventDefault();\n      cancelAnimationFrame(raf);\n      raf = 0;\n    };\n    canvas.addEventListener(\"webglcontextlost\", onLost);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro?.disconnect();\n      io?.disconnect();\n      window.removeEventListener(\"pointermove\", onPointer);\n      canvas.removeEventListener(\"webglcontextlost\", onLost);\n      gl.deleteProgram(program);\n      gl.deleteShader(vs);\n      gl.deleteShader(fs);\n      gl.deleteBuffer(buffer);\n    };\n  }, [fragmentShader, maxDpr, pauseWhenOffscreen]);\n\n  // Resume the loop when un-pausing or leaving reduced motion.\n  React.useEffect(() => {\n    if (!paused || isStatic) kickRef.current();\n  }, [paused, isStatic]);\n\n  // A new `uniforms` object (theme flip, colour or prop change) repaints one frame even while\n  // paused or static, so frozen canvases never show stale colours. While running this is a no-op.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `uniforms` identity is the trigger\n  React.useEffect(() => {\n    kickRef.current();\n  }, [uniforms]);\n\n  return (\n    <div\n      ref={mergeRefs(containerRef, ref)}\n      data-slot=\"shader-canvas\"\n      data-state={failed ? \"fallback\" : isStatic ? \"static\" : paused ? \"paused\" : \"running\"}\n      aria-hidden\n      className={cn(\"relative overflow-hidden\", className)}\n      style={style}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        className={cn(\"absolute inset-0 block size-full\", failed && \"hidden\")}\n      />\n      {failed && (fallback ?? <DefaultFallback />)}\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "shimmer-text",
      "name": "ShimmerText",
      "category": "feedback",
      "status": "beta",
      "description": "Animated gradient sweep across text — the canonical “Thinking…” / “Searching…” label for in-progress AI work.",
      "whenToUse": [
        "Show that a model or agent is working: “Thinking…”, “Searching the web…”, “Reading 12 files…”.",
        "Label the current step of a running tool call or reasoning trace.",
        "A lightweight inline loading label where a spinner would be too mechanical."
      ],
      "whenNotToUse": [
        "Long paragraphs or body text — the sweep hurts legibility beyond a short phrase.",
        "Determinate progress (uploads, token budgets) — use a progress bar or context-meter.",
        "Streaming the model's answer itself — use streaming-text.",
        "A standalone animated dot/orb indicator — use thinking-indicator or agent-orb."
      ],
      "props": [
        {
          "name": "children",
          "type": "React.ReactNode",
          "required": true,
          "description": "The text to shimmer. Plain text reads best; inline elements are allowed."
        },
        {
          "name": "active",
          "type": "boolean",
          "default": "true",
          "description": "Animate the sweep. Set false when the work finishes to settle into muted text."
        },
        {
          "name": "duration",
          "type": "number",
          "default": "2",
          "description": "Seconds per sweep. Keep between 1.5 (urgent) and 3 (calm)."
        },
        {
          "name": "spread",
          "type": "number",
          "default": "30",
          "description": "Width of the bright band as a % of the text width (clamped 5–80)."
        },
        {
          "name": "as",
          "type": "\"span\" | \"p\" | \"div\" | \"strong\" | \"em\"",
          "default": "\"span\"",
          "description": "Element to render."
        }
      ],
      "parts": [
        {
          "name": "ShimmerText",
          "description": "A single inline element; `data-active` reflects the `active` prop.",
          "dataSlot": "shimmer-text"
        }
      ],
      "a11y": {
        "notes": [
          "The text stays real text: screen readers read it normally; the animation is purely visual.",
          "Not a live region. Wrap the label in an element with role=\"status\" (or aria-live=\"polite\") if the change of state should be announced.",
          "Colors come from fg/fg-subtle tokens so contrast holds in light and dark mode."
        ],
        "reducedMotion": "The sweep is removed; the text renders as static muted text."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "thinking-indicator",
        "reasoning",
        "tool-call",
        "streaming-text"
      ],
      "tags": [
        "loading",
        "thinking",
        "status",
        "text",
        "animation",
        "shimmer",
        "skeleton",
        "progress"
      ],
      "examples": [
        {
          "name": "ShimmerTextMinimal",
          "title": "Thinking label",
          "description": "The smallest correct usage.",
          "kind": "minimal",
          "code": "import { ShimmerText } from \"@kura-ui/react/shimmer-text\";\n\nexport function ShimmerTextMinimal() {\n  return <ShimmerText>Thinking…</ShimmerText>;\n}\n"
        },
        {
          "name": "ShimmerTextFull",
          "title": "Tuned sweep",
          "description": "Every prop: element, speed, band width, and toggling `active` when work completes.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\n\nexport function ShimmerTextFull() {\n  const [active, setActive] = React.useState(true);\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\n      <ShimmerText as=\"p\" active={active} duration={2.4} spread={24} className=\"text-lg\">\n        {active ? \"Searching 24 sources…\" : \"Searched 24 sources\"}\n      </ShimmerText>\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => setActive((a) => !a)}>\n        {active ? \"Finish\" : \"Restart\"}\n      </Button>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ShimmerTextInToolStep",
          "title": "Agent step with status region",
          "description": "A tool-call style row announcing progress politely to screen readers.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Check } from \"lucide-react\";\nimport * as React from \"react\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\n\nexport function ShimmerTextInToolStep() {\n  const [done, setDone] = React.useState(false);\n  React.useEffect(() => {\n    const id = setTimeout(() => setDone(true), 3200);\n    return () => clearTimeout(id);\n  }, []);\n  return (\n    <div className=\"flex w-72 items-center gap-2.5 rounded-kura-lg bg-kura-surface px-3 py-2.5 text-sm shadow-kura-sm\">\n      <span className=\"flex size-5 items-center justify-center rounded-full bg-kura-accent-subtle text-kura-accent-text\">\n        {done ? (\n          <Check className=\"size-3\" />\n        ) : (\n          <span className=\"size-1.5 animate-kura-pulse rounded-full bg-current\" />\n        )}\n      </span>\n      <span role=\"status\" aria-live=\"polite\">\n        <ShimmerText active={!done}>\n          {done ? \"Read package.json\" : \"Reading package.json…\"}\n        </ShimmerText>\n      </span>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Server-component safe: no hooks, pure CSS animation. No \"use client\" needed to render it.",
        "Flip `active` to false instead of unmounting when work completes, so layout does not jump.",
        "Keep the label under ~6 words and end with an ellipsis (…) while active."
      ],
      "title": "Shimmer Text",
      "importPath": "@kura-ui/react/shimmer-text",
      "exports": [
        "ShimmerText",
        "ShimmerTextProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/shimmer-text",
      "markdownUrl": "https://kuro.abstergo.fyi/components/shimmer-text.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/shimmer-text.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/shimmer-text.json",
        "cli": "npx @kura-ui/cli add shimmer-text",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "children": {
            "description": "The text to shimmer. Plain text reads best; inline elements are allowed.",
            "x-ts-type": "React.ReactNode"
          },
          "active": {
            "description": "Animate the sweep. Set false when the work finishes to settle into muted text.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "duration": {
            "description": "Seconds per sweep. Keep between 1.5 (urgent) and 3 (calm).",
            "x-ts-type": "number",
            "type": "number",
            "default": 2
          },
          "spread": {
            "description": "Width of the bright band as a % of the text width (clamped 5–80).",
            "x-ts-type": "number",
            "type": "number",
            "default": 30
          },
          "as": {
            "description": "Element to render.",
            "x-ts-type": "\"span\" | \"p\" | \"div\" | \"strong\" | \"em\"",
            "type": "string",
            "enum": [
              "div",
              "em",
              "p",
              "span",
              "strong"
            ],
            "default": "span"
          }
        },
        "required": [
          "children"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/shimmer-text/index.ts",
          "type": "registry:ui",
          "content": "export { ShimmerText, type ShimmerTextProps } from \"./shimmer-text\";\n"
        },
        {
          "target": "components/kura/shimmer-text/shimmer-text.tsx",
          "type": "registry:ui",
          "content": "import type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ShimmerTextProps extends React.HTMLAttributes<HTMLElement> {\n  ref?: React.Ref<HTMLElement>;\n  /** The text to shimmer, e.g. \"Thinking…\". Plain text reads best; inline elements are allowed. */\n  children: React.ReactNode;\n  /** Animate the sweep. Set false when the work is done to settle into plain text. Default true. */\n  active?: boolean;\n  /** Seconds for one full sweep. Default 2. Shorter feels urgent; keep it between 1.5 and 3. */\n  duration?: number;\n  /** Width of the bright band as a percentage of the text width. Default 30. */\n  spread?: number;\n  /** Render as another element, e.g. \"p\" or \"div\". Default \"span\". */\n  as?: \"span\" | \"p\" | \"div\" | \"strong\" | \"em\";\n}\n\n/**\n * Animated gradient sweep across text — the canonical \"Thinking…\" / \"Searching the web…\"\n * affordance. Pure CSS (no JS animation loop), server-component safe, and static under\n * prefers-reduced-motion.\n */\nexport function ShimmerText({\n  children,\n  active = true,\n  duration = 2,\n  spread = 30,\n  as = \"span\",\n  className,\n  style,\n  ...props\n}: ShimmerTextProps) {\n  const band = Math.min(80, Math.max(5, spread)) / 2;\n  const Comp = as as \"span\";\n  return (\n    <Comp\n      data-slot=\"shimmer-text\"\n      data-active={active}\n      className={cn(\n        \"inline-block\",\n        active &&\n          \"animate-kura-shimmer bg-clip-text text-transparent [background-size:250%_100%] motion-reduce:animate-none motion-reduce:bg-none motion-reduce:text-kura-fg-muted\",\n        !active && \"text-kura-fg-muted transition-colors duration-(--kura-duration-slow)\",\n        className,\n      )}\n      style={\n        active\n          ? {\n              ...style,\n              [\"--kura-shimmer-duration\" as string]: `${duration}s`,\n              backgroundImage: `linear-gradient(90deg, var(--kura-fg-subtle) calc(50% - ${band}%), var(--kura-fg) 50%, var(--kura-fg-subtle) calc(50% + ${band}%))`,\n            }\n          : style\n      }\n      {...props}\n    >\n      {children}\n    </Comp>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "slash-command-menu",
      "name": "SlashCommandMenu",
      "category": "input",
      "status": "beta",
      "description": "Inline “/” command menu anchored to the caret of a textarea, with filtering, groups, icons, descriptions and keyboard navigation.",
      "whenToUse": [
        "Let users insert prompt templates or run actions by typing “/” in a composer (/summarize, /debug, /clear).",
        "Any textarea or input that needs caret-anchored suggestions triggered by a character (use `trigger` for “@” mentions or “#” tags)."
      ],
      "whenNotToUse": [
        "A global ⌘K launcher for navigation and actions — use command-palette.",
        "Choosing a model from a toolbar — use model-picker.",
        "Static clickable starters in an empty chat — use prompt-suggestions."
      ],
      "props": [
        {
          "name": "textareaRef",
          "type": "React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>",
          "required": true,
          "description": "Ref to the field to watch, e.g. the `ref` you pass to ComposerInput. The menu never takes focus from it."
        },
        {
          "name": "commands",
          "type": "readonly SlashCommand[]",
          "required": true,
          "description": "Commands: { id, label, description?, icon?, group?, keywords?, shortcut?, insert?, disabled? }."
        },
        {
          "name": "onSelect",
          "type": "(command: SlashCommand, details: SlashCommandSelectDetails) => void",
          "description": "Called after the typed “/query” has been replaced by `command.insert` (or removed). details.query is what was typed."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the menu opens or closes."
        },
        {
          "name": "trigger",
          "type": "string",
          "default": "\"/\"",
          "description": "Trigger character. Only fires at the start of a line or after whitespace."
        },
        {
          "name": "side",
          "type": "\"auto\" | \"top\" | \"bottom\"",
          "default": "\"auto\"",
          "description": "auto opens below the caret and flips above when there is no room."
        },
        {
          "name": "emptyMessage",
          "type": "string | null",
          "default": "\"No commands\"",
          "description": "Shown when nothing matches; null closes the menu instead."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "default": "false",
          "description": "Stop watching the field without unmounting."
        }
      ],
      "parts": [
        {
          "name": "SlashCommandMenu",
          "description": "Floating panel portalled to <body>, position: fixed at the trigger character. data-side = top | bottom; scales from the caret corner.",
          "dataSlot": "slash-command-menu"
        },
        {
          "name": "SlashCommandMenuGroup",
          "description": "role=group per `group`, labelled by its heading.",
          "dataSlot": "slash-command-menu-group"
        },
        {
          "name": "SlashCommandMenuItem",
          "description": "role=option row: icon tile, label (matched text emphasised), description, shortcut. data-active, data-disabled.",
          "dataSlot": "slash-command-menu-item"
        },
        {
          "name": "SlashCommandMenuFooter",
          "description": "Key hints: ↑↓ navigate, ↵ insert, Esc dismiss.",
          "dataSlot": "slash-command-menu-footer"
        }
      ],
      "a11y": {
        "role": "listbox",
        "notes": [
          "Focus never leaves the textarea. While open, the textarea gets aria-controls, aria-autocomplete=list and aria-activedescendant pointing at the highlighted option, so screen readers read the option as you arrow.",
          "A polite status announces the number of matching commands.",
          "Pointer selection uses mousedown preventDefault so the textarea keeps focus and its caret.",
          "Insertion uses the browser's insertText command where available, so ⌘Z undoes a command like normal typing."
        ],
        "keyboard": [
          {
            "keys": "/",
            "action": "Open (at line start or after a space)"
          },
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Move the highlight (wraps, skips disabled)"
          },
          {
            "keys": "Enter / Tab",
            "action": "Insert the highlighted command"
          },
          {
            "keys": "Escape",
            "action": "Dismiss for this “/” (typing on does not reopen it)"
          },
          {
            "keys": "Space / Backspace past /",
            "action": "Closes the menu"
          }
        ],
        "reducedMotion": "The menu fades in without the scale and 2px travel."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "kbd"
        ]
      },
      "related": [
        "composer",
        "command-palette",
        "prompt-suggestions"
      ],
      "tags": [
        "slash",
        "commands",
        "autocomplete",
        "mention",
        "caret",
        "menu",
        "composer",
        "textarea"
      ],
      "examples": [
        {
          "name": "SlashCommandMenuMinimal",
          "title": "On a plain textarea",
          "kind": "minimal",
          "code": "import {\n  AlignLeft,\n  Bug,\n  Code,\n  Eraser,\n  Globe,\n  Languages,\n  ListTodo,\n  MessageSquarePlus,\n  PenLine,\n  TestTube,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { type SlashCommand, SlashCommandMenu } from \"@kura-ui/react/slash-command-menu\";\n\nconst basic: SlashCommand[] = [\n  { id: \"summarize\", label: \"Summarize\", insert: \"Summarize the following:\\n\" },\n  { id: \"translate\", label: \"Translate\", insert: \"Translate into \" },\n  { id: \"explain\", label: \"Explain\", insert: \"Explain like I'm new to this: \" },\n];\n\nconst commands: SlashCommand[] = [\n  {\n    id: \"summarize\",\n    label: \"Summarize\",\n    description: \"Condense text into key points\",\n    icon: <AlignLeft />,\n    group: \"Write\",\n    keywords: [\"tldr\", \"shorten\"],\n    insert: \"Summarize the following in 5 bullet points:\\n\",\n  },\n  {\n    id: \"rewrite\",\n    label: \"Rewrite\",\n    description: \"Change tone, length or clarity\",\n    icon: <PenLine />,\n    group: \"Write\",\n    insert: \"Rewrite this to be clearer and more concise:\\n\",\n  },\n  {\n    id: \"translate\",\n    label: \"Translate\",\n    description: \"Into any language\",\n    icon: <Languages />,\n    group: \"Write\",\n    insert: \"Translate into \",\n  },\n  {\n    id: \"explain\",\n    label: \"Explain code\",\n    description: \"Walk through what a snippet does\",\n    icon: <Code />,\n    group: \"Code\",\n    insert: \"Explain this code step by step:\\n```\\n\\n```\",\n  },\n  {\n    id: \"debug\",\n    label: \"Debug\",\n    description: \"Find the cause of an error\",\n    icon: <Bug />,\n    group: \"Code\",\n    keywords: [\"fix\", \"error\", \"stack trace\"],\n    insert: \"Here is an error. Find the root cause:\\n\",\n  },\n  {\n    id: \"tests\",\n    label: \"Write tests\",\n    description: \"Unit tests for the selected function\",\n    icon: <TestTube />,\n    group: \"Code\",\n    insert: \"Write Vitest unit tests for:\\n\",\n  },\n  {\n    id: \"plan\",\n    label: \"Plan\",\n    description: \"Break a goal into steps first\",\n    icon: <ListTodo />,\n    group: \"Agent\",\n    insert: \"Before doing anything, write a step-by-step plan for: \",\n  },\n  {\n    id: \"search\",\n    label: \"Search the web\",\n    description: \"Ground the answer in fresh sources\",\n    icon: <Globe />,\n    group: \"Agent\",\n    disabled: true,\n  },\n  {\n    id: \"new\",\n    label: \"New chat\",\n    icon: <MessageSquarePlus />,\n    group: \"Session\",\n    shortcut: [\"mod\", \"shift\", \"o\"],\n  },\n  { id: \"clear\", label: \"Clear input\", icon: <Eraser />, group: \"Session\" },\n];\n\nexport function SlashCommandMenuMinimal() {\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  return (\n    <div className=\"w-full max-w-md\">\n      <textarea\n        ref={ref}\n        aria-label=\"Prompt\"\n        placeholder=\"Type / for commands\"\n        rows={3}\n        className=\"w-full resize-none rounded-kura-lg bg-kura-surface px-3 py-2.5 text-sm shadow-kura-sm outline-none placeholder:text-kura-fg-subtle focus-visible:shadow-kura-focus\"\n      />\n      <SlashCommandMenu textareaRef={ref} commands={basic} />\n    </div>\n  );\n}\n"
        },
        {
          "name": "SlashCommandMenuFull",
          "title": "Groups, icons, shortcuts and actions",
          "description": "Ten commands in four groups, a disabled command, keywords (/tldr finds Summarize) and an action that clears the input.",
          "kind": "full",
          "code": "\"use client\";\n\nimport {\n  AlignLeft,\n  Bug,\n  Code,\n  Eraser,\n  Globe,\n  Languages,\n  ListTodo,\n  MessageSquarePlus,\n  PenLine,\n  TestTube,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { type SlashCommand, SlashCommandMenu } from \"@kura-ui/react/slash-command-menu\";\n\nconst commands: SlashCommand[] = [\n  {\n    id: \"summarize\",\n    label: \"Summarize\",\n    description: \"Condense text into key points\",\n    icon: <AlignLeft />,\n    group: \"Write\",\n    keywords: [\"tldr\", \"shorten\"],\n    insert: \"Summarize the following in 5 bullet points:\\n\",\n  },\n  {\n    id: \"rewrite\",\n    label: \"Rewrite\",\n    description: \"Change tone, length or clarity\",\n    icon: <PenLine />,\n    group: \"Write\",\n    insert: \"Rewrite this to be clearer and more concise:\\n\",\n  },\n  {\n    id: \"translate\",\n    label: \"Translate\",\n    description: \"Into any language\",\n    icon: <Languages />,\n    group: \"Write\",\n    insert: \"Translate into \",\n  },\n  {\n    id: \"explain\",\n    label: \"Explain code\",\n    description: \"Walk through what a snippet does\",\n    icon: <Code />,\n    group: \"Code\",\n    insert: \"Explain this code step by step:\\n```\\n\\n```\",\n  },\n  {\n    id: \"debug\",\n    label: \"Debug\",\n    description: \"Find the cause of an error\",\n    icon: <Bug />,\n    group: \"Code\",\n    keywords: [\"fix\", \"error\", \"stack trace\"],\n    insert: \"Here is an error. Find the root cause:\\n\",\n  },\n  {\n    id: \"tests\",\n    label: \"Write tests\",\n    description: \"Unit tests for the selected function\",\n    icon: <TestTube />,\n    group: \"Code\",\n    insert: \"Write Vitest unit tests for:\\n\",\n  },\n  {\n    id: \"plan\",\n    label: \"Plan\",\n    description: \"Break a goal into steps first\",\n    icon: <ListTodo />,\n    group: \"Agent\",\n    insert: \"Before doing anything, write a step-by-step plan for: \",\n  },\n  {\n    id: \"search\",\n    label: \"Search the web\",\n    description: \"Ground the answer in fresh sources\",\n    icon: <Globe />,\n    group: \"Agent\",\n    disabled: true,\n  },\n  {\n    id: \"new\",\n    label: \"New chat\",\n    icon: <MessageSquarePlus />,\n    group: \"Session\",\n    shortcut: [\"mod\", \"shift\", \"o\"],\n  },\n  { id: \"clear\", label: \"Clear input\", icon: <Eraser />, group: \"Session\" },\n];\n\nexport function SlashCommandMenuFull() {\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  const [value, setValue] = React.useState(\"\");\n  const [log, setLog] = React.useState(\"Type / at the start of a line\");\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-2\">\n      <textarea\n        ref={ref}\n        aria-label=\"Prompt\"\n        value={value}\n        onChange={(e) => setValue(e.currentTarget.value)}\n        placeholder=\"Write a message… try /sum or /debug\"\n        rows={4}\n        className=\"w-full resize-none rounded-kura-lg bg-kura-surface px-3 py-2.5 text-sm leading-6 shadow-kura-sm outline-none placeholder:text-kura-fg-subtle focus-visible:shadow-kura-focus\"\n      />\n      <SlashCommandMenu\n        textareaRef={ref}\n        commands={commands}\n        trigger=\"/\"\n        side=\"auto\"\n        emptyMessage=\"No matching command\"\n        onOpenChange={(open) => setLog(open ? \"Menu open\" : \"Menu closed\")}\n        onSelect={(command, { query }) => {\n          if (command.id === \"clear\") setValue(\"\");\n          setLog(`Ran /${command.label}${query ? ` (typed “/${query}”)` : \"\"}`);\n        }}\n      />\n      <p className=\"px-1 text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n        {log}\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "SlashCommandMenuInComposer",
          "title": "Inside a composer",
          "description": "Pass the ComposerInput ref; Enter inserts the command instead of sending while the menu is open.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport {\n  AlignLeft,\n  Bug,\n  Code,\n  Eraser,\n  Globe,\n  Languages,\n  ListTodo,\n  MessageSquarePlus,\n  PenLine,\n  TestTube,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  Composer,\n  ComposerActions,\n  ComposerAttachButton,\n  ComposerHint,\n  ComposerInput,\n  ComposerSubmit,\n  ComposerToolbar,\n  ComposerTools,\n} from \"@kura-ui/react/composer\";\nimport { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\nimport { type SlashCommand, SlashCommandMenu } from \"@kura-ui/react/slash-command-menu\";\n\nconst commands: SlashCommand[] = [\n  {\n    id: \"summarize\",\n    label: \"Summarize\",\n    description: \"Condense text into key points\",\n    icon: <AlignLeft />,\n    group: \"Write\",\n    keywords: [\"tldr\", \"shorten\"],\n    insert: \"Summarize the following in 5 bullet points:\\n\",\n  },\n  {\n    id: \"rewrite\",\n    label: \"Rewrite\",\n    description: \"Change tone, length or clarity\",\n    icon: <PenLine />,\n    group: \"Write\",\n    insert: \"Rewrite this to be clearer and more concise:\\n\",\n  },\n  {\n    id: \"translate\",\n    label: \"Translate\",\n    description: \"Into any language\",\n    icon: <Languages />,\n    group: \"Write\",\n    insert: \"Translate into \",\n  },\n  {\n    id: \"explain\",\n    label: \"Explain code\",\n    description: \"Walk through what a snippet does\",\n    icon: <Code />,\n    group: \"Code\",\n    insert: \"Explain this code step by step:\\n```\\n\\n```\",\n  },\n  {\n    id: \"debug\",\n    label: \"Debug\",\n    description: \"Find the cause of an error\",\n    icon: <Bug />,\n    group: \"Code\",\n    keywords: [\"fix\", \"error\", \"stack trace\"],\n    insert: \"Here is an error. Find the root cause:\\n\",\n  },\n  {\n    id: \"tests\",\n    label: \"Write tests\",\n    description: \"Unit tests for the selected function\",\n    icon: <TestTube />,\n    group: \"Code\",\n    insert: \"Write Vitest unit tests for:\\n\",\n  },\n  {\n    id: \"plan\",\n    label: \"Plan\",\n    description: \"Break a goal into steps first\",\n    icon: <ListTodo />,\n    group: \"Agent\",\n    insert: \"Before doing anything, write a step-by-step plan for: \",\n  },\n  {\n    id: \"search\",\n    label: \"Search the web\",\n    description: \"Ground the answer in fresh sources\",\n    icon: <Globe />,\n    group: \"Agent\",\n    disabled: true,\n  },\n  {\n    id: \"new\",\n    label: \"New chat\",\n    icon: <MessageSquarePlus />,\n    group: \"Session\",\n    shortcut: [\"mod\", \"shift\", \"o\"],\n  },\n  { id: \"clear\", label: \"Clear input\", icon: <Eraser />, group: \"Session\" },\n];\n\nexport function SlashCommandMenuInComposer() {\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  const [value, setValue] = React.useState(\"\");\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-2\">\n      <Composer value={value} onValueChange={setValue} onSubmit={() => {}}>\n        <ComposerInput ref={ref} placeholder=\"Ask anything, or type / for commands\" />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerHint />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <SlashCommandMenu\n        textareaRef={ref}\n        commands={commands}\n        side=\"top\"\n        onSelect={(command) => {\n          if (command.id === \"clear\") setValue(\"\");\n        }}\n      />\n      <div className=\"flex gap-3 px-1\">\n        <KbdShortcut keys={[\"/\"]} label=\"Commands\" variant=\"ghost\" />\n        <KbdShortcut keys={[\"enter\"]} label=\"Send\" variant=\"ghost\" />\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Render the menu next to the field (anywhere in the tree); it portals itself to <body> and positions at the caret.",
        "It works with controlled fields: the replacement dispatches a native input event, so React onChange / Composer onValueChange fire.",
        "Use `insert` for prompt templates; omit it for actions and handle them in onSelect (e.g. id === \"clear\").",
        "Its keydown listener runs before React handlers and stops Enter/Tab/arrows while open, so Composer will not send mid-selection.",
        "getCaretRect(field, offset) is exported if you need caret coordinates for something else."
      ],
      "title": "Slash Command Menu",
      "importPath": "@kura-ui/react/slash-command-menu",
      "exports": [
        "CaretRect",
        "SlashCommand",
        "SlashCommandMenu",
        "SlashCommandMenuProps",
        "SlashCommandSelectDetails",
        "getCaretRect"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/slash-command-menu",
      "markdownUrl": "https://kuro.abstergo.fyi/components/slash-command-menu.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/slash-command-menu.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/slash-command-menu.json",
        "cli": "npx @kura-ui/cli add slash-command-menu",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kbd",
        "kura-keys",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "textareaRef": {
            "description": "Ref to the field to watch, e.g. the `ref` you pass to ComposerInput. The menu never takes focus from it.",
            "x-ts-type": "React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>"
          },
          "commands": {
            "description": "Commands: { id, label, description?, icon?, group?, keywords?, shortcut?, insert?, disabled? }.",
            "x-ts-type": "readonly SlashCommand[]",
            "type": "array"
          },
          "onSelect": {
            "description": "Called after the typed “/query” has been replaced by `command.insert` (or removed). details.query is what was typed.",
            "x-ts-type": "(command: SlashCommand, details: SlashCommandSelectDetails) => void"
          },
          "onOpenChange": {
            "description": "Called when the menu opens or closes.",
            "x-ts-type": "(open: boolean) => void"
          },
          "trigger": {
            "description": "Trigger character. Only fires at the start of a line or after whitespace.",
            "x-ts-type": "string",
            "type": "string",
            "default": "/"
          },
          "side": {
            "description": "auto opens below the caret and flips above when there is no room.",
            "x-ts-type": "\"auto\" | \"top\" | \"bottom\"",
            "type": "string",
            "enum": [
              "auto",
              "bottom",
              "top"
            ],
            "default": "auto"
          },
          "emptyMessage": {
            "description": "Shown when nothing matches; null closes the menu instead.",
            "x-ts-type": "string | null",
            "default": "No commands"
          },
          "disabled": {
            "description": "Stop watching the field without unmounting.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [
          "textareaRef",
          "commands"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/slash-command-menu/caret-position.ts",
          "type": "registry:ui",
          "content": "/**\n * Viewport coordinates of a character offset inside a <textarea> or <input>, measured with an\n * off-screen mirror element that copies the field's text layout. Browser-only; returns null\n * during SSR.\n */\nexport interface CaretRect {\n  /** Viewport x of the caret's left edge. */\n  left: number;\n  /** Viewport y of the top of the caret's line. */\n  top: number;\n  /** Line height in px. */\n  height: number;\n}\n\nconst COPIED = [\n  \"direction\",\n  \"boxSizing\",\n  \"width\",\n  \"overflowX\",\n  \"overflowY\",\n  \"borderTopWidth\",\n  \"borderRightWidth\",\n  \"borderBottomWidth\",\n  \"borderLeftWidth\",\n  \"borderStyle\",\n  \"paddingTop\",\n  \"paddingRight\",\n  \"paddingBottom\",\n  \"paddingLeft\",\n  \"fontStyle\",\n  \"fontVariant\",\n  \"fontWeight\",\n  \"fontStretch\",\n  \"fontSize\",\n  \"fontSizeAdjust\",\n  \"lineHeight\",\n  \"fontFamily\",\n  \"fontFeatureSettings\",\n  \"fontVariationSettings\",\n  \"textAlign\",\n  \"textTransform\",\n  \"textIndent\",\n  \"textDecoration\",\n  \"letterSpacing\",\n  \"wordSpacing\",\n  \"tabSize\",\n] as const;\n\nexport function getCaretRect(\n  field: HTMLTextAreaElement | HTMLInputElement,\n  offset: number,\n): CaretRect | null {\n  if (typeof window === \"undefined\" || typeof document === \"undefined\") return null;\n  const rect = field.getBoundingClientRect();\n\n  const style = window.getComputedStyle(field);\n  const mirror = document.createElement(\"div\");\n  const isInput = field.tagName === \"INPUT\";\n  const s = mirror.style;\n  for (const prop of COPIED) s[prop] = style[prop];\n  s.position = \"absolute\";\n  s.visibility = \"hidden\";\n  s.top = \"0\";\n  s.left = \"-9999px\";\n  s.whiteSpace = isInput ? \"pre\" : \"pre-wrap\";\n  s.overflowWrap = isInput ? \"normal\" : \"break-word\";\n  s.overflow = \"hidden\";\n  s.height = \"auto\";\n\n  mirror.textContent = field.value.slice(0, offset);\n  const marker = document.createElement(\"span\");\n  // A zero-width marker would collapse; use the rest of the text so wrapping matches.\n  marker.textContent = field.value.slice(offset) || \".\";\n  mirror.appendChild(marker);\n  document.body.appendChild(mirror);\n\n  const lineHeight =\n    Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.25 || 20;\n  const result: CaretRect = {\n    left: rect.left + marker.offsetLeft - field.scrollLeft,\n    top: rect.top + marker.offsetTop - field.scrollTop,\n    height: lineHeight,\n  };\n  document.body.removeChild(mirror);\n  return result;\n}\n"
        },
        {
          "target": "components/kura/slash-command-menu/index.ts",
          "type": "registry:ui",
          "content": "export { type CaretRect, getCaretRect } from \"./caret-position\";\nexport {\n  type SlashCommand,\n  SlashCommandMenu,\n  type SlashCommandMenuProps,\n  type SlashCommandSelectDetails,\n} from \"./slash-command-menu\";\n"
        },
        {
          "target": "components/kura/slash-command-menu/slash-command-menu.tsx",
          "type": "registry:ui",
          "content": "// ARIA listbox pattern: the textarea keeps focus and points at the highlighted option with\n// aria-activedescendant, so options are intentionally not focusable and have no key handlers.\n// biome-ignore-all lint/a11y/useSemanticElements: rich rows cannot be native <option>/<fieldset>.\n// biome-ignore-all lint/a11y/useFocusableInteractive: focus stays in the textarea (activedescendant).\n// biome-ignore-all lint/a11y/useKeyWithClickEvents: keyboard selection is handled on the textarea.\n// biome-ignore-all lint/a11y/noStaticElementInteractions: the panel only prevents mousedown to keep textarea focus.\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Kbd } from \"@/components/kura/kbd\";\nimport { type CaretRect, getCaretRect } from \"./caret-position\";\n\nexport interface SlashCommand {\n  /** Stable id. */\n  id: string;\n  /** Command name shown in the menu and matched by the query, e.g. \"Summarize\". */\n  label: string;\n  /** One line under/next to the label. */\n  description?: string;\n  /** 16px icon, e.g. a lucide icon. */\n  icon?: React.ReactNode;\n  /** Group heading, e.g. \"Actions\". Groups keep first-seen order. */\n  group?: string;\n  /** Extra words that should match, e.g. [\"tl;dr\", \"shorten\"]. */\n  keywords?: readonly string[];\n  /** Shortcut displayed on the right (display only). */\n  shortcut?: readonly KeyName[];\n  /**\n   * Text that replaces the typed \"/query\" when chosen, e.g. \"Summarize this: \". Omit to just\n   * remove the \"/query\" (action commands such as \"/clear\").\n   */\n  insert?: string;\n  disabled?: boolean;\n}\n\nexport interface SlashCommandSelectDetails {\n  /** What the user had typed after the trigger. */\n  query: string;\n}\n\ntype Field = HTMLTextAreaElement | HTMLInputElement;\n\nexport interface SlashCommandMenuProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> {\n  /** The field to watch: a ref to a <textarea> or <input> (e.g. ComposerInput's `ref`). */\n  textareaRef: React.RefObject<Field | null>;\n  /** Available commands. */\n  commands: readonly SlashCommand[];\n  /** Called after the command's `insert` text has replaced the \"/query\". */\n  onSelect?: (command: SlashCommand, details: SlashCommandSelectDetails) => void;\n  /** Called when the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** Trigger character. Default \"/\". It only triggers at the start of a line or after a space. */\n  trigger?: string;\n  /** `auto` opens below the caret unless there is no room, then above. Default \"auto\". */\n  side?: \"auto\" | \"top\" | \"bottom\";\n  /** Default \"No commands\". Pass null to close the menu when nothing matches. */\n  emptyMessage?: string | null;\n  /** Turn the menu off without unmounting it. */\n  disabled?: boolean;\n}\n\ninterface Match {\n  start: number;\n  end: number;\n  query: string;\n}\n\nfunction score(cmd: SlashCommand, q: string): number {\n  if (!q) return 1;\n  const label = cmd.label.toLowerCase();\n  if (label.startsWith(q)) return 4;\n  if (label.split(/[\\s-_/]+/).some((w) => w.startsWith(q))) return 3;\n  if (label.includes(q)) return 2;\n  const extra = [cmd.description ?? \"\", ...(cmd.keywords ?? [])].join(\" \").toLowerCase();\n  return extra.includes(q) ? 1 : 0;\n}\n\nfunction escapeRegExp(s: string): string {\n  return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/** Replace a range of a (possibly React-controlled) field, keeping native undo where possible. */\nfunction replaceRange(field: Field, start: number, end: number, text: string): void {\n  field.focus();\n  field.setSelectionRange(start, end);\n  let ok = false;\n  try {\n    ok =\n      typeof document.execCommand === \"function\" &&\n      document.execCommand(text ? \"insertText\" : \"delete\", false, text);\n  } catch {\n    ok = false;\n  }\n  if (!ok || field.value.slice(start, start + text.length) !== text) {\n    field.setRangeText(text, start, end, \"end\");\n    field.dispatchEvent(new Event(\"input\", { bubbles: true }));\n  }\n}\n\nconst MENU_WIDTH = 320;\nconst GAP = 6;\n\n/**\n * An inline \"/\" command menu anchored to the caret of a textarea. It never takes focus: typing\n * keeps filtering, ↑↓ move the highlight, Enter or Tab inserts, Escape dismisses.\n */\nexport function SlashCommandMenu({\n  textareaRef,\n  commands,\n  onSelect,\n  onOpenChange,\n  trigger = \"/\",\n  side = \"auto\",\n  emptyMessage = \"No commands\",\n  disabled = false,\n  className,\n  style,\n  ...props\n}: SlashCommandMenuProps) {\n  const [match, setMatch] = React.useState<Match | null>(null);\n  const [active, setActive] = React.useState<string | null>(null);\n  const [caret, setCaret] = React.useState<CaretRect | null>(null);\n  const [placement, setPlacement] = React.useState<\"top\" | \"bottom\">(\"bottom\");\n  const dismissedAt = React.useRef<number | null>(null);\n  const menuRef = React.useRef<HTMLDivElement>(null);\n  // First pointer position seen over the open menu. A menu that opens under a resting cursor\n  // receives a synthetic move; only real movement after that may change the highlight.\n  const pointerOrigin = React.useRef<{ x: number; y: number } | null>(null);\n  const baseId = React.useId();\n  const listId = `${baseId}-list`;\n  const optionId = React.useCallback(\n    (id: string) => `${baseId}-opt-${id.replace(/[^a-zA-Z0-9_-]/g, \"_\")}`,\n    [baseId],\n  );\n\n  const query = match?.query.toLowerCase() ?? \"\";\n  const results = React.useMemo(() => {\n    if (!match) return [];\n    return commands\n      .map((cmd, index) => ({ cmd, index, s: score(cmd, query) }))\n      .filter((r) => r.s > 0)\n      .sort((a, b) => b.s - a.s || a.index - b.index)\n      .map((r) => r.cmd);\n  }, [commands, match, query]);\n\n  const groups = React.useMemo(() => {\n    const map = new Map<string, SlashCommand[]>();\n    for (const cmd of results) {\n      const key = cmd.group ?? \"\";\n      const list = map.get(key);\n      if (list) list.push(cmd);\n      else map.set(key, [cmd]);\n    }\n    return [...map.entries()];\n  }, [results]);\n  const ordered = React.useMemo(() => groups.flatMap(([, list]) => list), [groups]);\n  const enabled = React.useMemo(() => ordered.filter((c) => !c.disabled), [ordered]);\n  const open = !!match && !disabled && (results.length > 0 || emptyMessage !== null);\n\n  // Detect \"/query\" right before the caret.\n  const detect = React.useCallback(() => {\n    const field = textareaRef.current;\n    if (!field || disabled) {\n      setMatch(null);\n      return;\n    }\n    const caretPos = field.selectionStart ?? 0;\n    if (caretPos !== field.selectionEnd) {\n      setMatch(null);\n      return;\n    }\n    const before = field.value.slice(0, caretPos);\n    const re = new RegExp(`(^|\\\\s)${escapeRegExp(trigger)}([^\\\\s${escapeRegExp(trigger)}]{0,32})$`);\n    const m = re.exec(before);\n    if (!m) {\n      dismissedAt.current = null;\n      setMatch(null);\n      return;\n    }\n    const q = m[2] ?? \"\";\n    const start = caretPos - q.length - trigger.length;\n    if (dismissedAt.current === start) {\n      setMatch(null);\n      return;\n    }\n    setMatch((prev) =>\n      prev && prev.start === start && prev.end === caretPos && prev.query === q\n        ? prev\n        : { start, end: caretPos, query: q },\n    );\n  }, [textareaRef, trigger, disabled]);\n\n  // Watch the field. Input is observed on `document` (after React's own root listener) so a\n  // controlled field has committed its new value before we react to it.\n  React.useEffect(() => {\n    const field = textareaRef.current;\n    if (!field) return;\n    const onInput = (e: Event) => {\n      if (e.target === field) detect();\n    };\n    const onSelectionChange = () => {\n      if (document.activeElement === field) detect();\n    };\n    const onBlur = () => setMatch(null);\n    document.addEventListener(\"input\", onInput);\n    field.addEventListener(\"click\", detect);\n    field.addEventListener(\"blur\", onBlur);\n    document.addEventListener(\"selectionchange\", onSelectionChange);\n    return () => {\n      document.removeEventListener(\"input\", onInput);\n      field.removeEventListener(\"click\", detect);\n      field.removeEventListener(\"blur\", onBlur);\n      document.removeEventListener(\"selectionchange\", onSelectionChange);\n    };\n  }, [textareaRef, detect]);\n\n  // Keep the highlight valid.\n  React.useEffect(() => {\n    if (!open) {\n      setActive(null);\n      pointerOrigin.current = null;\n      return;\n    }\n    setActive((current) =>\n      current && enabled.some((c) => c.id === current) ? current : (enabled[0]?.id ?? null),\n    );\n  }, [open, enabled]);\n\n  const openRef = React.useRef(open);\n  React.useEffect(() => {\n    if (openRef.current !== open) {\n      openRef.current = open;\n      onOpenChange?.(open);\n    }\n  }, [open, onOpenChange]);\n\n  // Position at the trigger character, flipping above when there is no room below.\n  const reposition = React.useCallback(() => {\n    const field = textareaRef.current;\n    if (!field || !match) return;\n    const rect = getCaretRect(field, match.start);\n    if (!rect) return;\n    setCaret(rect);\n    const menuHeight = menuRef.current?.offsetHeight ?? 280;\n    const below = window.innerHeight - (rect.top + rect.height);\n    const above = rect.top;\n    setPlacement(\n      side === \"auto\" ? (below < menuHeight + GAP + 8 && above > below ? \"top\" : \"bottom\") : side,\n    );\n  }, [textareaRef, match, side]);\n\n  React.useLayoutEffect(() => {\n    if (open) reposition();\n  }, [open, reposition]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const onChange = () => reposition();\n    window.addEventListener(\"resize\", onChange);\n    window.addEventListener(\"scroll\", onChange, true);\n    return () => {\n      window.removeEventListener(\"resize\", onChange);\n      window.removeEventListener(\"scroll\", onChange, true);\n    };\n  }, [open, reposition]);\n\n  const choose = React.useCallback(\n    (cmd: SlashCommand) => {\n      const field = textareaRef.current;\n      if (!field || !match || cmd.disabled) return;\n      const q = match.query;\n      setMatch(null);\n      dismissedAt.current = null;\n      replaceRange(field, match.start, match.end, cmd.insert ?? \"\");\n      onSelect?.(cmd, { query: q });\n    },\n    [textareaRef, match, onSelect],\n  );\n\n  // Keyboard: handled on the field itself, before React handlers (e.g. Composer's Enter).\n  React.useEffect(() => {\n    const field = textareaRef.current;\n    if (!field || !open) return;\n    const onKeyDown = (event: Event) => {\n      const e = event as KeyboardEvent;\n      if (e.isComposing) return;\n      const move = (delta: number) => {\n        if (enabled.length === 0) return;\n        const i = enabled.findIndex((c) => c.id === active);\n        const next = enabled[(i + delta + enabled.length) % enabled.length];\n        if (next) setActive(next.id);\n      };\n      let handled = true;\n      switch (e.key) {\n        case \"ArrowDown\":\n          move(1);\n          break;\n        case \"ArrowUp\":\n          move(-1);\n          break;\n        case \"Enter\":\n        case \"Tab\": {\n          if (e.shiftKey && e.key === \"Enter\") {\n            handled = false;\n            break;\n          }\n          const cmd = enabled.find((c) => c.id === active);\n          if (cmd) choose(cmd);\n          else handled = false;\n          break;\n        }\n        case \"Escape\":\n          dismissedAt.current = match?.start ?? null;\n          setMatch(null);\n          break;\n        default:\n          handled = false;\n      }\n      if (handled) {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n    };\n    field.addEventListener(\"keydown\", onKeyDown);\n    return () => field.removeEventListener(\"keydown\", onKeyDown);\n  }, [textareaRef, open, enabled, active, choose, match]);\n\n  // ARIA wiring on the field we do not own.\n  React.useEffect(() => {\n    const field = textareaRef.current;\n    if (!field) return;\n    if (open) {\n      field.setAttribute(\"aria-controls\", listId);\n      field.setAttribute(\"aria-autocomplete\", \"list\");\n      if (active) field.setAttribute(\"aria-activedescendant\", optionId(active));\n      else field.removeAttribute(\"aria-activedescendant\");\n    } else {\n      field.removeAttribute(\"aria-controls\");\n      field.removeAttribute(\"aria-activedescendant\");\n      field.removeAttribute(\"aria-autocomplete\");\n    }\n  }, [textareaRef, open, active, listId, optionId]);\n\n  React.useEffect(() => {\n    if (!active) return;\n    document.getElementById(optionId(active))?.scrollIntoView?.({ block: \"nearest\" });\n  }, [active, optionId]);\n\n  if (!open || typeof document === \"undefined\") return null;\n\n  const vw = typeof window !== \"undefined\" ? window.innerWidth : 1024;\n  const left = caret ? Math.max(8, Math.min(caret.left - 10, vw - MENU_WIDTH - 8)) : 0;\n  const positioned: React.CSSProperties = caret\n    ? placement === \"bottom\"\n      ? { left, top: caret.top + caret.height + GAP }\n      : { left, bottom: window.innerHeight - caret.top + GAP }\n    : { left: 0, top: 0, visibility: \"hidden\" };\n\n  return createPortal(\n    <div\n      ref={menuRef}\n      data-slot=\"slash-command-menu\"\n      data-side={placement}\n      data-state=\"open\"\n      onMouseDown={(e) => e.preventDefault()}\n      style={{ ...positioned, width: MENU_WIDTH, ...style }}\n      className={cn(\n        \"fixed z-50 flex max-h-[min(18rem,50vh)] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-kura-lg bg-kura-overlay font-kura-sans text-kura-fg shadow-kura-lg backdrop-blur-xl backdrop-saturate-150\",\n        \"animate-kura-pop-in data-[side=bottom]:origin-top-left data-[side=top]:origin-bottom-left\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        id={listId}\n        role=\"listbox\"\n        aria-label=\"Commands\"\n        className=\"scroll-py-1 overflow-y-auto overscroll-contain p-1 [scrollbar-width:thin]\"\n      >\n        {results.length === 0 && (\n          <div className=\"px-3 py-5 text-center text-[13px] text-kura-fg-subtle\">\n            {emptyMessage}\n          </div>\n        )}\n        {groups.map(([group, list]) => {\n          const headingId = `${baseId}-g-${group.replace(/\\W/g, \"_\") || \"default\"}`;\n          return (\n            <div\n              key={group || \"__default\"}\n              role=\"group\"\n              aria-labelledby={group ? headingId : undefined}\n              data-slot=\"slash-command-menu-group\"\n              className=\"not-first:mt-1\"\n            >\n              {group && (\n                <div\n                  id={headingId}\n                  className=\"select-none px-2 pt-1.5 pb-1 font-medium text-[11px] text-kura-fg-subtle\"\n                >\n                  {group}\n                </div>\n              )}\n              {list.map((cmd) => {\n                const isActive = cmd.id === active;\n                return (\n                  <div\n                    key={cmd.id}\n                    id={optionId(cmd.id)}\n                    role=\"option\"\n                    aria-selected={isActive}\n                    aria-disabled={cmd.disabled || undefined}\n                    data-slot=\"slash-command-menu-item\"\n                    data-active={isActive || undefined}\n                    data-disabled={cmd.disabled || undefined}\n                    onPointerMove={(e) => {\n                      const origin = pointerOrigin.current;\n                      if (!origin) {\n                        pointerOrigin.current = { x: e.clientX, y: e.clientY };\n                        return;\n                      }\n                      if (origin.x === e.clientX && origin.y === e.clientY) return;\n                      if (!cmd.disabled && !isActive) setActive(cmd.id);\n                    }}\n                    onClick={() => choose(cmd)}\n                    className=\"flex cursor-pointer select-none items-center gap-2.5 rounded-kura-md px-1.5 py-1.5 data-active:bg-kura-hover data-disabled:cursor-not-allowed data-disabled:opacity-45\"\n                  >\n                    <span\n                      aria-hidden\n                      className=\"flex size-7 shrink-0 items-center justify-center rounded-kura-sm bg-kura-surface text-kura-fg-muted shadow-kura-xs [&_svg]:size-3.5\"\n                    >\n                      {cmd.icon ?? (\n                        <span className=\"font-kura-mono text-[11px] leading-none\">{trigger}</span>\n                      )}\n                    </span>\n                    <span className=\"flex min-w-0 flex-1 flex-col\">\n                      <span className=\"truncate font-medium text-[13px] leading-5\">\n                        <Highlight text={cmd.label} query={query} />\n                      </span>\n                      {cmd.description && (\n                        <span className=\"truncate text-kura-fg-subtle text-xs leading-4\">\n                          {cmd.description}\n                        </span>\n                      )}\n                    </span>\n                    {cmd.shortcut && (\n                      <Kbd keys={cmd.shortcut} variant=\"ghost\" size=\"sm\" className=\"shrink-0\" />\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          );\n        })}\n      </div>\n      <div\n        data-slot=\"slash-command-menu-footer\"\n        aria-hidden\n        className=\"flex h-7 shrink-0 items-center gap-3 px-3 text-[11px] text-kura-fg-subtle shadow-[inset_0_1px_0_var(--kura-border-subtle)]\"\n      >\n        <span className=\"inline-flex items-center gap-1\">\n          <Kbd keys={[\"up\"]} variant=\"ghost\" size=\"sm\" />\n          <Kbd keys={[\"down\"]} variant=\"ghost\" size=\"sm\" />\n          navigate\n        </span>\n        <span className=\"inline-flex items-center gap-1\">\n          <Kbd keys={[\"enter\"]} variant=\"ghost\" size=\"sm\" />\n          insert\n        </span>\n        <span className=\"inline-flex items-center gap-1\">\n          <Kbd keys={[\"esc\"]} variant=\"ghost\" size=\"sm\" />\n          dismiss\n        </span>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {results.length === 0\n          ? (emptyMessage ?? \"\")\n          : `${results.length} ${results.length === 1 ? \"command\" : \"commands\"} available`}\n      </span>\n    </div>,\n    document.body,\n  );\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>;\n  const i = text.toLowerCase().indexOf(query);\n  if (i === -1) return <>{text}</>;\n  return (\n    <>\n      <span className=\"text-kura-fg-muted\">{text.slice(0, i)}</span>\n      <span className=\"text-kura-fg\">{text.slice(i, i + query.length)}</span>\n      <span className=\"text-kura-fg-muted\">{text.slice(i + query.length)}</span>\n    </>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "spinner",
      "name": "Spinner",
      "category": "primitives",
      "status": "stable",
      "description": "Eight-segment radial spinner for indeterminate loading that inherits the current text color.",
      "whenToUse": [
        "Indeterminate waits on a control: a loading button, a pending row, a fetching list.",
        "Small inline busy indicators next to text."
      ],
      "whenNotToUse": [
        "AI ‘thinking’ states — use shimmer-text or thinking-indicator; they read as the model working, not the network.",
        "Known progress — use a progress bar or context-meter.",
        "Inside a Button — pass `loading` to button instead."
      ],
      "props": [
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "default": "\"md\"",
          "description": "12px, 16px or 20px."
        },
        {
          "name": "label",
          "type": "string | null",
          "default": "\"Loading\"",
          "description": "Accessible label (role=status). Pass null when a parent already announces loading."
        }
      ],
      "a11y": {
        "role": "status",
        "notes": [
          "With a label it renders role=\"status\" containing visually hidden label text; with label={null} it is aria-hidden."
        ],
        "reducedMotion": "Rotation is replaced by a slow opacity pulse."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "button",
        "shimmer-text",
        "thinking-indicator"
      ],
      "tags": [
        "loading",
        "spinner",
        "busy",
        "progress",
        "indeterminate"
      ],
      "examples": [
        {
          "name": "SpinnerMinimal",
          "title": "Default",
          "kind": "minimal",
          "code": "import { Spinner } from \"@kura-ui/react/spinner\";\n\nexport function SpinnerMinimal() {\n  return <Spinner />;\n}\n"
        },
        {
          "name": "SpinnerFull",
          "title": "Sizes and colors",
          "kind": "full",
          "code": "import { Spinner } from \"@kura-ui/react/spinner\";\n\nexport function SpinnerFull() {\n  return (\n    <div className=\"flex items-center gap-6 text-kura-fg-muted\">\n      <Spinner size=\"sm\" label=\"Loading small\" />\n      <Spinner size=\"md\" label=\"Loading medium\" />\n      <Spinner size=\"lg\" label=\"Loading large\" className=\"text-kura-accent\" />\n      <span className=\"inline-flex items-center gap-2 text-sm\">\n        <Spinner size=\"sm\" label={null} /> Fetching models…\n      </span>\n    </div>\n  );\n}\n"
        },
        {
          "name": "SpinnerSourcesList",
          "title": "Syncing knowledge sources",
          "description": "Per-row spinners for indexing, a loading Sync now Button and a Reconnect action for a failed source.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Check, FileText, FolderGit2, HardDrive, ListTodo, TriangleAlert } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Spinner } from \"@kura-ui/react/spinner\";\n\nconst initialSources: Array<{\n  id: string;\n  name: string;\n  detail: string;\n  icon: React.ReactNode;\n  status: SourceStatus;\n}> = [\n  {\n    id: \"drive\",\n    name: \"Product specs\",\n    detail: \"Drive · 1,204 files\",\n    icon: <HardDrive />,\n    status: \"synced\",\n  },\n  {\n    id: \"wiki\",\n    name: \"Team wiki\",\n    detail: \"Notion · 386 pages\",\n    icon: <FileText />,\n    status: \"syncing\",\n  },\n  {\n    id: \"repo\",\n    name: \"kura-ui/kura\",\n    detail: \"GitHub · main\",\n    icon: <FolderGit2 />,\n    status: \"synced\",\n  },\n  {\n    id: \"issues\",\n    name: \"Roadmap issues\",\n    detail: \"Linear · access expired\",\n    icon: <ListTodo />,\n    status: \"error\",\n  },\n];\n\nexport function SpinnerSourcesList() {\n  const [sources, setSources] = React.useState(initialSources);\n  const [syncingAll, setSyncingAll] = React.useState(false);\n  const syncing = sources.some((s) => s.status === \"syncing\");\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  React.useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  // Let the initial \"Team wiki\" sync finish so the demo settles.\n  React.useEffect(() => {\n    const id = setTimeout(\n      () => setSources((all) => all.map((s) => (s.id === \"wiki\" ? { ...s, status: \"synced\" } : s))),\n      3200,\n    );\n    return () => clearTimeout(id);\n  }, []);\n\n  const settle = (id: string, ms: number) =>\n    timers.current.push(\n      setTimeout(\n        () => setSources((all) => all.map((s) => (s.id === id ? { ...s, status: \"synced\" } : s))),\n        ms,\n      ),\n    );\n  const syncOne = (id: string) => {\n    setSources((all) => all.map((s) => (s.id === id ? { ...s, status: \"syncing\" } : s)));\n    settle(id, 1800);\n  };\n  // Re-index healthy sources; one that needs reconnecting stays put until the user acts.\n  const syncAll = () => {\n    const healthy = sources.filter((s) => s.status !== \"error\");\n    setSources((all) => all.map((s) => (s.status === \"error\" ? s : { ...s, status: \"syncing\" })));\n    for (const [i, s] of healthy.entries()) settle(s.id, 900 + i * 650);\n    setSyncingAll(true);\n    timers.current.push(setTimeout(() => setSyncingAll(false), 900 + healthy.length * 650));\n  };\n\n  return (\n    <section\n      aria-labelledby=\"sources-title\"\n      className=\"flex w-full max-w-md flex-col rounded-kura-xl bg-kura-surface shadow-kura-sm\"\n    >\n      <div className=\"flex items-center justify-between gap-3 px-4 pt-4 pb-2\">\n        <div className=\"flex flex-col gap-0.5\">\n          <h3 id=\"sources-title\" className=\"font-medium text-kura-fg text-sm\">\n            Knowledge sources\n          </h3>\n          <p className=\"text-kura-fg-muted text-xs\" aria-live=\"polite\">\n            {syncing ? \"Syncing — answers may miss recent changes\" : \"Indexed 2 minutes ago\"}\n          </p>\n        </div>\n        <Button variant=\"secondary\" size=\"sm\" loading={syncingAll} onClick={syncAll}>\n          Sync now\n        </Button>\n      </div>\n      <ul className=\"flex flex-col p-1.5\">\n        {sources.map((source) => (\n          <li\n            key={source.id}\n            data-status={source.status}\n            className=\"flex items-center gap-3 rounded-kura-md px-2.5 py-2\"\n          >\n            <span className=\"flex size-8 shrink-0 items-center justify-center rounded-kura-md bg-kura-surface-sunken text-kura-fg-muted shadow-kura-xs [&_svg]:size-4\">\n              {source.icon}\n            </span>\n            <span className=\"flex min-w-0 flex-1 flex-col\">\n              <span className=\"truncate font-medium text-[13px] text-kura-fg\">{source.name}</span>\n              <span className=\"truncate text-kura-fg-subtle text-xs\">{source.detail}</span>\n            </span>\n            {source.status === \"error\" ? (\n              <Button\n                variant=\"outline\"\n                size=\"xs\"\n                aria-label={`Reconnect ${source.name}`}\n                onClick={() => syncOne(source.id)}\n              >\n                <TriangleAlert className=\"text-kura-danger-text\" />\n                Reconnect\n              </Button>\n            ) : (\n              <span className=\"inline-flex shrink-0 items-center gap-1.5 text-kura-fg-muted text-xs\">\n                {source.status === \"syncing\" ? (\n                  <Spinner size=\"sm\" label={null} />\n                ) : (\n                  <Check aria-hidden className=\"size-3.5 text-kura-success-text\" />\n                )}\n                {source.status === \"syncing\" ? \"Indexing…\" : \"Up to date\"}\n              </span>\n            )}\n          </li>\n        ))}\n      </ul>\n    </section>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Color it with a text-* class on the spinner or a parent; it uses currentColor."
      ],
      "title": "Spinner",
      "importPath": "@kura-ui/react/spinner",
      "exports": [
        "Spinner",
        "SpinnerProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/spinner",
      "markdownUrl": "https://kuro.abstergo.fyi/components/spinner.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/spinner.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/spinner.json",
        "cli": "npx @kura-ui/cli add spinner",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "size": {
            "description": "12px, 16px or 20px.",
            "x-ts-type": "\"sm\" | \"md\" | \"lg\"",
            "type": "string",
            "enum": [
              "lg",
              "md",
              "sm"
            ],
            "default": "md"
          },
          "label": {
            "description": "Accessible label (role=status). Pass null when a parent already announces loading.",
            "x-ts-type": "string | null",
            "default": "Loading"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/spinner/index.ts",
          "type": "registry:ui",
          "content": "export { Spinner, type SpinnerProps } from \"./spinner\";\n"
        },
        {
          "target": "components/kura/spinner/spinner.tsx",
          "type": "registry:ui",
          "content": "import type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SpinnerProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Visual size. `sm` 12px, `md` 16px, `lg` 20px. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible label announced to screen readers. Pass `null` when a parent already announces loading. */\n  label?: string | null;\n}\n\nconst sizes = { sm: \"size-3\", md: \"size-4\", lg: \"size-5\" } as const;\n\nconst SEGMENTS = 8;\n\n/**\n * Eight-segment radial spinner. Inherits `currentColor`. Under reduced motion it pulses instead\n * of rotating.\n */\nexport function Spinner({ size = \"md\", label = \"Loading\", className, ...props }: SpinnerProps) {\n  return (\n    <span\n      data-slot=\"spinner\"\n      role={label ? \"status\" : undefined}\n      aria-hidden={label ? undefined : true}\n      className={cn(\"relative inline-flex shrink-0 text-current\", sizes[size], className)}\n      {...props}\n    >\n      {label && <span className=\"sr-only\">{label}</span>}\n      <span\n        aria-hidden\n        className=\"absolute inset-0 animate-kura-spin [animation-timing-function:steps(8)] motion-reduce:animate-kura-pulse\"\n      >\n        {Array.from({ length: SEGMENTS }, (_, i) => (\n          <span\n            key={i}\n            className=\"absolute top-0 left-[calc(50%-0.75px)] h-full w-[1.5px]\"\n            style={{ transform: `rotate(${(i * 360) / SEGMENTS}deg)` }}\n          >\n            <span\n              className=\"block h-[30%] w-full rounded-full bg-current\"\n              style={{ opacity: 0.15 + (0.85 * (i + 1)) / SEGMENTS }}\n            />\n          </span>\n        ))}\n      </span>\n    </span>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "streaming-text",
      "name": "StreamingText",
      "category": "conversation",
      "status": "beta",
      "description": "Token-by-token model output with an even reveal cadence, word-group fade/blur-in and a soft caret. Takes a growing string or an async iterable.",
      "whenToUse": [
        "Render an LLM answer while it streams in (AI SDK, OpenAI/Anthropic SSE, a ReadableStream adapted to an async iterable).",
        "Smooth out bursty token delivery so text appears at a calm, even rhythm instead of in jumps.",
        "Show a live caret at the end of text that is still being generated.",
        "Build a custom renderer (markdown, rich text) on top of the `useStreamingText` hook exported from the same folder."
      ],
      "whenNotToUse": [
        "Static text that is already complete — render it directly (StreamingText shows initial text instantly anyway).",
        "A “Thinking…” label before the first token — use shimmer-text (or reasoning for a collapsible trace).",
        "A full chat bubble with avatar and actions — use message, which composes streaming-text.",
        "Streaming code — use code-block with `streaming` so partial lines do not flicker."
      ],
      "props": [
        {
          "name": "text",
          "type": "string",
          "description": "The text received so far. Pass a growing string; a change that is not an append restarts the reveal from the common prefix."
        },
        {
          "name": "stream",
          "type": "AsyncIterable<string> | null",
          "description": "An async iterable of chunks (an async generator, AI SDK `textStream`). Takes precedence over `text`. Each iterable is consumed once and shared across re-renders."
        },
        {
          "name": "speed",
          "type": "number",
          "default": "90",
          "description": "Minimum reveal cadence in characters per second. Bursts are buffered and released at this rate, faster when a backlog builds up (never more than ~½s behind). `0` disables smoothing."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "description": "More text is still coming: keeps the caret visible and `aria-busy` set. Defaults to true while `stream` is being read, otherwise false."
        },
        {
          "name": "onComplete",
          "type": "() => void",
          "description": "Called once when the stream has ended and every character has been revealed (useful to enable message actions)."
        },
        {
          "name": "animation",
          "type": "\"blur\" | \"fade\" | \"none\"",
          "default": "\"blur\"",
          "description": "How each newly revealed word group enters: fade + 3px blur, fade only, or no animation."
        },
        {
          "name": "caret",
          "type": "boolean",
          "default": "true",
          "description": "Show a soft pulsing caret at the live edge while streaming. It fades out (without reflow) when the stream completes."
        }
      ],
      "parts": [
        {
          "name": "StreamingText",
          "description": "Root, `white-space: pre-wrap`. `data-status=\"streaming\" | \"done\"`, `aria-busy` while streaming.",
          "dataSlot": "streaming-text"
        },
        {
          "name": "Chunk",
          "description": "A recently revealed word group, animating in. Chunks merge into one text node ~0.7s after they appear, so long answers stay a single node.",
          "dataSlot": "streaming-text-chunk"
        },
        {
          "name": "Caret",
          "description": "Decorative caret (aria-hidden). `data-visible` reflects streaming.",
          "dataSlot": "streaming-text-caret"
        }
      ],
      "a11y": {
        "notes": [
          "The text is real text in the DOM; the root sets `aria-busy` while streaming so a surrounding `role=\"log\"` / live region announces the finished answer instead of every token.",
          "Do not make StreamingText itself a live region — put it inside message / a `role=\"log\"` list.",
          "The caret is decorative (`aria-hidden`)."
        ],
        "reducedMotion": "Word groups only fade in (no blur) and the caret stops pulsing; the even reveal cadence is kept because it carries meaning (text is still arriving)."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "message",
        "reasoning",
        "code-block",
        "shimmer-text"
      ],
      "tags": [
        "streaming",
        "tokens",
        "llm",
        "text",
        "typewriter",
        "caret",
        "chat",
        "output"
      ],
      "examples": [
        {
          "name": "StreamingTextMinimal",
          "title": "Async iterable",
          "description": "Pass a token generator; bursty delivery is evened out automatically.",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { StreamingText } from \"@kura-ui/react/streaming-text\";\n\nconst ANSWER =\n  \"Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.\";\n\nasync function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; ) {\n    if (signal?.aborted) return;\n    const n = 1 + Math.floor(Math.random() * 4);\n    yield words.slice(i, i + n).join(\"\");\n    i += n;\n    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));\n  }\n}\n\nexport function StreamingTextMinimal() {\n  const [stream] = React.useState(() => fakeTokens(ANSWER));\n  return <StreamingText stream={stream} className=\"max-w-md\" />;\n}\n"
        },
        {
          "name": "StreamingTextFull",
          "title": "Growing string, every prop",
          "description": "Drive it with a growing `text` + `streaming`, tune `speed`, and react to `onComplete`.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { StreamingText } from \"@kura-ui/react/streaming-text\";\n\nconst ANSWER =\n  \"Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.\";\n\nasync function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; ) {\n    if (signal?.aborted) return;\n    const n = 1 + Math.floor(Math.random() * 4);\n    yield words.slice(i, i + n).join(\"\");\n    i += n;\n    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));\n  }\n}\n\nexport function StreamingTextFull() {\n  const [run, setRun] = React.useState(0);\n  const [text, setText] = React.useState(\"\");\n  const [streaming, setStreaming] = React.useState(true);\n  const [done, setDone] = React.useState(0);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` restarts the fake stream\n  React.useEffect(() => {\n    const controller = new AbortController();\n    setText(\"\");\n    setStreaming(true);\n    (async () => {\n      let acc = \"\";\n      for await (const token of fakeTokens(ANSWER, controller.signal)) {\n        acc += token;\n        setText(acc);\n      }\n      if (!controller.signal.aborted) setStreaming(false);\n    })();\n    return () => controller.abort();\n  }, [run]);\n\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-4\">\n      <StreamingText\n        text={text}\n        streaming={streaming}\n        speed={120}\n        animation=\"blur\"\n        caret\n        onComplete={() => setDone((d) => d + 1)}\n        className=\"min-h-32\"\n      />\n      <div className=\"flex items-center justify-between\">\n        <span className=\"font-kura-mono text-kura-fg-subtle text-xs tabular-nums\">\n          {text.length} chars · completed {done}×\n        </span>\n        <Button variant=\"secondary\" size=\"sm\" onClick={() => setRun((r) => r + 1)}>\n          <RotateCcw /> Replay\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "StreamingTextComposition",
          "title": "Custom renderer with useStreamingText",
          "description": "Shimmering “Thinking…” until the first token, then a hand-rolled renderer built on the hook, with a Stop button.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Square } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ShimmerText } from \"@kura-ui/react/shimmer-text\";\nimport { useStreamingText } from \"@kura-ui/react/streaming-text\";\n\nconst ANSWER =\n  \"Streaming feels calm when the reveal keeps an even rhythm. Tokens arrive in bursts — three at once, then a pause — so Kura buffers them and releases whole words at a steady cadence, fading each group in while the caret holds the live edge.\";\n\nasync function* fakeTokens(text: string, signal?: AbortSignal): AsyncGenerator<string> {\n  const words = text.split(/(?<=\\s)/);\n  for (let i = 0; i < words.length; ) {\n    if (signal?.aborted) return;\n    const n = 1 + Math.floor(Math.random() * 4);\n    yield words.slice(i, i + n).join(\"\");\n    i += n;\n    await new Promise((r) => setTimeout(r, Math.random() < 0.15 ? 380 : 40 + Math.random() * 90));\n  }\n}\n\nexport function StreamingTextComposition() {\n  const [stream, setStream] = React.useState<AsyncIterable<string> | null>(null);\n  const [stopped, setStopped] = React.useState(false);\n  const controller = React.useRef<AbortController | null>(null);\n\n  React.useEffect(() => {\n    const id = setTimeout(() => {\n      controller.current = new AbortController();\n      setStream(fakeTokens(ANSWER, controller.current.signal));\n    }, 1200);\n    return () => {\n      clearTimeout(id);\n      controller.current?.abort();\n    };\n  }, []);\n\n  const { settled, chunks, isStreaming } = useStreamingText({ stream });\n  const waiting = !stream;\n\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-3 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm\">\n      <div className=\"flex h-6 items-center justify-between\">\n        <span className=\"font-medium text-kura-fg text-sm\">Assistant</span>\n        {(waiting || isStreaming) && !stopped && (\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            onClick={() => {\n              controller.current?.abort();\n              setStopped(true);\n            }}\n          >\n            <Square className=\"fill-current\" /> Stop\n          </Button>\n        )}\n      </div>\n      {waiting ? (\n        <p role=\"status\" className=\"text-[15px] leading-relaxed\">\n          <ShimmerText>Thinking…</ShimmerText>\n        </p>\n      ) : (\n        <p\n          aria-busy={isStreaming || undefined}\n          className=\"whitespace-pre-wrap text-[15px] text-kura-fg leading-relaxed\"\n        >\n          {settled}\n          {chunks.map((c) => (\n            <span\n              key={c.id}\n              className=\"transition-opacity duration-(--kura-duration-slow) starting:opacity-0\"\n            >\n              {c.text}\n            </span>\n          ))}\n        </p>\n      )}\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass the SAME iterable object across renders (store it in state/ref). Creating a new generator in JSX on every render restarts the stream.",
        "For a growing string, set `streaming` while tokens are still arriving; otherwise the caret hides and `onComplete` fires each time the reveal catches up.",
        "Text present at mount renders instantly — remounting or hydrating a finished message never replays the animation.",
        "For markdown, call `useStreamingText({ stream })` and feed `displayed` to your markdown renderer (or render `settled` + `chunks` yourself to keep the per-chunk fade).",
        "Import the hook from the same entry: `import { StreamingText, useStreamingText } from \"@kura-ui/react/streaming-text\"`."
      ],
      "title": "Streaming Text",
      "importPath": "@kura-ui/react/streaming-text",
      "exports": [
        "StreamingChunk",
        "StreamingText",
        "StreamingTextProps",
        "UseStreamingTextOptions",
        "UseStreamingTextResult",
        "useStreamingText"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/streaming-text",
      "markdownUrl": "https://kuro.abstergo.fyi/components/streaming-text.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/streaming-text.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/streaming-text.json",
        "cli": "npx @kura-ui/cli add streaming-text",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "text": {
            "description": "The text received so far. Pass a growing string; a change that is not an append restarts the reveal from the common prefix.",
            "x-ts-type": "string",
            "type": "string"
          },
          "stream": {
            "description": "An async iterable of chunks (an async generator, AI SDK `textStream`). Takes precedence over `text`. Each iterable is consumed once and shared across re-renders.",
            "x-ts-type": "AsyncIterable<string> | null"
          },
          "speed": {
            "description": "Minimum reveal cadence in characters per second. Bursts are buffered and released at this rate, faster when a backlog builds up (never more than ~½s behind). `0` disables smoothing.",
            "x-ts-type": "number",
            "type": "number",
            "default": 90
          },
          "streaming": {
            "description": "More text is still coming: keeps the caret visible and `aria-busy` set. Defaults to true while `stream` is being read, otherwise false.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onComplete": {
            "description": "Called once when the stream has ended and every character has been revealed (useful to enable message actions).",
            "x-ts-type": "() => void"
          },
          "animation": {
            "description": "How each newly revealed word group enters: fade + 3px blur, fade only, or no animation.",
            "x-ts-type": "\"blur\" | \"fade\" | \"none\"",
            "type": "string",
            "enum": [
              "blur",
              "fade",
              "none"
            ],
            "default": "blur"
          },
          "caret": {
            "description": "Show a soft pulsing caret at the live edge while streaming. It fades out (without reflow) when the stream completes.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/streaming-text/index.ts",
          "type": "registry:ui",
          "content": "export { StreamingText, type StreamingTextProps } from \"./streaming-text\";\nexport {\n  type StreamingChunk,\n  type UseStreamingTextOptions,\n  type UseStreamingTextResult,\n  useStreamingText,\n} from \"./use-streaming-text\";\n"
        },
        {
          "target": "components/kura/streaming-text/streaming-text.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { type UseStreamingTextOptions, useStreamingText } from \"./use-streaming-text\";\n\nexport interface StreamingTextProps\n  extends Omit<React.ComponentProps<\"div\">, \"children\">,\n    UseStreamingTextOptions {\n  /** How each newly revealed word group enters. `blur` = fade + 2px blur (default). */\n  animation?: \"blur\" | \"fade\" | \"none\";\n  /** Show the caret while streaming. It fades out when the stream completes. Default true. */\n  caret?: boolean;\n}\n\nconst enter = {\n  blur: \"transition-[opacity,filter] duration-(--kura-duration-slower) ease-kura-out starting:opacity-0 starting:[filter:blur(3px)] motion-reduce:starting:[filter:none]\",\n  fade: \"transition-opacity duration-(--kura-duration-slow) ease-kura-out starting:opacity-0\",\n  none: \"\",\n} as const;\n\n/**\n * Renders model output that arrives token by token. Bursty input is buffered into an even\n * cadence, new word groups fade (and un-blur) in, and a soft caret marks the live edge. Accepts a\n * growing string (`text`) or an async iterable (`stream`). Never reflows earlier text.\n */\nexport function StreamingText({\n  text,\n  stream,\n  speed,\n  streaming,\n  onComplete,\n  animation = \"blur\",\n  caret = true,\n  className,\n  ...props\n}: StreamingTextProps) {\n  const { settled, chunks, isStreaming } = useStreamingText({\n    text,\n    stream,\n    speed,\n    streaming,\n    onComplete,\n  });\n  return (\n    <div\n      data-slot=\"streaming-text\"\n      data-status={isStreaming ? \"streaming\" : \"done\"}\n      aria-busy={isStreaming || undefined}\n      className={cn(\n        \"whitespace-pre-wrap break-words font-kura-sans text-[15px] text-kura-fg leading-relaxed\",\n        className,\n      )}\n      {...props}\n    >\n      {settled}\n      {chunks.map((chunk) => (\n        <span key={chunk.id} data-slot=\"streaming-text-chunk\" className={enter[animation]}>\n          {chunk.text}\n        </span>\n      ))}\n      {caret && (\n        <span\n          aria-hidden\n          data-slot=\"streaming-text-caret\"\n          data-visible={isStreaming}\n          className={cn(\n            \"ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.17em] rounded-full bg-kura-accent\",\n            \"transition-opacity duration-(--kura-duration-slow) ease-kura-out\",\n            \"data-[visible=true]:animate-kura-pulse data-[visible=false]:opacity-0 motion-reduce:animate-none\",\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/streaming-text/use-streaming-text.ts",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface UseStreamingTextOptions {\n  /** The text received so far. Pass a growing string; a non-append change restarts the reveal. */\n  text?: string;\n  /** An async iterable of chunks (e.g. tokens from `fetch` or the AI SDK). Takes precedence over `text`. */\n  stream?: AsyncIterable<string> | null;\n  /**\n   * Minimum reveal cadence in characters per second. Bursty input is buffered and released at\n   * this rate (faster when a backlog builds up, so the reveal never trails by more than ~½s).\n   * `0` disables smoothing: text appears exactly as it arrives. Default 90.\n   */\n  speed?: number;\n  /** More text is still coming. Defaults to true while `stream` is being read, otherwise false. */\n  streaming?: boolean;\n  /** Called once when the stream has ended and every character has been revealed. */\n  onComplete?: () => void;\n}\n\nexport interface StreamingChunk {\n  id: number;\n  text: string;\n}\n\nexport interface UseStreamingTextResult {\n  /** Everything received so far (not yet necessarily revealed). */\n  text: string;\n  /** The revealed text: `settled + chunks.join(\"\")`. */\n  displayed: string;\n  /** Revealed text whose enter animation has finished; render it as one plain text node. */\n  settled: string;\n  /** Recently revealed chunks, still animating in. Render each one in its own element. */\n  chunks: StreamingChunk[];\n  /** True while more text is expected or the reveal is still catching up. */\n  isStreaming: boolean;\n}\n\n/** How long a chunk keeps its own element before it is merged into the settled text node. */\nconst SETTLE_MS = 700;\n/** Minimum time between two revealed chunks — groups characters into words, not letters. */\nconst CADENCE_MS = 45;\n/** Extra characters/second per character of backlog, so the reveal drains a burst in ~⅓s. */\nconst CATCH_UP = 3;\n\nconst now = () => (typeof performance !== \"undefined\" ? performance.now() : Date.now());\nconst raf = (cb: (t: number) => void): number =>\n  typeof requestAnimationFrame === \"function\"\n    ? requestAnimationFrame(cb)\n    : (setTimeout(() => cb(now()), 16) as unknown as number);\nconst caf = (id: number) =>\n  typeof cancelAnimationFrame === \"function\" ? cancelAnimationFrame(id) : clearTimeout(id);\n\ninterface StreamReader {\n  text: string;\n  done: boolean;\n  subscribe: (listener: () => void) => () => void;\n}\n\n/**\n * One reader per iterable: an async iterator can only be consumed once, so effects that re-run\n * (React StrictMode, remounts) share it. The iterator is closed shortly after the last\n * subscriber leaves, which lets StrictMode's unmount/remount keep reading.\n */\nconst readers = new WeakMap<AsyncIterable<string>, StreamReader>();\n\nfunction readerFor(stream: AsyncIterable<string>): StreamReader {\n  const existing = readers.get(stream);\n  if (existing) return existing;\n  const iterator = stream[Symbol.asyncIterator]();\n  const listeners = new Set<() => void>();\n  let stopTimer: ReturnType<typeof setTimeout> | undefined;\n  const notify = () => {\n    for (const l of listeners) l();\n  };\n  const reader: StreamReader = {\n    text: \"\",\n    done: false,\n    subscribe(listener) {\n      clearTimeout(stopTimer);\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n        if (listeners.size === 0 && !reader.done) {\n          stopTimer = setTimeout(() => {\n            reader.done = true;\n            void iterator.return?.();\n          }, 50);\n        }\n      };\n    },\n  };\n  readers.set(stream, reader);\n  (async () => {\n    try {\n      while (!reader.done) {\n        const { value, done } = await iterator.next();\n        if (done || reader.done) break;\n        reader.text += value;\n        notify();\n      }\n    } catch {\n      // A failed stream simply ends; the consumer surfaces the error state.\n    }\n    reader.done = true;\n    notify();\n  })();\n  return reader;\n}\n\n/**\n * Turns a growing string or an async iterable into a calm, evenly paced reveal. Returns the\n * revealed text split into a settled prefix and a few still-animating chunks (word groups),\n * so a renderer can fade only the newest words in without creating thousands of elements.\n */\nexport function useStreamingText({\n  text,\n  stream,\n  speed = 90,\n  streaming,\n  onComplete,\n}: UseStreamingTextOptions = {}): UseStreamingTextResult {\n  // --- source: a prop string or an async iterable ---------------------------------------------\n  const [streamed, setStreamed] = React.useState({ text: \"\", done: !stream });\n\n  React.useEffect(() => {\n    if (!stream) return;\n    const reader = readerFor(stream);\n    const sync = () => setStreamed({ text: reader.text, done: reader.done });\n    sync();\n    return reader.subscribe(sync);\n  }, [stream]);\n\n  const reading = !!stream && !streamed.done;\n  const source = stream ? streamed.text : (text ?? \"\");\n  const more = streaming ?? (stream ? reading : false);\n\n  // --- reveal ----------------------------------------------------------------------------------\n  // Text present at mount is shown immediately (no replay on remount or hydration).\n  const initial = stream ? \"\" : source;\n  const [state, setState] = React.useState<{ settled: string; chunks: StreamingChunk[] }>({\n    settled: initial,\n    chunks: [],\n  });\n  const shown = React.useRef(initial.length);\n  const sourceRef = React.useRef(source);\n  sourceRef.current = source;\n  const chunkTimes = React.useRef(new Map<number, number>());\n  const nextId = React.useRef(0);\n  const [revealing, setRevealing] = React.useState(false);\n\n  const settle = React.useCallback((all: boolean) => {\n    const t = now();\n    setState((s) => {\n      let i = 0;\n      while (\n        i < s.chunks.length &&\n        (all || t - (chunkTimes.current.get(s.chunks[i]!.id) ?? 0) >= SETTLE_MS)\n      )\n        i++;\n      if (i === 0) return s;\n      for (const c of s.chunks.slice(0, i)) chunkTimes.current.delete(c.id);\n      return {\n        settled:\n          s.settled +\n          s.chunks\n            .slice(0, i)\n            .map((c) => c.text)\n            .join(\"\"),\n        chunks: s.chunks.slice(i),\n      };\n    });\n  }, []);\n\n  const revealed = React.useRef(initial);\n  const emit = React.useCallback(\n    (piece: string) => {\n      revealed.current += piece;\n      const id = nextId.current++;\n      chunkTimes.current.set(id, now());\n      setState((s) => ({ settled: s.settled, chunks: [...s.chunks, { id, text: piece }] }));\n      settle(false);\n    },\n    [settle],\n  );\n\n  const speedRef = React.useRef(speed);\n  speedRef.current = speed;\n  const loop = React.useRef({ frame: 0, running: false, last: 0, lastEmit: 0, budget: 0 });\n\n  const tick = React.useCallback(\n    (t: number) => {\n      const l = loop.current;\n      const target = sourceRef.current;\n      const dt = Math.max(0, Math.min(0.1, (t - l.last) / 1000));\n      l.last = t;\n      const backlog = target.length - shown.current;\n      l.budget += (speedRef.current + backlog * CATCH_UP) * dt;\n      if (t - l.lastEmit >= CADENCE_MS && l.budget >= 1) {\n        let end = Math.min(target.length, shown.current + Math.floor(l.budget));\n        // Finish the current word so chunks are word groups, never broken letters.\n        const rest = target.slice(end);\n        const ws = rest.search(/\\s/);\n        if (ws > 0 && ws <= 12) end += ws;\n        else if (ws < 0 && rest.length <= 12) end = target.length;\n        l.budget = Math.max(0, l.budget - (end - shown.current));\n        emit(target.slice(shown.current, end));\n        shown.current = end;\n        l.lastEmit = t;\n      }\n      if (shown.current < target.length) {\n        l.frame = raf(tick);\n      } else {\n        l.running = false;\n        l.budget = 0;\n        setRevealing(false);\n      }\n    },\n    [emit],\n  );\n\n  React.useEffect(() => {\n    const displayed = revealed.current;\n    // Non-append change (new message, regenerate): restart from the common prefix.\n    if (!source.startsWith(displayed)) {\n      let common = 0;\n      while (common < source.length && source[common] === displayed[common]) common++;\n      shown.current = common;\n      revealed.current = source.slice(0, common);\n      chunkTimes.current.clear();\n      setState({ settled: source.slice(0, common), chunks: [] });\n    }\n    if (shown.current >= source.length) return;\n    if (speed <= 0) {\n      emit(source.slice(shown.current));\n      shown.current = source.length;\n      return;\n    }\n    const l = loop.current;\n    if (l.running) return;\n    l.running = true;\n    l.last = now();\n    setRevealing(true);\n    l.frame = raf(tick);\n  }, [source, speed, emit, tick]);\n\n  React.useEffect(\n    () => () => {\n      caf(loop.current.frame);\n      loop.current.running = false;\n    },\n    [],\n  );\n\n  // Merge the last chunks into the settled node once their animation has finished.\n  React.useEffect(() => {\n    if (revealing || state.chunks.length === 0) return;\n    const id = setTimeout(() => settle(true), SETTLE_MS);\n    return () => clearTimeout(id);\n  }, [revealing, state.chunks.length, settle]);\n\n  // --- completion ------------------------------------------------------------------------------\n  const displayed = state.settled + state.chunks.map((c) => c.text).join(\"\");\n  // Derived from the render, not the loop state, so a stream that ends in the same tick as its\n  // last token does not flip to \"done\" before the reveal has started.\n  const isStreaming = more || revealing || displayed !== source;\n  const onCompleteRef = React.useRef(onComplete);\n  onCompleteRef.current = onComplete;\n  const wasStreaming = React.useRef(isStreaming);\n  React.useEffect(() => {\n    if (wasStreaming.current && !isStreaming) onCompleteRef.current?.();\n    wasStreaming.current = isStreaming;\n  }, [isStreaming]);\n\n  return { text: source, displayed, settled: state.settled, chunks: state.chunks, isStreaming };\n}\n"
        }
      ]
    },
    {
      "slug": "thinking-indicator",
      "name": "ThinkingIndicator",
      "category": "feedback",
      "status": "beta",
      "description": "Compact AI “thinking” indicators — dots, pulse, orbit, wave, or cycling status text — inside a polite live region.",
      "whenToUse": [
        "The assistant has received a prompt but not produced its first token yet.",
        "A pending assistant message bubble or a placeholder row in a chat list.",
        "Cycling short status lines while an agent works: “Searching…”, “Reading 4 files…” (variant text).",
        "A tiny inline signal next to a label, tab or button that work is in progress."
      ],
      "whenNotToUse": [
        "Shimmering an existing label in place — use shimmer-text.",
        "Network or file loading that is not model thinking — use spinner.",
        "A whole container being worked on — use border-beam.",
        "A voice agent's state — use agent-orb.",
        "Streaming the answer itself — use streaming-text."
      ],
      "props": [
        {
          "name": "variant",
          "type": "\"dots\" | \"pulse\" | \"orbit\" | \"wave\" | \"text\"",
          "default": "\"dots\"",
          "description": "dots: three bouncing dots (staggered spring). pulse: a breathing dot with an expanding ring. orbit: two dots chasing round a track. wave: four equaliser bars. text: a pulse plus shimmering status text that cycles through `messages`."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "default": "\"md\"",
          "description": "Dot size 4 / 6 / 8 px; text 12 / 14 / 15 px."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Thinking\"",
          "description": "Accessible status text. Announced once when the indicator mounts or the label changes — never on each animation cycle."
        },
        {
          "name": "messages",
          "type": "readonly string[]",
          "description": "Status lines for the text variant. Several lines cycle with a cross-fade (and only `label` is announced); a single line is shown and announced as is — update it as the agent progresses."
        },
        {
          "name": "interval",
          "type": "number",
          "default": "2400",
          "description": "Milliseconds each message stays up when cycling (minimum 600)."
        }
      ],
      "parts": [
        {
          "name": "ThinkingIndicator",
          "description": "Inline root with role=status. `data-variant` and `data-size` mirror the props. Colour is currentColor (text-kura-accent by default).",
          "dataSlot": "thinking-indicator"
        },
        {
          "name": "Shape",
          "description": "The animated glyph (aria-hidden).",
          "dataSlot": "thinking-indicator-shape"
        },
        {
          "name": "Text",
          "description": "Text variant only: all messages stacked in one grid cell (width = widest message, so no layout shift); the active one has data-active=true.",
          "dataSlot": "thinking-indicator-text"
        }
      ],
      "a11y": {
        "role": "status",
        "notes": [
          "role=status + aria-live=polite: screen readers hear the label once, politely, not a stream of updates.",
          "Shapes are aria-hidden; a visually hidden label carries the meaning.",
          "When several messages cycle, the cycling text is aria-hidden and only `label` is announced, so the live region does not spam.",
          "Colour is not the only signal: the text variant and the label describe the state."
        ],
        "keyboard": [],
        "reducedMotion": "Loops stop. Dots show three static dots of rising opacity, the pulse is a still dot, orbit freezes with both dots visible, wave shows static bars of varied heights. Text still cycles but with an opacity cross-fade only (no blur, no rise, no shimmer)."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "shimmer-text"
        ]
      },
      "related": [
        "shimmer-text",
        "spinner",
        "border-beam",
        "agent-orb",
        "streaming-text",
        "message"
      ],
      "tags": [
        "loading",
        "thinking",
        "typing",
        "dots",
        "status",
        "indicator",
        "pending",
        "ai"
      ],
      "examples": [
        {
          "name": "ThinkingIndicatorMinimal",
          "title": "Thinking dots",
          "kind": "minimal",
          "code": "import { ThinkingIndicator } from \"@kura-ui/react/thinking-indicator\";\n\nexport function ThinkingIndicatorMinimal() {\n  return <ThinkingIndicator />;\n}\n"
        },
        {
          "name": "ThinkingIndicatorFull",
          "title": "Every variant and size",
          "description": "dots, pulse, orbit and wave at sm/md/lg, cycling text, and a muted recolour.",
          "kind": "full",
          "code": "import * as React from \"react\";\nimport {\n  ThinkingIndicator,\n  type ThinkingIndicatorSize,\n  type ThinkingIndicatorVariant,\n} from \"@kura-ui/react/thinking-indicator\";\n\nconst variants: ThinkingIndicatorVariant[] = [\"dots\", \"pulse\", \"orbit\", \"wave\"];\n\nconst sizes: ThinkingIndicatorSize[] = [\"sm\", \"md\", \"lg\"];\n\nexport function ThinkingIndicatorFull() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-6\">\n      <div className=\"grid grid-cols-[auto_repeat(3,1fr)] items-center gap-x-6 gap-y-4 text-kura-fg-subtle text-xs\">\n        <span />\n        {sizes.map((s) => (\n          <span key={s} className=\"font-kura-mono\">\n            {s}\n          </span>\n        ))}\n        {variants.map((v) => (\n          <React.Fragment key={v}>\n            <span className=\"font-kura-mono\">{v}</span>\n            {sizes.map((s) => (\n              <span key={s} className=\"flex h-6 items-center\">\n                <ThinkingIndicator variant={v} size={s} label={`Thinking (${v}, ${s})`} />\n              </span>\n            ))}\n          </React.Fragment>\n        ))}\n      </div>\n      <div className=\"flex flex-col gap-3 border-kura-border-subtle border-t pt-5\">\n        <ThinkingIndicator\n          variant=\"text\"\n          label=\"Researching\"\n          messages={[\"Searching the web…\", \"Reading 4 files…\", \"Comparing 12 sources…\"]}\n          interval={2200}\n        />\n        <ThinkingIndicator variant=\"dots\" size=\"sm\" className=\"text-kura-fg-subtle\" />\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ThinkingIndicatorInMessage",
          "title": "Pending assistant message",
          "description": "Text variant inside a message bubble with a Stop Button.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Bot } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ThinkingIndicator } from \"@kura-ui/react/thinking-indicator\";\n\nexport function ThinkingIndicatorInMessage() {\n  const [stopped, setStopped] = React.useState(false);\n  return (\n    <div className=\"flex w-full max-w-md items-start gap-3\">\n      <span className=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-kura-accent-subtle text-kura-accent-text\">\n        <Bot className=\"size-3.5\" aria-hidden />\n      </span>\n      <div className=\"flex min-w-0 flex-1 flex-col gap-2 rounded-kura-xl bg-kura-surface px-4 py-3 shadow-kura-sm\">\n        {stopped ? (\n          <p className=\"text-kura-fg-muted text-sm\">Stopped. Ask again whenever you're ready.</p>\n        ) : (\n          <ThinkingIndicator\n            variant=\"text\"\n            label=\"Assistant is working\"\n            messages={[\"Planning the migration…\", \"Reading schema.prisma…\", \"Drafting SQL…\"]}\n          />\n        )}\n        <div className=\"flex justify-end\">\n          <Button size=\"xs\" variant=\"ghost\" onClick={() => setStopped((s) => !s)}>\n            {stopped ? \"Retry\" : \"Stop\"}\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Recolour with a text class (`className=\"text-kura-fg-subtle\"`); shapes use currentColor. Keep the jade default when it means “the model is working”.",
        "Replace the indicator with the streamed message once the first token arrives; do not show both.",
        "For real progress, pass one message and update it (`messages={[step]}`) — each change is announced. For ambient variety, pass several and let them cycle.",
        "Keep messages short (≤ 5 words) and end with an ellipsis."
      ],
      "title": "Thinking Indicator",
      "importPath": "@kura-ui/react/thinking-indicator",
      "exports": [
        "ThinkingIndicator",
        "ThinkingIndicatorProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/thinking-indicator",
      "markdownUrl": "https://kuro.abstergo.fyi/components/thinking-indicator.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/thinking-indicator.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/thinking-indicator.json",
        "cli": "npx @kura-ui/cli add thinking-indicator",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "shimmer-text",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "variant": {
            "description": "dots: three bouncing dots (staggered spring). pulse: a breathing dot with an expanding ring. orbit: two dots chasing round a track. wave: four equaliser bars. text: a pulse plus shimmering status text that cycles through `messages`.",
            "x-ts-type": "\"dots\" | \"pulse\" | \"orbit\" | \"wave\" | \"text\"",
            "type": "string",
            "enum": [
              "dots",
              "orbit",
              "pulse",
              "text",
              "wave"
            ],
            "default": "dots"
          },
          "size": {
            "description": "Dot size 4 / 6 / 8 px; text 12 / 14 / 15 px.",
            "x-ts-type": "\"sm\" | \"md\" | \"lg\"",
            "type": "string",
            "enum": [
              "lg",
              "md",
              "sm"
            ],
            "default": "md"
          },
          "label": {
            "description": "Accessible status text. Announced once when the indicator mounts or the label changes — never on each animation cycle.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Thinking"
          },
          "messages": {
            "description": "Status lines for the text variant. Several lines cycle with a cross-fade (and only `label` is announced); a single line is shown and announced as is — update it as the agent progresses.",
            "x-ts-type": "readonly string[]",
            "type": "array"
          },
          "interval": {
            "description": "Milliseconds each message stays up when cycling (minimum 600).",
            "x-ts-type": "number",
            "type": "number",
            "default": 2400
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/thinking-indicator/index.ts",
          "type": "registry:ui",
          "content": "export { ThinkingIndicator, type ThinkingIndicatorProps } from \"./thinking-indicator\";\n"
        },
        {
          "target": "components/kura/thinking-indicator/thinking-indicator.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type ThinkingIndicatorVariant = \"dots\" | \"pulse\" | \"orbit\" | \"wave\" | \"text\";\nexport type ThinkingIndicatorSize = \"sm\" | \"md\" | \"lg\";\n\nconst CSS = `\n@keyframes kura-ti-dot {\n  0%, 64%, 100% { transform: translateY(0); opacity: 0.35; animation-timing-function: cubic-bezier(0.34, 1.5, 0.64, 1); }\n  32% { transform: translateY(-70%); opacity: 1; animation-timing-function: cubic-bezier(0.55, 0, 0.45, 1); }\n}\n@keyframes kura-ti-ring { from { transform: scale(1); opacity: 0.55; } to { transform: scale(2.8); opacity: 0; } }\n@keyframes kura-ti-breathe { 0%, 100% { transform: scale(0.82); } 50% { transform: scale(1); } }\n@keyframes kura-ti-orbit { to { transform: rotate(360deg); } }\n@keyframes kura-ti-bar { 0%, 100% { transform: scaleY(0.32); opacity: 0.5; } 50% { transform: scaleY(1); opacity: 1; } }\n`;\n\nconst SIZE: Record<ThinkingIndicatorSize, string> = {\n  sm: \"[--ti-dot:4px] [--ti-gap:3px]\",\n  md: \"[--ti-dot:6px] [--ti-gap:4px]\",\n  lg: \"[--ti-dot:8px] [--ti-gap:5px]\",\n};\n\nconst TEXT_SIZE: Record<ThinkingIndicatorSize, string> = {\n  sm: \"text-xs\",\n  md: \"text-sm\",\n  lg: \"text-[15px]\",\n};\n\nexport interface ThinkingIndicatorProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Visual style. `text` cycles through `messages`. Default \"dots\". */\n  variant?: ThinkingIndicatorVariant;\n  /** Overall scale. Default \"md\". */\n  size?: ThinkingIndicatorSize;\n  /** Accessible status text announced to screen readers. Default \"Thinking\". */\n  label?: string;\n  /**\n   * Status lines for the `text` variant, e.g. [\"Searching…\", \"Reading 4 files…\"]. Several lines\n   * cycle on a timer with a cross-fade; a single line is shown (and announced) as is.\n   */\n  messages?: readonly string[];\n  /** Milliseconds each message stays up when cycling. Default 2400. */\n  interval?: number;\n}\n\nconst motion = \"motion-reduce:animate-none\";\n\nfunction Dots() {\n  return (\n    <span data-slot=\"thinking-indicator-shape\" className=\"inline-flex items-center gap-(--ti-gap)\">\n      {[0, 1, 2].map((i) => (\n        <span\n          key={i}\n          className={cn(\n            \"size-(--ti-dot) rounded-full bg-current [animation:kura-ti-dot_1.25s_infinite]\",\n            motion,\n            i === 0 && \"motion-reduce:opacity-40\",\n            i === 1 && \"motion-reduce:opacity-70\",\n          )}\n          style={{ animationDelay: `${i * 0.14}s` }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Pulse() {\n  return (\n    <span\n      data-slot=\"thinking-indicator-shape\"\n      className=\"relative inline-flex size-[calc(var(--ti-dot)*2.6)] items-center justify-center\"\n    >\n      <span\n        className={cn(\n          \"absolute size-(--ti-dot) rounded-full bg-current [animation:kura-ti-ring_1.6s_cubic-bezier(0.23,1,0.32,1)_infinite]\",\n          motion,\n          \"motion-reduce:opacity-0\",\n        )}\n      />\n      <span\n        className={cn(\n          \"size-[calc(var(--ti-dot)*1.25)] rounded-full bg-current [animation:kura-ti-breathe_1.6s_cubic-bezier(0.77,0,0.175,1)_infinite]\",\n          motion,\n        )}\n      />\n    </span>\n  );\n}\n\nfunction Orbit() {\n  return (\n    <span\n      data-slot=\"thinking-indicator-shape\"\n      className=\"relative inline-flex size-[calc(var(--ti-dot)*3.2)] items-center justify-center\"\n    >\n      <span className=\"absolute inset-[calc(var(--ti-dot)*0.5)] rounded-full border border-current opacity-20\" />\n      {[0, 1].map((i) => (\n        <span\n          key={i}\n          className={cn(\n            \"absolute inset-0 [animation:kura-ti-orbit_1.4s_cubic-bezier(0.45,0.05,0.55,0.95)_infinite]\",\n            motion,\n          )}\n          style={{\n            animationDelay: `${-i * 0.18}s`,\n            transform: `rotate(${i * 140}deg)`,\n          }}\n        >\n          <span\n            className=\"absolute top-0 left-1/2 size-(--ti-dot) -translate-x-1/2 rounded-full bg-current\"\n            style={{ opacity: i === 0 ? 1 : 0.45, scale: i === 0 ? \"1\" : \"0.75\" }}\n          />\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction Wave() {\n  const still = [0.45, 0.8, 1, 0.6];\n  return (\n    <span\n      data-slot=\"thinking-indicator-shape\"\n      className=\"inline-flex h-[calc(var(--ti-dot)*2.6)] items-center gap-[calc(var(--ti-gap)*0.6)]\"\n    >\n      {still.map((s, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"h-full w-[calc(var(--ti-dot)*0.55)] rounded-full bg-current [animation:kura-ti-bar_1s_cubic-bezier(0.77,0,0.175,1)_infinite]\",\n            motion,\n            \"motion-reduce:scale-y-(--ti-still)\",\n          )}\n          style={{ animationDelay: `${i * 0.12}s`, [\"--ti-still\" as string]: s }}\n        />\n      ))}\n    </span>\n  );\n}\n\n/**\n * Compact AI \"thinking\" indicators — dots, pulse, orbit, wave, or cycling status text — wrapped in\n * a polite live region. Shapes use `currentColor` (jade by default); restyle with a text-colour\n * class. CSS-only animation; everything settles to a still glyph under reduced motion.\n */\nexport function ThinkingIndicator({\n  variant = \"dots\",\n  size = \"md\",\n  label = \"Thinking\",\n  messages,\n  interval = 2400,\n  className,\n  ...props\n}: ThinkingIndicatorProps) {\n  const lines = messages?.length ? messages : [`${label}…`];\n  const cycling = variant === \"text\" && lines.length > 1;\n  const [index, setIndex] = React.useState(0);\n  const key = lines.join(\"\\n\");\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: restart when the message list changes.\n  React.useEffect(() => {\n    setIndex(0);\n    if (!cycling) return;\n    const id = setInterval(() => setIndex((i) => (i + 1) % lines.length), Math.max(600, interval));\n    return () => clearInterval(id);\n  }, [cycling, key, interval]);\n\n  const shape =\n    variant === \"dots\" ? (\n      <Dots />\n    ) : variant === \"pulse\" ? (\n      <Pulse />\n    ) : variant === \"orbit\" ? (\n      <Orbit />\n    ) : variant === \"wave\" ? (\n      <Wave />\n    ) : null;\n\n  return (\n    <span\n      role=\"status\"\n      aria-live=\"polite\"\n      data-slot=\"thinking-indicator\"\n      data-variant={variant}\n      data-size={size}\n      className={cn(\n        \"inline-flex items-center gap-2 align-middle text-kura-accent\",\n        SIZE[size],\n        className,\n      )}\n      {...props}\n    >\n      <style href=\"kura-thinking-indicator\" precedence=\"kura\">\n        {CSS}\n      </style>\n      {shape && (\n        <>\n          <span aria-hidden className=\"contents\">\n            {shape}\n          </span>\n          <span className=\"sr-only\">{label}</span>\n        </>\n      )}\n      {variant === \"text\" && (\n        <>\n          <span aria-hidden className=\"contents\">\n            <Pulse />\n          </span>\n          {cycling && <span className=\"sr-only\">{label}</span>}\n          <span\n            data-slot=\"thinking-indicator-text\"\n            aria-hidden={cycling || undefined}\n            className={cn(\"grid text-kura-fg-muted\", TEXT_SIZE[size])}\n          >\n            {lines.map((line, i) => (\n              <span\n                key={`${i}-${line}`}\n                data-active={i === index}\n                className={cn(\n                  \"col-start-1 row-start-1 whitespace-nowrap transition-[opacity,filter,translate] duration-(--kura-duration-slow) ease-kura-out motion-reduce:translate-y-0 motion-reduce:blur-none\",\n                  i === index ? \"opacity-100\" : \"translate-y-0.5 opacity-0 blur-[2px]\",\n                )}\n              >\n                {i === index ? <ShimmerText>{line}</ShimmerText> : line}\n              </span>\n            ))}\n          </span>\n        </>\n      )}\n    </span>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "tool-call",
      "name": "ToolCall",
      "category": "agent",
      "status": "beta",
      "description": "One tool invocation: name and icon, queued/running/done/failed/awaiting-approval status, live elapsed time and collapsible JSON args and result.",
      "whenToUse": [
        "Show a function/tool call an LLM made (AI SDK tool parts, MCP calls, OpenAI/Anthropic tool_use blocks) inside an assistant message.",
        "Show a tool's progress with a live timer, then its result or error.",
        "A tool that needs the user's go-ahead: status=\"awaiting-approval\" with Approve/Deny buttons in `actions`.",
        "Compact inline mentions of tool use within answer text (variant=\"inline\")."
      ],
      "whenNotToUse": [
        "A multi-step plan with many steps and progress — use agent-timeline (put ToolCalls inside steps if needed).",
        "A dedicated, prominent permission request with risk level and keyboard shortcuts — use approval-prompt.",
        "Plain code output — use code-block.",
        "Model thinking — use reasoning."
      ],
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "description": "Tool identifier, e.g. \"search_web\". Rendered in mono when no `title` is given; also used in the status announcement."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "description": "Human label, e.g. \"Searching the web\" → \"Searched the web\". Replaces `name` visually."
        },
        {
          "name": "icon",
          "type": "React.ReactNode",
          "description": "Tool icon (lucide), e.g. <Globe />. Default a wrench."
        },
        {
          "name": "status",
          "type": "\"pending\" | \"running\" | \"success\" | \"error\" | \"awaiting-approval\"",
          "default": "\"running\"",
          "description": "Lifecycle. pending = hollow ring; running = jade pulse + shimmering title + ticking timer; success = check; error = cross (details open by default); awaiting-approval = jade hand."
        },
        {
          "name": "args",
          "type": "unknown",
          "description": "Arguments. Objects are pretty-printed JSON with syntax tint (CodeBlock); strings render as-is."
        },
        {
          "name": "result",
          "type": "unknown",
          "description": "Output. Objects are pretty-printed JSON; strings render as-is. Long values collapse at 220px with “Show N more lines”."
        },
        {
          "name": "error",
          "type": "React.ReactNode",
          "description": "Error message for status=\"error\", shown in a danger panel in the details."
        },
        {
          "name": "summary",
          "type": "React.ReactNode",
          "description": "One-line summary next to the title (truncated). Default: the first primitive value in `args`. Pass null to hide."
        },
        {
          "name": "startedAt",
          "type": "Date | number",
          "description": "Start time for the live timer (e.g. from the server). Default: when status became running."
        },
        {
          "name": "duration",
          "type": "number",
          "description": "Final duration in ms (persisted calls). Default: measured from running → success/error."
        },
        {
          "name": "variant",
          "type": "\"card\" | \"inline\"",
          "default": "\"card\"",
          "description": "card = bordered card; inline = compact borderless row for use inside message text."
        },
        {
          "name": "open",
          "type": "boolean",
          "description": "Details expanded (controlled)."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "description": "Initial expanded state when uncontrolled. Default true for \"error\" and \"awaiting-approval\"."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when details are toggled."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Always-visible footer row (right-aligned), e.g. Deny / Approve buttons."
        }
      ],
      "parts": [
        {
          "name": "ToolCall",
          "description": "`data-status`, `data-variant`, `data-state=\"open\" | \"closed\"` (when it has details).",
          "dataSlot": "tool-call"
        },
        {
          "name": "Trigger",
          "description": "Header row; a button with aria-expanded when there are details.",
          "dataSlot": "tool-call-trigger"
        },
        {
          "name": "Status",
          "description": "20px status glyph (aria-hidden).",
          "dataSlot": "tool-call-status"
        },
        {
          "name": "Icon",
          "description": "Tool icon (aria-hidden).",
          "dataSlot": "tool-call-icon"
        },
        {
          "name": "Title",
          "description": "Title or mono name; shimmers while running.",
          "dataSlot": "tool-call-title"
        },
        {
          "name": "Summary",
          "description": "Truncated one-line summary.",
          "dataSlot": "tool-call-summary"
        },
        {
          "name": "Elapsed",
          "description": "Mono tabular duration: 180ms, 1.5s, 1m 5s.",
          "dataSlot": "tool-call-elapsed"
        },
        {
          "name": "Details",
          "description": "Collapsible args/error/result (grid-rows transition, inert when closed).",
          "dataSlot": "tool-call-details"
        },
        {
          "name": "Section",
          "description": "“Arguments” / “Result” label + headerless CodeBlock.",
          "dataSlot": "tool-call-section"
        },
        {
          "name": "Error",
          "description": "Danger panel with the error message.",
          "dataSlot": "tool-call-error"
        },
        {
          "name": "Actions",
          "description": "Footer for `actions`.",
          "dataSlot": "tool-call-actions"
        }
      ],
      "a11y": {
        "notes": [
          "The header is a button (aria-expanded/aria-controls) only when there is something to expand.",
          "Each call has a polite status region (“search_web Running” → “search_web Completed”); the ticking timer is not announced.",
          "Status is conveyed by text for screen readers, not only by colour/icon.",
          "Collapsed details are inert; JSON panes are keyboard-scrollable regions named “Arguments code” / “Result code”."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Expand or collapse the details"
          },
          {
            "keys": "Tab",
            "action": "Into the JSON panes and footer actions"
          }
        ],
        "reducedMotion": "The running pulse and title shimmer stop; the chevron and details change without travel (fade only)."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "code-block",
          "shimmer-text"
        ]
      },
      "related": [
        "agent-timeline",
        "approval-prompt",
        "code-block",
        "reasoning",
        "message"
      ],
      "tags": [
        "tool",
        "function-calling",
        "agent",
        "mcp",
        "status",
        "json",
        "timer",
        "collapsible"
      ],
      "examples": [
        {
          "name": "ToolCallMinimal",
          "title": "Finished call",
          "description": "Name, args and a string result.",
          "kind": "minimal",
          "code": "import { ToolCall } from \"@kura-ui/react/tool-call\";\n\nexport function ToolCallMinimal() {\n  return (\n    <ToolCall\n      name=\"read_file\"\n      status=\"success\"\n      duration={182}\n      args={{ path: \"src/app/page.tsx\" }}\n      result=\"export default function Page() {\\n  return <Chat />;\\n}\"\n      className=\"w-full max-w-lg\"\n    />\n  );\n}\n"
        },
        {
          "name": "ToolCallFull",
          "title": "Live lifecycle",
          "description": "pending → running (shimmer + timer) → success with a JSON result; controlled open state.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Globe, RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ToolCall, type ToolCallStatus } from \"@kura-ui/react/tool-call\";\n\nconst RESULT = {\n  results: [\n    { title: \"useOptimistic – React\", url: \"https://react.dev/reference/react/useOptimistic\" },\n    { title: \"Server Actions and Mutations\", url: \"https://nextjs.org/docs/app/guides/forms\" },\n  ],\n  took_ms: 412,\n};\n\nexport function ToolCallFull() {\n  const [status, setStatus] = React.useState<ToolCallStatus>(\"pending\");\n  const [open, setOpen] = React.useState(false);\n  const [run, setRun] = React.useState(0);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the lifecycle\n  React.useEffect(() => {\n    setStatus(\"pending\");\n    const a = setTimeout(() => setStatus(\"running\"), 700);\n    const b = setTimeout(() => setStatus(\"success\"), 3100);\n    return () => {\n      clearTimeout(a);\n      clearTimeout(b);\n    };\n  }, [run]);\n\n  return (\n    <div className=\"flex w-full max-w-lg flex-col items-start gap-3\">\n      <ToolCall\n        name=\"search_web\"\n        title={status === \"success\" ? \"Searched the web\" : \"Searching the web\"}\n        icon={<Globe />}\n        status={status}\n        args={{ query: \"react useOptimistic rollback\", max_results: 2 }}\n        result={status === \"success\" ? RESULT : undefined}\n        summary=\"react useOptimistic rollback\"\n        variant=\"card\"\n        open={open}\n        onOpenChange={setOpen}\n        className=\"w-full\"\n      />\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => setRun((r) => r + 1)}>\n        <RotateCcw /> Replay\n      </Button>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ToolCallStates",
          "title": "All states",
          "description": "Queued, running, done and failed.",
          "kind": "state",
          "code": "import { FileText, Terminal } from \"lucide-react\";\nimport { ToolCall } from \"@kura-ui/react/tool-call\";\n\nexport function ToolCallStates() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-2\">\n      <ToolCall name=\"list_files\" status=\"pending\" args={{ dir: \"src/components\" }} />\n      <ToolCall\n        name=\"run_tests\"\n        icon={<Terminal />}\n        status=\"running\"\n        args={{ filter: \"message\" }}\n      />\n      <ToolCall\n        name=\"read_file\"\n        icon={<FileText />}\n        status=\"success\"\n        duration={96}\n        args={{ path: \"README.md\" }}\n        result=\"# Kura UI\"\n      />\n      <ToolCall\n        name=\"deploy_preview\"\n        status=\"error\"\n        duration={4210}\n        args={{ branch: \"feat/message\" }}\n        error=\"Error: build failed — Type error in message.tsx:295\"\n        defaultOpen={false}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "ToolCallInline",
          "title": "Inline",
          "description": "A compact row inside answer text.",
          "kind": "variant",
          "code": "import { Globe } from \"lucide-react\";\nimport { ToolCall } from \"@kura-ui/react/tool-call\";\n\nexport function ToolCallInline() {\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-2 text-[15px] text-kura-fg leading-relaxed\">\n      <p>I checked the docs before answering:</p>\n      <ToolCall\n        variant=\"inline\"\n        name=\"fetch_url\"\n        title=\"Read react.dev\"\n        icon={<Globe />}\n        status=\"success\"\n        duration={640}\n        args={{ url: \"https://react.dev/reference/react/useOptimistic\" }}\n        result={{ status: 200, bytes: 48213 }}\n      />\n      <p>The hook drops the optimistic value automatically when the action throws.</p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ToolCallApproval",
          "title": "Awaiting approval",
          "description": "Approve/Deny Buttons in the actions footer resolve the call.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Terminal } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { ToolCall, type ToolCallStatus } from \"@kura-ui/react/tool-call\";\n\nexport function ToolCallApproval() {\n  const [status, setStatus] = React.useState<ToolCallStatus>(\"awaiting-approval\");\n  return (\n    <ToolCall\n      name=\"run_command\"\n      title=\"Run a shell command\"\n      icon={<Terminal />}\n      status={status}\n      args={{ command: \"pnpm db:migrate --env production\" }}\n      result={status === \"success\" ? \"Applied 3 migrations.\" : undefined}\n      duration={status === \"success\" ? 1830 : undefined}\n      className=\"w-full max-w-lg\"\n      actions={\n        status === \"awaiting-approval\" ? (\n          <>\n            <Button variant=\"ghost\" size=\"sm\" onClick={() => setStatus(\"error\")}>\n              Deny\n            </Button>\n            <Button size=\"sm\" onClick={() => setStatus(\"success\")}>\n              Approve\n            </Button>\n          </>\n        ) : undefined\n      }\n      error={status === \"error\" ? \"Denied by user.\" : undefined}\n    />\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Map AI SDK tool part states: input-streaming → pending, input-available → running, output-available → success, output-error → error.",
        "Keep the same ToolCall mounted across states so the measured timer works; for history, pass `duration` (ms).",
        "Swap `title` between present and past tense (“Searching…” → “Searched the web”) as the status changes.",
        "args/result accept any JSON-serializable value; circular objects fall back to String()."
      ],
      "title": "Tool Call",
      "importPath": "@kura-ui/react/tool-call",
      "exports": [
        "ToolCall",
        "ToolCallProps",
        "ToolCallStatus",
        "formatDuration"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/tool-call",
      "markdownUrl": "https://kuro.abstergo.fyi/components/tool-call.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/tool-call.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/tool-call.json",
        "cli": "npx @kura-ui/cli add tool-call",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "code-block",
        "shimmer-text",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "name": {
            "description": "Tool identifier, e.g. \"search_web\". Rendered in mono when no `title` is given; also used in the status announcement.",
            "x-ts-type": "string",
            "type": "string"
          },
          "title": {
            "description": "Human label, e.g. \"Searching the web\" → \"Searched the web\". Replaces `name` visually.",
            "x-ts-type": "React.ReactNode"
          },
          "icon": {
            "description": "Tool icon (lucide), e.g. <Globe />. Default a wrench.",
            "x-ts-type": "React.ReactNode"
          },
          "status": {
            "description": "Lifecycle. pending = hollow ring; running = jade pulse + shimmering title + ticking timer; success = check; error = cross (details open by default); awaiting-approval = jade hand.",
            "x-ts-type": "\"pending\" | \"running\" | \"success\" | \"error\" | \"awaiting-approval\"",
            "type": "string",
            "enum": [
              "awaiting-approval",
              "error",
              "pending",
              "running",
              "success"
            ],
            "default": "running"
          },
          "args": {
            "description": "Arguments. Objects are pretty-printed JSON with syntax tint (CodeBlock); strings render as-is.",
            "x-ts-type": "unknown"
          },
          "result": {
            "description": "Output. Objects are pretty-printed JSON; strings render as-is. Long values collapse at 220px with “Show N more lines”.",
            "x-ts-type": "unknown"
          },
          "error": {
            "description": "Error message for status=\"error\", shown in a danger panel in the details.",
            "x-ts-type": "React.ReactNode"
          },
          "summary": {
            "description": "One-line summary next to the title (truncated). Default: the first primitive value in `args`. Pass null to hide.",
            "x-ts-type": "React.ReactNode"
          },
          "startedAt": {
            "description": "Start time for the live timer (e.g. from the server). Default: when status became running.",
            "x-ts-type": "Date | number"
          },
          "duration": {
            "description": "Final duration in ms (persisted calls). Default: measured from running → success/error.",
            "x-ts-type": "number",
            "type": "number"
          },
          "variant": {
            "description": "card = bordered card; inline = compact borderless row for use inside message text.",
            "x-ts-type": "\"card\" | \"inline\"",
            "type": "string",
            "enum": [
              "card",
              "inline"
            ],
            "default": "card"
          },
          "open": {
            "description": "Details expanded (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultOpen": {
            "description": "Initial expanded state when uncontrolled. Default true for \"error\" and \"awaiting-approval\".",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "onOpenChange": {
            "description": "Called when details are toggled.",
            "x-ts-type": "(open: boolean) => void"
          },
          "actions": {
            "description": "Always-visible footer row (right-aligned), e.g. Deny / Approve buttons.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/tool-call/index.ts",
          "type": "registry:ui",
          "content": "export { formatDuration, ToolCall, type ToolCallProps, type ToolCallStatus } from \"./tool-call\";\n"
        },
        {
          "target": "components/kura/tool-call/tool-call.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Check, ChevronRight, Hand, Wrench, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { CodeBlock } from \"@/components/kura/code-block\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type ToolCallStatus = \"pending\" | \"running\" | \"success\" | \"error\" | \"awaiting-approval\";\n\nexport interface ToolCallProps extends Omit<React.ComponentProps<\"div\">, \"title\"> {\n  /** The tool's identifier, e.g. \"search_web\". Rendered in mono. */\n  name: string;\n  /** Human label, e.g. \"Searching the web\". Shown instead of `name` when given. */\n  title?: React.ReactNode;\n  /** Icon for the tool, e.g. `<Globe />`. Default a wrench. */\n  icon?: React.ReactNode;\n  /** Lifecycle state. Default \"running\". */\n  status?: ToolCallStatus;\n  /** Arguments the model passed. Objects are pretty-printed as JSON; strings are shown as-is. */\n  args?: unknown;\n  /** The tool's output. Objects are pretty-printed as JSON; strings are shown as-is. */\n  result?: unknown;\n  /** Error message for status=\"error\". */\n  error?: React.ReactNode;\n  /** One-line summary next to the title. Default: the first primitive value in `args`. Pass null to hide. */\n  summary?: React.ReactNode;\n  /** When the call started (for the live timer). Default: when status became \"running\". */\n  startedAt?: Date | number;\n  /** Final duration in ms. Default: measured from running → done. */\n  duration?: number;\n  /** `card` = a bordered card (default); `inline` = a compact row inside message text. */\n  variant?: \"card\" | \"inline\";\n  /** Details (args/result) expanded (controlled). */\n  open?: boolean;\n  /** Initial expanded state when uncontrolled. Default: true for \"error\" and \"awaiting-approval\". */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Always-visible footer, e.g. Approve / Deny buttons while awaiting approval. */\n  actions?: React.ReactNode;\n}\n\nexport function formatDuration(ms: number): string {\n  if (ms < 1000) return `${Math.max(0, Math.round(ms / 10) * 10)}ms`;\n  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;\n  const s = Math.round(ms / 1000);\n  return `${Math.floor(s / 60)}m ${s % 60}s`;\n}\n\nconst statusText: Record<ToolCallStatus, string> = {\n  pending: \"Queued\",\n  running: \"Running\",\n  success: \"Completed\",\n  error: \"Failed\",\n  \"awaiting-approval\": \"Waiting for approval\",\n};\n\nfunction toText(value: unknown): string {\n  if (typeof value === \"string\") return value;\n  try {\n    return JSON.stringify(value, null, 2) ?? String(value);\n  } catch {\n    return String(value);\n  }\n}\n\nfunction deriveSummary(args: unknown): string | null {\n  if (args == null) return null;\n  if (typeof args !== \"object\") return String(args);\n  for (const v of Object.values(args as Record<string, unknown>)) {\n    if (typeof v === \"string\" || typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n  }\n  return null;\n}\n\nfunction StatusGlyph({ status, small }: { status: ToolCallStatus; small?: boolean }) {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"tool-call-status\"\n      className={cn(\n        \"relative grid shrink-0 place-items-center rounded-full transition-colors duration-(--kura-duration-base) ease-kura-out\",\n        small ? \"size-4 [&_svg]:size-2.5\" : \"size-5 [&_svg]:size-3\",\n        status === \"pending\" &&\n          \"text-kura-fg-subtle shadow-[inset_0_0_0_1px_var(--kura-border-strong)]\",\n        status === \"running\" && \"bg-kura-accent-subtle text-kura-accent-text\",\n        status === \"success\" && \"bg-kura-hover text-kura-success-text\",\n        status === \"error\" && \"bg-kura-danger-subtle text-kura-danger-text\",\n        status === \"awaiting-approval\" && \"bg-kura-accent-subtle text-kura-accent-text\",\n      )}\n    >\n      {status === \"running\" && (\n        <span className=\"size-1.5 animate-kura-pulse rounded-full bg-current motion-reduce:animate-none\" />\n      )}\n      {status === \"success\" && <Check strokeWidth={2.5} className=\"animate-kura-pop-in\" />}\n      {status === \"error\" && <X strokeWidth={2.5} className=\"animate-kura-pop-in\" />}\n      {status === \"awaiting-approval\" && <Hand className=\"animate-kura-pop-in\" />}\n    </span>\n  );\n}\n\nfunction useElapsed(status: ToolCallStatus, startedAt?: Date | number, duration?: number) {\n  const start = React.useRef<number | null>(startedAt != null ? +new Date(startedAt) : null);\n  const [now, setNow] = React.useState<number | null>(null);\n  const [final, setFinal] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    if (status === \"running\") {\n      start.current = startedAt != null ? +new Date(startedAt) : Date.now();\n      setFinal(null);\n      setNow(Date.now());\n      const id = setInterval(() => setNow(Date.now()), 100);\n      return () => clearInterval(id);\n    }\n    const began = start.current;\n    if ((status === \"success\" || status === \"error\") && began != null) {\n      setFinal((f) => f ?? Date.now() - began);\n    }\n  }, [status, startedAt]);\n  if (duration != null) return duration;\n  if (status === \"running\" && start.current != null && now != null) return now - start.current;\n  return final;\n}\n\n/**\n * One tool invocation: name and icon, a status glyph (queued, running, done, failed, awaiting\n * approval), a live elapsed timer, and collapsible pretty-printed arguments and result.\n */\nexport function ToolCall({\n  name,\n  title,\n  icon,\n  status = \"running\",\n  args,\n  result,\n  error,\n  summary,\n  startedAt,\n  duration,\n  variant = \"card\",\n  open: openProp,\n  defaultOpen,\n  onOpenChange,\n  actions,\n  className,\n  ...props\n}: ToolCallProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen ?? (status === \"error\" || status === \"awaiting-approval\"),\n    onChange: onOpenChange,\n  });\n  const elapsed = useElapsed(status, startedAt, duration);\n  const id = React.useId();\n  const hasDetails = args !== undefined || result !== undefined || error != null;\n  const line = summary === undefined ? deriveSummary(args) : summary;\n  const inline = variant === \"inline\";\n  const running = status === \"running\";\n\n  const label = title ?? <span className=\"font-kura-mono text-[0.93em]\">{name}</span>;\n\n  const headerContent = (\n    <>\n      <StatusGlyph status={status} small={inline} />\n      <span\n        aria-hidden\n        className=\"shrink-0 text-kura-fg-subtle [&_svg]:size-3.5\"\n        data-slot=\"tool-call-icon\"\n      >\n        {icon ?? <Wrench />}\n      </span>\n      <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n        <span data-slot=\"tool-call-title\" className=\"shrink-0 font-medium text-kura-fg\">\n          {running ? <ShimmerText>{label}</ShimmerText> : label}\n        </span>\n        {line != null && line !== \"\" && (\n          <span data-slot=\"tool-call-summary\" className=\"truncate text-kura-fg-subtle\">\n            {line}\n          </span>\n        )}\n      </span>\n      <span className=\"sr-only\">, {statusText[status]}</span>\n      {elapsed != null && (\n        <span\n          data-slot=\"tool-call-elapsed\"\n          className=\"shrink-0 font-kura-mono text-kura-fg-subtle text-xs tabular-nums\"\n        >\n          {formatDuration(elapsed)}\n        </span>\n      )}\n      {hasDetails && (\n        <ChevronRight\n          aria-hidden\n          className={cn(\n            \"size-3.5 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n            open && \"rotate-90\",\n          )}\n        />\n      )}\n    </>\n  );\n\n  const headerClass = cn(\n    \"flex w-full min-w-0 items-center gap-2 text-left outline-none\",\n    inline ? \"h-7 rounded-kura-sm px-1.5 text-[13px]\" : \"h-11 px-3 text-sm\",\n  );\n\n  return (\n    <div\n      data-slot=\"tool-call\"\n      data-status={status}\n      data-variant={variant}\n      data-state={hasDetails ? (open ? \"open\" : \"closed\") : undefined}\n      className={cn(\n        \"flex min-w-0 flex-col font-kura-sans\",\n        inline\n          ? \"-mx-1.5 w-fit max-w-full\"\n          : \"overflow-hidden rounded-kura-lg bg-kura-surface shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      {hasDetails ? (\n        <button\n          type=\"button\"\n          data-slot=\"tool-call-trigger\"\n          aria-expanded={open}\n          aria-controls={`${id}-details`}\n          onClick={() => setOpen(!open)}\n          className={cn(\n            headerClass,\n            \"cursor-pointer transition-[background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n            inline && \"focus-visible:shadow-kura-focus\",\n          )}\n        >\n          {headerContent}\n        </button>\n      ) : (\n        <div data-slot=\"tool-call-trigger\" className={headerClass}>\n          {headerContent}\n        </div>\n      )}\n      <span className=\"sr-only\" role=\"status\">\n        {name} {statusText[status]}\n      </span>\n\n      {hasDetails && (\n        <div\n          id={`${id}-details`}\n          data-slot=\"tool-call-details\"\n          inert={!open || undefined}\n          className={cn(\n            \"grid transition-[grid-template-rows,opacity] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-opacity\",\n            open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n          )}\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <div\n              className={cn(\n                \"flex flex-col gap-3\",\n                inline\n                  ? \"mt-1 mb-2 ml-[18px] border-kura-border border-l pl-4\"\n                  : \"px-3 pt-1 pb-3 shadow-[inset_0_1px_0_var(--kura-border-subtle)] [&>:first-child]:mt-2\",\n              )}\n            >\n              {args !== undefined && <Section label=\"Arguments\" value={args} />}\n              {error != null && (\n                <div\n                  data-slot=\"tool-call-error\"\n                  className=\"rounded-kura-md bg-kura-danger-subtle px-3 py-2 font-kura-mono text-kura-danger-text text-xs leading-relaxed\"\n                >\n                  {error}\n                </div>\n              )}\n              {result !== undefined && <Section label=\"Result\" value={result} />}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {actions != null && (\n        <div\n          data-slot=\"tool-call-actions\"\n          className={cn(\n            \"flex items-center justify-end gap-2\",\n            inline ? \"py-1.5\" : \"px-3 py-2.5 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\",\n          )}\n        >\n          {actions}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction Section({ label, value }: { label: string; value: unknown }) {\n  const text = toText(value);\n  const json = typeof value !== \"string\";\n  return (\n    <div data-slot=\"tool-call-section\" className=\"flex flex-col gap-1.5\">\n      <span className=\"font-medium text-[11px] text-kura-fg-subtle uppercase tracking-wider\">\n        {label}\n      </span>\n      <CodeBlock\n        code={text}\n        filename={label}\n        language={json ? \"json\" : \"text\"}\n        header={false}\n        wrap\n        maxHeight={220}\n        className=\"rounded-kura-md text-xs shadow-[0_0_0_1px_var(--kura-border-subtle)] [&_[data-slot=code-block-line]]:px-3 [&_section]:py-2\"\n      />\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "tooltip",
      "name": "Tooltip",
      "category": "primitives",
      "status": "stable",
      "description": "Short label on hover or focus with an optional shortcut, origin-aware motion and instant skip between tooltips.",
      "whenToUse": [
        "Name an icon-only control or reveal its shortcut.",
        "Brief supplementary hints (a few words)."
      ],
      "whenNotToUse": [
        "Interactive or rich content (links, buttons, forms) — use popover.",
        "Essential information — it is invisible on touch devices; put it in the UI.",
        "Labelling icon buttons — icon-button already includes a tooltip."
      ],
      "props": [
        {
          "name": "content",
          "type": "React.ReactNode",
          "required": true,
          "description": "The label. Keep it to a few words."
        },
        {
          "name": "children",
          "type": "React.ReactElement",
          "required": true,
          "description": "The trigger: one focusable element that accepts a ref."
        },
        {
          "name": "shortcut",
          "type": "readonly KeyName[]",
          "description": "Keys shown after the label, e.g. [\"mod\", \"enter\"]."
        },
        {
          "name": "side",
          "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
          "default": "\"top\"",
          "description": "Preferred side."
        },
        {
          "name": "align",
          "type": "\"start\" | \"center\" | \"end\"",
          "default": "\"center\"",
          "description": "Alignment on that side."
        },
        {
          "name": "delayDuration",
          "type": "number",
          "default": "500",
          "description": "Hover delay in ms before opening. Inside a TooltipProvider, defaults to the provider's."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "default": "false",
          "description": "Render only the trigger."
        },
        {
          "name": "contentClassName",
          "type": "string",
          "description": "Classes for the floating bubble."
        }
      ],
      "parts": [
        {
          "name": "TooltipProvider",
          "description": "Optional shared provider so moving between tooltips skips the delay."
        },
        {
          "name": "TooltipRoot",
          "description": "Radix Root, for fully custom compositions."
        },
        {
          "name": "TooltipTrigger",
          "description": "Radix Trigger."
        },
        {
          "name": "TooltipContent",
          "description": "Styled floating bubble; accepts `shortcut`.",
          "dataSlot": "tooltip-content",
          "props": [
            {
              "name": "shortcut",
              "type": "readonly KeyName[]",
              "description": "Keys shown after the label."
            }
          ]
        }
      ],
      "a11y": {
        "role": "tooltip",
        "notes": [
          "Opens on hover after a delay and immediately on keyboard focus; the trigger gets aria-describedby.",
          "Never the only way to reach information; not shown on touch."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Focus the trigger and open"
          },
          {
            "keys": "Escape",
            "action": "Close"
          }
        ],
        "reducedMotion": "Scale and travel are removed; only a fast fade remains."
      },
      "dependencies": {
        "npm": [
          "radix-ui"
        ],
        "kura": [
          "kbd"
        ]
      },
      "related": [
        "icon-button",
        "popover",
        "kbd"
      ],
      "tags": [
        "tooltip",
        "hint",
        "label",
        "hover",
        "overlay"
      ],
      "examples": [
        {
          "name": "TooltipMinimal",
          "title": "Basic",
          "kind": "minimal",
          "code": "import { Button } from \"@kura-ui/react/button\";\nimport { Tooltip } from \"@kura-ui/react/tooltip\";\n\nexport function TooltipMinimal() {\n  return (\n    <Tooltip content=\"Copy to clipboard\">\n      <Button variant=\"secondary\">Hover me</Button>\n    </Tooltip>\n  );\n}\n"
        },
        {
          "name": "TooltipFull",
          "title": "Shortcut, sides and shared provider",
          "kind": "full",
          "code": "import { Button } from \"@kura-ui/react/button\";\nimport { Tooltip, TooltipProvider } from \"@kura-ui/react/tooltip\";\n\nexport function TooltipFull() {\n  return (\n    <TooltipProvider>\n      <div className=\"flex items-center gap-2\">\n        <Tooltip content=\"Send message\" shortcut={[\"mod\", \"enter\"]} side=\"top\">\n          <Button variant=\"secondary\" size=\"sm\">\n            Top\n          </Button>\n        </Tooltip>\n        <Tooltip\n          content=\"New chat\"\n          shortcut={[\"mod\", \"shift\", \"o\"]}\n          side=\"bottom\"\n          delayDuration={200}\n        >\n          <Button variant=\"secondary\" size=\"sm\">\n            Bottom\n          </Button>\n        </Tooltip>\n        <Tooltip content=\"Disabled tooltip\" disabled>\n          <Button variant=\"ghost\" size=\"sm\">\n            No tooltip\n          </Button>\n        </Tooltip>\n      </div>\n    </TooltipProvider>\n  );\n}\n"
        },
        {
          "name": "TooltipEditorToolbar",
          "title": "Editor toolbar with shared delay",
          "description": "Tooltip parts under one TooltipProvider, so moving across the formatting toolbar skips the open delay.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Bold, Cloud, Code, Italic, Link, PenLine, Redo2, Undo2 } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from \"@kura-ui/react/tooltip\";\n\nconst formatTools = [\n  { id: \"bold\", label: \"Bold\", icon: <Bold />, shortcut: [\"mod\", \"b\"] },\n  { id: \"italic\", label: \"Italic\", icon: <Italic />, shortcut: [\"mod\", \"i\"] },\n  { id: \"code\", label: \"Inline code\", icon: <Code />, shortcut: [\"mod\", \"e\"] },\n  { id: \"link\", label: \"Link\", icon: <Link />, shortcut: [\"mod\", \"k\"] },\n] as const;\n\nfunction ToolbarTip({\n  label,\n  shortcut,\n  side = \"top\",\n  children,\n}: {\n  label: string;\n  shortcut?: readonly string[];\n  side?: \"top\" | \"bottom\";\n  children: React.ReactElement;\n}) {\n  return (\n    <TooltipRoot>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      <TooltipContent side={side} shortcut={shortcut}>\n        {label}\n      </TooltipContent>\n    </TooltipRoot>\n  );\n}\n\nconst square = \"size-7 px-0\";\n\nexport function TooltipEditorToolbar() {\n  const [active, setActive] = React.useState<string[]>([\"bold\"]);\n  const toggle = (id: string) =>\n    setActive((a) => (a.includes(id) ? a.filter((x) => x !== id) : [...a, id]));\n\n  return (\n    <TooltipProvider delayDuration={400}>\n      <div className=\"flex w-full max-w-xl flex-col overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm\">\n        <div\n          role=\"toolbar\"\n          aria-label=\"Formatting\"\n          className=\"flex items-center gap-0.5 border-kura-border-subtle border-b px-2 py-1.5\"\n        >\n          <ToolbarTip label=\"Undo\" shortcut={[\"mod\", \"z\"]}>\n            <Button variant=\"ghost\" size=\"sm\" aria-label=\"Undo\" className={square}>\n              <Undo2 />\n            </Button>\n          </ToolbarTip>\n          <ToolbarTip label=\"Redo\" shortcut={[\"mod\", \"shift\", \"z\"]}>\n            <Button variant=\"ghost\" size=\"sm\" aria-label=\"Redo\" className={square}>\n              <Redo2 />\n            </Button>\n          </ToolbarTip>\n          <span aria-hidden className=\"mx-1 h-4 w-px bg-kura-border\" />\n          {formatTools.map((tool) => (\n            <ToolbarTip key={tool.id} label={tool.label} shortcut={tool.shortcut}>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                aria-label={tool.label}\n                aria-pressed={active.includes(tool.id)}\n                onClick={() => toggle(tool.id)}\n                className={`${square} aria-pressed:bg-kura-active aria-pressed:text-kura-fg`}\n              >\n                {tool.icon}\n              </Button>\n            </ToolbarTip>\n          ))}\n          <span aria-hidden className=\"mx-1 h-4 w-px bg-kura-border\" />\n          <ToolbarTip label=\"Rewrite the selection\" shortcut={[\"mod\", \"j\"]}>\n            <Button variant=\"ghost\" size=\"sm\">\n              <PenLine />\n              Rewrite\n            </Button>\n          </ToolbarTip>\n          <div className=\"ml-auto flex items-center\">\n            <ToolbarTip label=\"Saved 12:04 · version 18\" side=\"bottom\">\n              <button\n                type=\"button\"\n                className=\"inline-flex h-7 cursor-default items-center gap-1.5 rounded-kura-sm px-2 text-kura-fg-subtle text-xs outline-none focus-visible:shadow-kura-focus\"\n              >\n                <Cloud className=\"size-3.5\" aria-hidden />\n                Saved\n              </button>\n            </ToolbarTip>\n          </div>\n        </div>\n        <p className=\"px-4 py-3.5 text-[15px] text-kura-fg leading-relaxed\">\n          <strong className=\"font-semibold\">Launch note:</strong> the new composer ships behind the{\" \"}\n          <code className=\"rounded-kura-xs bg-kura-surface-sunken px-1 py-0.5 font-kura-mono text-[13px]\">\n            composer_v2\n          </code>{\" \"}\n          flag on Thursday, starting with 10% of workspaces.\n        </p>\n      </div>\n    </TooltipProvider>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "The child must forward refs and accept event props (Kura Button/IconButton do). Wrap plain text in a <button>.",
        "<Tooltip> (and IconButton) creates its own provider only when standalone; inside a <TooltipProvider> it joins it, so wrapping a toolbar in TooltipProvider makes moving between sibling tooltips instant."
      ],
      "title": "Tooltip",
      "importPath": "@kura-ui/react/tooltip",
      "exports": [
        "Tooltip",
        "TooltipContent",
        "TooltipContentProps",
        "TooltipProps",
        "TooltipProvider",
        "TooltipProviderProps",
        "TooltipRoot",
        "TooltipTrigger"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/tooltip",
      "markdownUrl": "https://kuro.abstergo.fyi/components/tooltip.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/tooltip.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/tooltip.json",
        "cli": "npx @kura-ui/cli add tooltip",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kbd",
        "kura-keys",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "content": {
            "description": "The label. Keep it to a few words.",
            "x-ts-type": "React.ReactNode"
          },
          "children": {
            "description": "The trigger: one focusable element that accepts a ref.",
            "x-ts-type": "React.ReactElement"
          },
          "shortcut": {
            "description": "Keys shown after the label, e.g. [\"mod\", \"enter\"].",
            "x-ts-type": "readonly KeyName[]",
            "type": "array"
          },
          "side": {
            "description": "Preferred side.",
            "x-ts-type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
            "type": "string",
            "enum": [
              "bottom",
              "left",
              "right",
              "top"
            ],
            "default": "top"
          },
          "align": {
            "description": "Alignment on that side.",
            "x-ts-type": "\"start\" | \"center\" | \"end\"",
            "type": "string",
            "enum": [
              "center",
              "end",
              "start"
            ],
            "default": "center"
          },
          "delayDuration": {
            "description": "Hover delay in ms before opening. Inside a TooltipProvider, defaults to the provider's.",
            "x-ts-type": "number",
            "type": "number",
            "default": 500
          },
          "disabled": {
            "description": "Render only the trigger.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "contentClassName": {
            "description": "Classes for the floating bubble.",
            "x-ts-type": "string",
            "type": "string"
          }
        },
        "required": [
          "content",
          "children"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/tooltip/index.ts",
          "type": "registry:ui",
          "content": "export {\n  Tooltip,\n  TooltipContent,\n  type TooltipContentProps,\n  type TooltipProps,\n  TooltipProvider,\n  type TooltipProviderProps,\n  TooltipRoot,\n  TooltipTrigger,\n} from \"./tooltip\";\n"
        },
        {
          "target": "components/kura/tooltip/tooltip.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Tooltip as TooltipPrimitive } from \"radix-ui\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { Kbd } from \"@/components/kura/kbd\";\n\nexport interface TooltipProviderProps\n  extends React.ComponentProps<typeof TooltipPrimitive.Provider> {}\n\n/** True inside a `TooltipProvider`, so `<Tooltip>` joins it instead of creating its own. */\nconst InsideProvider = React.createContext(false);\n\n/**\n * Optional. Wrap an area (or the app) so moving between tooltips skips the open delay — this\n * includes every `<Tooltip>` and `IconButton` inside it. `<Tooltip>` works without it.\n */\nexport function TooltipProvider({\n  delayDuration = 500,\n  skipDelayDuration = 300,\n  children,\n  ...props\n}: TooltipProviderProps) {\n  return (\n    <TooltipPrimitive.Provider\n      delayDuration={delayDuration}\n      skipDelayDuration={skipDelayDuration}\n      {...props}\n    >\n      <InsideProvider.Provider value={true}>{children}</InsideProvider.Provider>\n    </TooltipPrimitive.Provider>\n  );\n}\n\nexport interface TooltipContentProps extends React.ComponentProps<typeof TooltipPrimitive.Content> {\n  /** Keyboard shortcut shown after the label, e.g. `[\"mod\", \"enter\"]`. */\n  shortcut?: readonly KeyName[];\n}\n\n/** The floating label. Origin-aware scale+fade in, instant when moving between tooltips. */\nexport function TooltipContent({\n  className,\n  sideOffset = 6,\n  shortcut,\n  children,\n  ...props\n}: TooltipContentProps) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"z-50 inline-flex max-w-xs select-none items-center gap-2 rounded-kura-sm bg-kura-ink px-2 py-1 font-kura-sans text-kura-fg-inverted text-xs leading-snug shadow-kura-md\",\n          \"origin-(--radix-tooltip-content-transform-origin)\",\n          \"data-[state=delayed-open]:animate-kura-pop-in data-[state=closed]:animate-kura-pop-out data-[state=instant-open]:animate-none\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        {shortcut && (\n          <Kbd\n            keys={shortcut}\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"-mr-1 [&_[data-slot=kbd-key]]:bg-white/15 [&_[data-slot=kbd-key]]:text-kura-fg-inverted\"\n          />\n        )}\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport const TooltipRoot = TooltipPrimitive.Root;\nexport const TooltipTrigger = TooltipPrimitive.Trigger;\n\nexport interface TooltipProps\n  extends Omit<React.ComponentProps<typeof TooltipPrimitive.Root>, \"children\"> {\n  /** The tooltip label. Keep it to a few words; never put interactive content in a tooltip. */\n  content: React.ReactNode;\n  /** The trigger. Must be a single focusable element that accepts a ref (e.g. <Button>). */\n  children: React.ReactElement;\n  /** Keyboard shortcut rendered as keys after the label. */\n  shortcut?: readonly KeyName[];\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Delay before opening in ms. Default 500, or the surrounding TooltipProvider's (0 when moving between tooltips). */\n  delayDuration?: number;\n  /** Disable without unmounting the trigger. */\n  disabled?: boolean;\n  /** Extra classes for the content bubble. */\n  contentClassName?: string;\n}\n\n/**\n * Label-on-hover/focus for an icon or control. Opens on hover after a delay and immediately on\n * keyboard focus; closes on Escape. Creates its own provider unless it sits inside a\n * `TooltipProvider`, which it then shares (instant hand-off between neighbouring tooltips).\n */\nexport function Tooltip({\n  content,\n  children,\n  shortcut,\n  side = \"top\",\n  align = \"center\",\n  delayDuration,\n  disabled = false,\n  contentClassName,\n  ...rootProps\n}: TooltipProps) {\n  const shared = React.useContext(InsideProvider);\n  if (disabled) return children;\n  const root = (\n    <TooltipPrimitive.Root data-slot=\"tooltip\" delayDuration={delayDuration} {...rootProps}>\n      <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>\n      <TooltipContent side={side} align={align} shortcut={shortcut} className={contentClassName}>\n        {content}\n      </TooltipContent>\n    </TooltipPrimitive.Root>\n  );\n  // Inside a TooltipProvider, join it so the skip-delay hand-off works across tooltips.\n  if (shared) return root;\n  return (\n    <TooltipPrimitive.Provider delayDuration={delayDuration ?? 500} skipDelayDuration={300}>\n      {root}\n    </TooltipPrimitive.Provider>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "voice-dictation",
      "name": "VoiceDictation",
      "category": "voice",
      "status": "experimental",
      "description": "Mic button that expands into a live waveform pill with a timer and live transcript; speech-to-text with recording fallback, toggle or hold-to-talk.",
      "whenToUse": [
        "Let users dictate a prompt instead of typing — standalone or in a composer toolbar.",
        "Push-to-talk input (mode=\"hold\") for voice notes or quick commands.",
        "Browsers without the Web Speech API: record audio and transcribe on your server via `transcribe`."
      ],
      "whenNotToUse": [
        "A full-duplex conversation with a voice agent (barge-in, spoken replies) — use agent-orb with your realtime session.",
        "Only visualising audio you already have — use audio-waveform.",
        "Typing prompts — use composer."
      ],
      "props": [
        {
          "name": "onTranscript",
          "type": "(text: string) => void",
          "description": "Called once per dictation with the final trimmed text. Not called when cancelled (× or Escape) or when nothing was heard."
        },
        {
          "name": "onInterim",
          "type": "(text: string) => void",
          "description": "Live text (final + interim) while listening, e.g. to preview in a placeholder."
        },
        {
          "name": "onStatusChange",
          "type": "(status: VoiceDictationStatus) => void",
          "description": "idle | requesting | listening | processing | denied | unsupported | error."
        },
        {
          "name": "mode",
          "type": "\"toggle\" | \"hold\"",
          "default": "\"toggle\"",
          "description": "toggle = click to start/stop; hold = dictate while the pointer, Space/Enter or the hotkey is held."
        },
        {
          "name": "hotkey",
          "type": "readonly KeyName[] | null",
          "default": "[\"mod\", \"shift\", \"d\"]",
          "description": "Global shortcut (works while typing). null disables it."
        },
        {
          "name": "lang",
          "type": "string",
          "description": "BCP-47 language for recognition, e.g. \"en-US\". Defaults to the page language."
        },
        {
          "name": "transcribe",
          "type": "(audio: Blob) => Promise<string>",
          "description": "Fallback transcription when the Web Speech API is missing (Firefox): receives the recording; status is “processing” until it resolves."
        },
        {
          "name": "simulate",
          "type": "boolean | string",
          "description": "Demo mode: fake permission, simulated waveform and a scripted transcript (a string is typed out word by word). For docs and tests."
        },
        {
          "name": "showTranscript",
          "type": "boolean",
          "default": "true",
          "description": "Show the live transcript card under the control. Turn off inside a composer."
        },
        {
          "name": "transcriptPlaceholder",
          "type": "string",
          "default": "\"Listening…\"",
          "description": "Text in the transcript card before words arrive."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "default": "false",
          "description": "Disable the control."
        }
      ],
      "parts": [
        {
          "name": "VoiceDictation",
          "description": "Root column. data-status (see onStatusChange) and data-mode.",
          "dataSlot": "voice-dictation"
        },
        {
          "name": "VoiceDictationControl",
          "description": "The mic button; while live it grows (layout spring) into a signal-tinted pill with waveform, timer and cancel.",
          "dataSlot": "voice-dictation-control"
        },
        {
          "name": "VoiceDictationButton",
          "description": "Mic / stop / mic-off button with aria-pressed and a tooltip showing the hotkey.",
          "dataSlot": "voice-dictation-button"
        },
        {
          "name": "VoiceDictationCancel",
          "description": "× button that discards the dictation.",
          "dataSlot": "voice-dictation-cancel"
        },
        {
          "name": "VoiceDictationTranscript",
          "description": "Live transcript: final words in ink, interim words subtle, a signal caret while listening.",
          "dataSlot": "voice-dictation-transcript"
        },
        {
          "name": "VoiceDictationMessage",
          "description": "Recovery text for denied / unsupported / error.",
          "dataSlot": "voice-dictation-message"
        }
      ],
      "a11y": {
        "notes": [
          "The button is labelled “Dictate” (or “Hold to dictate”) with aria-pressed reflecting listening; the stop/mic icon swap is visual only.",
          "A visually hidden role=status announces state changes once (“Listening”, “Transcribing”, “Microphone access is blocked”) — never per word.",
          "Denied and unsupported states keep a visible text explanation and the button stays reachable to retry.",
          "The waveform is decorative (aria-hidden); the timer is plain text with tabular numbers."
        ],
        "keyboard": [
          {
            "keys": "Enter / Space",
            "action": "Toggle dictation (hold them in hold mode)"
          },
          {
            "keys": "Mod+Shift+D",
            "action": "Global toggle / hold (configurable via hotkey)"
          },
          {
            "keys": "Escape",
            "action": "Cancel and discard while listening"
          }
        ],
        "reducedMotion": "The pill appears without the layout spring, icon swaps fade without scale or blur, the waveform becomes a slow level meter and the caret stops blinking."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "motion"
        ],
        "kura": [
          "audio-waveform",
          "shimmer-text",
          "spinner",
          "tooltip"
        ]
      },
      "related": [
        "composer",
        "audio-waveform",
        "agent-orb"
      ],
      "tags": [
        "voice",
        "dictation",
        "speech-to-text",
        "microphone",
        "push-to-talk",
        "transcription",
        "waveform"
      ],
      "examples": [
        {
          "name": "VoiceDictationMinimal",
          "title": "Toggle with live transcript",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport { VoiceDictation } from \"@kura-ui/react/voice-dictation\";\n\nexport function VoiceDictationMinimal() {\n  return (\n    <div className=\"w-full max-w-md\">\n      <VoiceDictation simulate onTranscript={(text) => console.log(text)} />\n    </div>\n  );\n}\n"
        },
        {
          "name": "VoiceDictationFull",
          "title": "Toggle or hold, hotkey and collected notes",
          "description": "Switch modes, use ⌘⇧D, and watch final transcripts collect below.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\nimport { VoiceDictation, type VoiceDictationStatus } from \"@kura-ui/react/voice-dictation\";\n\nexport function VoiceDictationFull() {\n  const [notes, setNotes] = React.useState<string[]>([]);\n  const [status, setStatus] = React.useState<VoiceDictationStatus>(\"idle\");\n  const [mode, setMode] = React.useState<\"toggle\" | \"hold\">(\"toggle\");\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-4\">\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"flex rounded-kura-md bg-kura-surface-sunken p-0.5 text-xs shadow-kura-xs\">\n          {([\"toggle\", \"hold\"] as const).map((m) => (\n            <button\n              key={m}\n              type=\"button\"\n              aria-pressed={mode === m}\n              onClick={() => setMode(m)}\n              className=\"cursor-pointer rounded-kura-sm px-2.5 py-1 text-kura-fg-muted capitalize outline-none transition-colors duration-(--kura-duration-fast) focus-visible:shadow-kura-focus aria-pressed:bg-kura-surface aria-pressed:text-kura-fg aria-pressed:shadow-kura-xs\"\n            >\n              {m}\n            </button>\n          ))}\n        </div>\n        <KbdShortcut keys={[\"mod\", \"shift\", \"d\"]} label={mode === \"hold\" ? \"Hold\" : \"Toggle\"} />\n      </div>\n      <VoiceDictation\n        simulate=\"Remind me to send Priya the revised contract after the design review tomorrow.\"\n        mode={mode}\n        hotkey={[\"mod\", \"shift\", \"d\"]}\n        lang=\"en-US\"\n        showTranscript\n        transcriptPlaceholder=\"Speak — your words appear here\"\n        onStatusChange={setStatus}\n        onTranscript={(text) => setNotes((n) => [text, ...n])}\n      />\n      <p className=\"text-kura-fg-subtle text-xs capitalize\" aria-live=\"polite\">\n        Status: {status}\n      </p>\n      <ul aria-label=\"Dictated notes\" className=\"flex flex-col gap-2\">\n        {notes.length === 0 && (\n          <li className=\"rounded-kura-lg bg-kura-surface-sunken px-3.5 py-3 text-kura-fg-subtle text-sm\">\n            Dictated notes appear here.\n          </li>\n        )}\n        {notes.map((note, i) => (\n          <li\n            key={`${i}-${note}`}\n            className=\"animate-kura-rise-in rounded-kura-lg bg-kura-surface px-3.5 py-3 text-kura-fg text-sm leading-6 shadow-kura-sm\"\n          >\n            {note}\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"
        },
        {
          "name": "VoiceDictationInComposer",
          "title": "Dictating into a composer",
          "description": "Interim words preview in the placeholder; the final transcript is appended to the composer value.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  Composer,\n  ComposerActions,\n  ComposerAttachButton,\n  ComposerInput,\n  ComposerSubmit,\n  ComposerToolbar,\n  ComposerTools,\n} from \"@kura-ui/react/composer\";\nimport { VoiceDictation } from \"@kura-ui/react/voice-dictation\";\n\nexport function VoiceDictationInComposer() {\n  const [value, setValue] = React.useState(\"\");\n  const [interim, setInterim] = React.useState(\"\");\n  return (\n    <div className=\"w-full max-w-xl\">\n      <Composer value={value} onValueChange={setValue} onSubmit={() => setValue(\"\")}>\n        <ComposerInput placeholder={interim || \"Type or dictate…\"} />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n          </ComposerTools>\n          <ComposerActions>\n            <VoiceDictation\n              simulate=\"Find three cafés near the office that open before seven.\"\n              showTranscript={false}\n              hotkey={null}\n              onInterim={setInterim}\n              onStatusChange={(s) => s === \"idle\" && setInterim(\"\")}\n              onTranscript={(text) => setValue((v) => (v ? `${v.trimEnd()} ${text}` : text))}\n            />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "In docs, demos and tests pass `simulate` — real recognition needs a user gesture, mic permission and (for Chrome) a network connection.",
        "Feed a Composer by controlling its value: onTranscript={(t) => setValue((v) => (v ? v + ' ' + t : t))}; pass showTranscript={false}.",
        "Provide `transcribe` for Firefox/other browsers without SpeechRecognition; without it those browsers record but emit nothing.",
        "Only one mounted instance should own a given hotkey; pass hotkey={null} on the others.",
        "The listening pill uses the jade signal — avoid another accent-filled element in the same view while dictating."
      ],
      "title": "Voice Dictation",
      "importPath": "@kura-ui/react/voice-dictation",
      "exports": [
        "VoiceDictation",
        "VoiceDictationProps",
        "VoiceDictationStatus"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/voice-dictation",
      "markdownUrl": "https://kuro.abstergo.fyi/components/voice-dictation.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/voice-dictation.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/voice-dictation.json",
        "cli": "npx @kura-ui/cli add voice-dictation",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "audio-waveform",
        "kura-keys",
        "kura-motion",
        "shimmer-text",
        "spinner",
        "tooltip",
        "use-audio-analyser",
        "use-hotkey",
        "use-media-recorder",
        "use-reduced-motion",
        "use-speech-recognition",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "onTranscript": {
            "description": "Called once per dictation with the final trimmed text. Not called when cancelled (× or Escape) or when nothing was heard.",
            "x-ts-type": "(text: string) => void"
          },
          "onInterim": {
            "description": "Live text (final + interim) while listening, e.g. to preview in a placeholder.",
            "x-ts-type": "(text: string) => void"
          },
          "onStatusChange": {
            "description": "idle | requesting | listening | processing | denied | unsupported | error.",
            "x-ts-type": "(status: VoiceDictationStatus) => void"
          },
          "mode": {
            "description": "toggle = click to start/stop; hold = dictate while the pointer, Space/Enter or the hotkey is held.",
            "x-ts-type": "\"toggle\" | \"hold\"",
            "type": "string",
            "enum": [
              "hold",
              "toggle"
            ],
            "default": "toggle"
          },
          "hotkey": {
            "description": "Global shortcut (works while typing). null disables it.",
            "x-ts-type": "readonly KeyName[] | null",
            "default": [
              "mod",
              "shift",
              "d"
            ]
          },
          "lang": {
            "description": "BCP-47 language for recognition, e.g. \"en-US\". Defaults to the page language.",
            "x-ts-type": "string",
            "type": "string"
          },
          "transcribe": {
            "description": "Fallback transcription when the Web Speech API is missing (Firefox): receives the recording; status is “processing” until it resolves.",
            "x-ts-type": "(audio: Blob) => Promise<string>"
          },
          "simulate": {
            "description": "Demo mode: fake permission, simulated waveform and a scripted transcript (a string is typed out word by word). For docs and tests.",
            "x-ts-type": "boolean | string"
          },
          "showTranscript": {
            "description": "Show the live transcript card under the control. Turn off inside a composer.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "transcriptPlaceholder": {
            "description": "Text in the transcript card before words arrive.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Listening…"
          },
          "disabled": {
            "description": "Disable the control.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/voice-dictation/index.ts",
          "type": "registry:ui",
          "content": "export {\n  VoiceDictation,\n  type VoiceDictationProps,\n  type VoiceDictationStatus,\n} from \"./voice-dictation\";\n"
        },
        {
          "target": "components/kura/voice-dictation/voice-dictation.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Mic, MicOff, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useAudioAnalyser } from \"@/hooks/kura/use-audio-analyser\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { useMediaRecorder } from \"@/hooks/kura/use-media-recorder\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { useSpeechRecognition } from \"@/hooks/kura/use-speech-recognition\";\nimport { cn } from \"@/lib/utils\";\nimport type { KeyName } from \"@/lib/kura/keys\";\nimport { springs } from \"@/lib/kura/motion\";\nimport { AudioWaveform } from \"@/components/kura/audio-waveform\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { Spinner } from \"@/components/kura/spinner\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport type VoiceDictationStatus =\n  | \"idle\"\n  | \"requesting\"\n  | \"listening\"\n  | \"processing\"\n  | \"denied\"\n  | \"unsupported\"\n  | \"error\";\n\nexport interface VoiceDictationProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Called once per dictation with the final, trimmed text (not called when cancelled or empty). */\n  onTranscript?: (text: string) => void;\n  /** Live transcript while listening (final + interim), e.g. to preview in a field. */\n  onInterim?: (text: string) => void;\n  onStatusChange?: (status: VoiceDictationStatus) => void;\n  /** `toggle` = click to start and stop. `hold` = press and hold (pointer, Space/Enter or the hotkey). Default \"toggle\". */\n  mode?: \"toggle\" | \"hold\";\n  /** Global shortcut. Default [\"mod\", \"shift\", \"d\"]. Pass null to disable. */\n  hotkey?: readonly KeyName[] | null;\n  /** BCP-47 language for speech recognition, e.g. \"en-US\". Default: the page language. */\n  lang?: string;\n  /**\n   * Fallback when the browser has no Web Speech API: the recording is passed here and the\n   * returned text is used (status \"processing\" meanwhile). E.g. post to a Whisper endpoint.\n   */\n  transcribe?: (audio: Blob) => Promise<string>;\n  /**\n   * Demo mode: fake permission, a simulated waveform and a scripted transcript. `true` uses a\n   * built-in script; a string is typed out word by word.\n   */\n  simulate?: boolean | string;\n  /** Show the live transcript card under the control. Default true. */\n  showTranscript?: boolean;\n  /** Placeholder in the transcript card before any words arrive. Default \"Listening…\". */\n  transcriptPlaceholder?: string;\n  disabled?: boolean;\n}\n\nconst DEFAULT_SCRIPT =\n  \"Draft a short note to the team: the launch moves to Thursday, and the release notes need one more pass on the pricing section.\";\n\nfunction formatElapsed(ms: number): string {\n  const s = Math.floor(ms / 1000);\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n}\n\nconst STATUS_TEXT: Record<VoiceDictationStatus, string> = {\n  idle: \"\",\n  requesting: \"Waiting for microphone permission\",\n  listening: \"Listening\",\n  processing: \"Transcribing\",\n  denied: \"Microphone access is blocked\",\n  unsupported: \"Voice input is not supported in this browser\",\n  error: \"Voice input failed\",\n};\n\n/**\n * Microphone dictation: a mic button that expands into a live waveform pill with a timer, plus\n * a live transcript. Uses the Web Speech API when available and falls back to recording +\n * your `transcribe` function. Toggle or hold-to-talk, with a global shortcut.\n */\nexport function VoiceDictation({\n  onTranscript,\n  onInterim,\n  onStatusChange,\n  mode = \"toggle\",\n  hotkey = [\"mod\", \"shift\", \"d\"],\n  lang,\n  transcribe,\n  simulate,\n  showTranscript = true,\n  transcriptPlaceholder = \"Listening…\",\n  disabled = false,\n  className,\n  ...props\n}: VoiceDictationProps) {\n  const reduced = useReducedMotion();\n  const simulating = simulate !== undefined && simulate !== false;\n  const script = typeof simulate === \"string\" ? simulate : DEFAULT_SCRIPT;\n\n  const [status, setStatusState] = React.useState<VoiceDictationStatus>(\"idle\");\n  const statusRef = React.useRef(status);\n  const setStatus = React.useCallback(\n    (next: VoiceDictationStatus) => {\n      if (statusRef.current === next) return;\n      statusRef.current = next;\n      setStatusState(next);\n      onStatusChange?.(next);\n    },\n    [onStatusChange],\n  );\n\n  const [finalText, setFinalText] = React.useState(\"\");\n  const [interimText, setInterimText] = React.useState(\"\");\n  const [elapsed, setElapsed] = React.useState(0);\n  const cancelled = React.useRef(false);\n  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);\n  const startedAt = React.useRef(0);\n\n  const emit = React.useCallback(\n    (text: string) => {\n      const clean = text.replace(/\\s+/g, \" \").trim();\n      if (clean && !cancelled.current) onTranscript?.(clean);\n    },\n    [onTranscript],\n  );\n\n  const speech = useSpeechRecognition({\n    lang,\n    continuous: true,\n    interimResults: true,\n    onEnd: (final) => {\n      if (simulating) return;\n      if (recorderRef.current?.isRecording) void recorderRef.current.stop();\n      if (statusRef.current === \"listening\" || statusRef.current === \"processing\") {\n        emit(final);\n        setStatus(\"idle\");\n      }\n    },\n    onError: (error) => {\n      if (simulating || error.name === \"aborted\") return;\n      if (recorderRef.current?.isRecording) void recorderRef.current.stop();\n      if (error.name === \"no-speech\") setStatus(\"idle\");\n      else\n        setStatus(\n          error.name === \"not-allowed\" || error.name === \"service-not-allowed\" ? \"denied\" : \"error\",\n        );\n    },\n  });\n  const recorderError = React.useRef<Error | null>(null);\n  const recorder = useMediaRecorder({\n    onError: (error) => {\n      recorderError.current = error;\n    },\n  });\n  const recorderRef = React.useRef(recorder);\n  recorderRef.current = recorder;\n  const statusId = React.useId();\n  const analyser = useAudioAnalyser({\n    source: simulating ? null : recorder.stream,\n    simulate: simulating && status === \"listening\" ? \"speech\" : false,\n    bands: 24,\n  });\n\n  const supported = simulating || speech.isSupported || recorder.isSupported;\n\n  // Mirror real recognition text while listening.\n  React.useEffect(() => {\n    if (simulating) return;\n    setFinalText(speech.finalTranscript);\n    setInterimText(speech.interimTranscript);\n  }, [simulating, speech.finalTranscript, speech.interimTranscript]);\n\n  React.useEffect(() => {\n    if (status === \"listening\") onInterim?.(`${finalText}${interimText}`.trim());\n  }, [status, finalText, interimText, onInterim]);\n\n  // Timer.\n  React.useEffect(() => {\n    if (status !== \"listening\") return;\n    startedAt.current = Date.now();\n    setElapsed(0);\n    const id = setInterval(() => setElapsed(Date.now() - startedAt.current), 250);\n    return () => clearInterval(id);\n  }, [status]);\n\n  const clearTimers = React.useCallback(() => {\n    for (const t of timers.current) clearTimeout(t);\n    timers.current = [];\n  }, []);\n  React.useEffect(() => clearTimers, [clearTimers]);\n\n  const start = React.useCallback(async () => {\n    if (disabled) return;\n    const s = statusRef.current;\n    if (s === \"listening\" || s === \"requesting\" || s === \"processing\") return;\n    cancelled.current = false;\n    setFinalText(\"\");\n    setInterimText(\"\");\n    clearTimers();\n\n    if (simulating) {\n      setStatus(\"requesting\");\n      timers.current.push(\n        setTimeout(() => {\n          setStatus(\"listening\");\n          const words = script.split(/\\s+/);\n          words.forEach((_, i) => {\n            timers.current.push(\n              setTimeout(\n                () => {\n                  const done = words.slice(0, Math.max(0, i - 2)).join(\" \");\n                  const live = words.slice(Math.max(0, i - 2), i + 1).join(\" \");\n                  setFinalText(done ? `${done} ` : \"\");\n                  setInterimText(live);\n                  if (i === words.length - 1) {\n                    timers.current.push(\n                      setTimeout(() => {\n                        setFinalText(script);\n                        setInterimText(\"\");\n                      }, 500),\n                    );\n                  }\n                },\n                450 + i * 190,\n              ),\n            );\n          });\n        }, 450),\n      );\n      return;\n    }\n\n    if (!supported) {\n      setStatus(\"unsupported\");\n      return;\n    }\n    setStatus(\"requesting\");\n    recorderError.current = null;\n    if (recorder.isSupported) {\n      const stream = await recorder.start();\n      if (!stream) {\n        const name = (recorderError.current as { name?: string } | null)?.name;\n        setStatus(name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : \"error\");\n        return;\n      }\n    }\n    if (cancelled.current) {\n      recorder.reset();\n      setStatus(\"idle\");\n      return;\n    }\n    if (speech.isSupported) {\n      speech.reset();\n      speech.start();\n    }\n    setStatus(\"listening\");\n  }, [disabled, simulating, script, supported, recorder, speech, setStatus, clearTimers]);\n\n  const stop = React.useCallback(async () => {\n    const s = statusRef.current;\n    if (s === \"requesting\") {\n      cancelled.current = true;\n      clearTimers();\n      recorder.reset();\n      setStatus(\"idle\");\n      return;\n    }\n    if (s !== \"listening\") return;\n\n    if (simulating) {\n      clearTimers();\n      setStatus(\"processing\");\n      timers.current.push(\n        setTimeout(() => {\n          emit(`${finalText}${interimText}`);\n          setInterimText(\"\");\n          setStatus(\"idle\");\n        }, 650),\n      );\n      return;\n    }\n\n    setStatus(\"processing\");\n    if (speech.isSupported) {\n      speech.stop(); // onEnd emits the final transcript\n      if (recorder.isRecording) void recorder.stop();\n      return;\n    }\n    const blob = await recorder.stop();\n    if (blob && transcribe) {\n      try {\n        emit(await transcribe(blob));\n        setStatus(\"idle\");\n      } catch {\n        setStatus(\"error\");\n      }\n    } else {\n      setStatus(\"idle\");\n    }\n  }, [\n    simulating,\n    finalText,\n    interimText,\n    emit,\n    speech,\n    recorder,\n    transcribe,\n    setStatus,\n    clearTimers,\n  ]);\n\n  const cancel = React.useCallback(() => {\n    cancelled.current = true;\n    clearTimers();\n    if (!simulating) {\n      speech.abort();\n      recorder.reset();\n    }\n    setFinalText(\"\");\n    setInterimText(\"\");\n    setStatus(\"idle\");\n  }, [simulating, speech, recorder, setStatus, clearTimers]);\n\n  const toggle = () => {\n    const s = statusRef.current;\n    if (s === \"listening\" || s === \"requesting\") void stop();\n    else void start();\n  };\n\n  useHotkey(\n    hotkey,\n    (e) => {\n      if (e.repeat) return;\n      if (mode === \"hold\") void start();\n      else toggle();\n    },\n    {\n      enabled: !disabled && hotkey !== null,\n      allowInInputs: true,\n      onKeyUp: mode === \"hold\" ? () => void stop() : undefined,\n    },\n  );\n\n  React.useEffect(() => {\n    if (status !== \"listening\") return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        cancel();\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [status, cancel]);\n\n  const live = status === \"listening\" || status === \"processing\" || status === \"requesting\";\n  const blocked = status === \"denied\" || status === \"unsupported\" || status === \"error\";\n  const effective: VoiceDictationStatus = !supported && status === \"idle\" ? \"unsupported\" : status;\n  const transcript = `${finalText}${interimText}`;\n\n  const holdHandlers =\n    mode === \"hold\"\n      ? {\n          onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {\n            if (e.button !== 0) return;\n            e.currentTarget.setPointerCapture?.(e.pointerId);\n            void start();\n          },\n          onPointerUp: () => void stop(),\n          onPointerCancel: () => void stop(),\n          onKeyDown: (e: React.KeyboardEvent) => {\n            if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n              e.preventDefault();\n              void start();\n            }\n          },\n          onKeyUp: (e: React.KeyboardEvent) => {\n            if (e.key === \" \" || e.key === \"Enter\") {\n              e.preventDefault();\n              void stop();\n            }\n          },\n        }\n      : { onClick: toggle };\n\n  const tooltipText =\n    effective === \"unsupported\"\n      ? \"Voice input isn't supported here\"\n      : effective === \"denied\"\n        ? \"Microphone blocked\"\n        : mode === \"hold\"\n          ? \"Hold to dictate\"\n          : status === \"listening\"\n            ? \"Stop dictation\"\n            : \"Dictate\";\n\n  const layoutTransition = reduced ? { duration: 0 } : springs.smooth;\n  const swap = reduced\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n    : {\n        initial: { opacity: 0, filter: \"blur(2px)\", scale: 0.9 },\n        animate: { opacity: 1, filter: \"blur(0px)\", scale: 1 },\n        exit: { opacity: 0, filter: \"blur(2px)\", scale: 0.9 },\n      };\n\n  return (\n    <div\n      data-slot=\"voice-dictation\"\n      data-status={effective}\n      data-mode={mode}\n      className={cn(\"flex min-w-0 flex-col items-start gap-3 font-kura-sans\", className)}\n      {...props}\n    >\n      <motion.div\n        layout\n        transition={layoutTransition}\n        data-slot=\"voice-dictation-control\"\n        style={{ borderRadius: 999 }}\n        className={cn(\n          \"relative flex h-8 items-center\",\n          live && \"gap-1 bg-kura-accent-subtle p-0.5 pr-3 text-kura-accent-text\",\n        )}\n      >\n        <Tooltip content={tooltipText} shortcut={hotkey ?? undefined}>\n          <motion.button\n            layout=\"position\"\n            transition={layoutTransition}\n            type=\"button\"\n            data-slot=\"voice-dictation-button\"\n            aria-label={mode === \"hold\" ? \"Hold to dictate\" : \"Dictate\"}\n            aria-pressed={status === \"listening\"}\n            aria-describedby={blocked ? statusId : undefined}\n            disabled={disabled || effective === \"unsupported\" || status === \"processing\"}\n            {...holdHandlers}\n            className={cn(\n              \"relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none\",\n              \"transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out\",\n              \"focus-visible:shadow-kura-focus disabled:cursor-not-allowed disabled:opacity-50\",\n              live\n                ? \"bg-kura-accent text-kura-accent-fg hover:bg-kura-accent-hover\"\n                : blocked\n                  ? \"text-kura-danger-text hover:bg-kura-hover\"\n                  : \"text-kura-fg-muted hover:bg-kura-hover hover:text-kura-fg\",\n              effective === \"unsupported\" && \"text-kura-fg-subtle\",\n              mode === \"hold\" && \"touch-none select-none\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={live ? \"stop\" : blocked ? \"off\" : \"mic\"}\n                className=\"flex\"\n                transition={springs.snappy}\n                {...swap}\n              >\n                {status === \"requesting\" ? (\n                  <Spinner size=\"sm\" label={null} />\n                ) : live && mode === \"toggle\" ? (\n                  <span aria-hidden className=\"size-2.5 rounded-[2.5px] bg-current\" />\n                ) : blocked ? (\n                  <MicOff aria-hidden className=\"size-4\" />\n                ) : (\n                  <Mic aria-hidden className=\"size-4\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </motion.button>\n        </Tooltip>\n        <AnimatePresence initial={false}>\n          {live && (\n            <motion.div\n              key=\"live\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { delay: reduced ? 0 : 0.08, duration: 0.2 } }}\n              exit={{ opacity: 0, transition: { duration: 0.09 } }}\n              className=\"flex items-center gap-2\"\n            >\n              {status === \"processing\" ? (\n                <ShimmerText className=\"px-1 text-[13px]\">Transcribing…</ShimmerText>\n              ) : (\n                <>\n                  <AudioWaveform\n                    analyser={analyser}\n                    variant=\"mirrored\"\n                    tone=\"accent\"\n                    barWidth={2}\n                    barGap={2}\n                    active={status === \"listening\"}\n                    className=\"h-5 w-16\"\n                    aria-hidden\n                  />\n                  <span className=\"w-8 text-right font-medium text-xs tabular-nums\">\n                    {formatElapsed(elapsed)}\n                  </span>\n                  <button\n                    type=\"button\"\n                    data-slot=\"voice-dictation-cancel\"\n                    aria-label=\"Cancel dictation\"\n                    onClick={cancel}\n                    className=\"-mr-1.5 flex size-6 cursor-pointer items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] duration-(--kura-duration-fast) hover:bg-kura-hover hover:opacity-100 focus-visible:shadow-kura-focus\"\n                  >\n                    <X aria-hidden className=\"size-3.5\" />\n                  </button>\n                </>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n\n      <span id={statusId} role=\"status\" className=\"sr-only\">\n        {STATUS_TEXT[effective]}\n      </span>\n\n      {blocked && (\n        <p\n          data-slot=\"voice-dictation-message\"\n          className=\"max-w-xs animate-kura-fade-in text-kura-danger-text text-xs\"\n        >\n          {status === \"denied\"\n            ? \"Microphone access is blocked. Allow it in your browser's site settings and try again.\"\n            : status === \"unsupported\"\n              ? \"This browser can't record audio. Try Chrome, Edge or Safari.\"\n              : \"Something went wrong with voice input. Try again.\"}\n        </p>\n      )}\n\n      {showTranscript && live && (\n        <div\n          data-slot=\"voice-dictation-transcript\"\n          className={cn(\n            \"w-full min-w-0 animate-kura-fade-in rounded-kura-lg bg-kura-surface px-3.5 py-3 text-[15px] leading-6 shadow-kura-sm\",\n          )}\n        >\n          {transcript ? (\n            <p className=\"text-pretty\">\n              <span className=\"text-kura-fg\">{finalText}</span>\n              <span className=\"text-kura-fg-subtle\">{interimText}</span>\n              {status === \"listening\" && (\n                <span\n                  aria-hidden\n                  className=\"ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-kura-pulse rounded-full bg-kura-accent motion-reduce:animate-none\"\n                />\n              )}\n            </p>\n          ) : (\n            <p className=\"text-kura-fg-subtle\">\n              {status === \"requesting\" ? \"Allow microphone access…\" : transcriptPlaceholder}\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
        }
      ]
    }
  ],
  "hooks": [
    {
      "name": "useControllableState",
      "file": "use-controllable-state",
      "description": "One state API for components that support controlled (value/onChange) and uncontrolled (defaultValue) usage.",
      "signature": "useControllableState<T>({ value?: T, defaultValue: T, onChange?: (value: T) => void }): [T, (next: T | ((prev: T) => T)) => void]",
      "returns": "[value, setValue] — setValue skips unchanged values and always calls onChange.",
      "example": "const [open, setOpen] = useControllableState({ value: props.open, defaultValue: false, onChange: props.onOpenChange });",
      "tags": [
        "state",
        "controlled",
        "uncontrolled"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-controllable-state.json",
      "files": [
        {
          "target": "hooks/kura/use-controllable-state.ts",
          "type": "registry:hook",
          "content": "import { useCallback, useRef, useState } from \"react\";\n\nexport interface UseControllableStateOptions<T> {\n  /** Controlled value. When defined, the hook is controlled and never stores its own state. */\n  value?: T | undefined;\n  /** Initial value when uncontrolled. */\n  defaultValue: T;\n  /** Called on every change request, controlled or not. Not called when the value is unchanged. */\n  onChange?: ((value: T) => void) | undefined;\n}\n\nexport type SetStateAction<T> = T | ((previous: T) => T);\n\n/**\n * One state API for components that support both controlled (`value` + `onChange`) and\n * uncontrolled (`defaultValue`) usage.\n *\n *   const [open, setOpen] = useControllableState({ value: props.open, defaultValue: false, onChange: props.onOpenChange });\n */\nexport function useControllableState<T>({\n  value,\n  defaultValue,\n  onChange,\n}: UseControllableStateOptions<T>): [T, (next: SetStateAction<T>) => void] {\n  const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);\n  const isControlled = value !== undefined;\n  const current = isControlled ? (value as T) : uncontrolled;\n\n  const currentRef = useRef(current);\n  currentRef.current = current;\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  const setValue = useCallback(\n    (next: SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\" ? (next as (previous: T) => T)(currentRef.current) : next;\n      if (Object.is(resolved, currentRef.current)) return;\n      currentRef.current = resolved;\n      if (!isControlled) setUncontrolled(resolved);\n      onChangeRef.current?.(resolved);\n    },\n    [isControlled],\n  );\n\n  return [current, setValue];\n}\n"
        }
      ]
    },
    {
      "name": "useReducedMotion",
      "file": "use-reduced-motion",
      "description": "Live, SSR-safe prefers-reduced-motion flag (false on the server).",
      "signature": "useReducedMotion(): boolean",
      "returns": "true when the user prefers reduced motion.",
      "example": "const reduced = useReducedMotion();\n<motion.div transition={reduced ? { duration: 0 } : springs.smooth} />",
      "tags": [
        "motion",
        "a11y",
        "reduced-motion"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
      "files": [
        {
          "target": "hooks/kura/use-reduced-motion.ts",
          "type": "registry:hook",
          "content": "import { useSyncExternalStore } from \"react\";\n\nconst QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void): () => void {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return () => {};\n  const mql = window.matchMedia(QUERY);\n  mql.addEventListener?.(\"change\", onChange);\n  return () => mql.removeEventListener?.(\"change\", onChange);\n}\n\nfunction getSnapshot(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(QUERY).matches;\n}\n\nfunction getServerSnapshot(): boolean {\n  return false;\n}\n\n/**\n * `true` when the user asked the OS for reduced motion. SSR-safe (false on the server, then\n * hydrates to the real value) and live-updating.\n */\nexport function useReducedMotion(): boolean {\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n"
        }
      ]
    },
    {
      "name": "useHotkey",
      "file": "use-hotkey",
      "description": "Bind a keyboard shortcut on document (or a target). `mod` = ⌘ on Apple, Ctrl elsewhere. Exact modifier matching.",
      "signature": "useHotkey(keys: readonly KeyName[] | null, handler: (e: KeyboardEvent) => void, options?: { enabled?: boolean; preventDefault?: boolean; allowInInputs?: boolean; target?: EventTarget | null; onKeyUp?: (e: KeyboardEvent) => void }): void",
      "returns": "void",
      "notes": [
        "Without a modifier, the shortcut is ignored while typing in inputs unless allowInInputs is true."
      ],
      "example": "useHotkey([\"mod\", \"enter\"], () => submit());",
      "tags": [
        "keyboard",
        "shortcut",
        "hotkey"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-hotkey.json",
      "files": [
        {
          "target": "hooks/kura/use-hotkey.ts",
          "type": "registry:hook",
          "content": "import { useEffect, useRef } from \"react\";\nimport { isApplePlatform, type KeyName, matchesKeyCombo } from \"@/lib/kura/keys\";\n\nexport interface UseHotkeyOptions {\n  /** Default true. */\n  enabled?: boolean;\n  /** Call preventDefault on match. Default true. */\n  preventDefault?: boolean;\n  /**\n   * Fire while focus is in an input, textarea or contenteditable. Defaults to true only when the\n   * combo contains a modifier (so \"/\" does not hijack typing but \"mod+k\" still works).\n   */\n  allowInInputs?: boolean;\n  /** Element to listen on. Default `document`. */\n  target?: EventTarget | null;\n  /** Also report key-up so UIs can show a pressed state. */\n  onKeyUp?: (event: KeyboardEvent) => void;\n}\n\nfunction isEditable(target: EventTarget | null): boolean {\n  if (!(target instanceof HTMLElement)) return false;\n  const tag = target.tagName;\n  return tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || target.isContentEditable;\n}\n\n/**\n * Bind a keyboard shortcut. `keys` uses `mod` for ⌘/Ctrl: `useHotkey([\"mod\", \"enter\"], send)`.\n * SSR-safe; the listener is attached after mount and the latest handler is always used.\n */\nexport function useHotkey(\n  keys: readonly KeyName[] | null,\n  handler: (event: KeyboardEvent) => void,\n  options: UseHotkeyOptions = {},\n): void {\n  const { enabled = true, preventDefault = true, allowInInputs, target, onKeyUp } = options;\n  const handlerRef = useRef(handler);\n  handlerRef.current = handler;\n  const keyUpRef = useRef(onKeyUp);\n  keyUpRef.current = onKeyUp;\n  const combo = keys?.join(\"+\") ?? \"\";\n\n  useEffect(() => {\n    if (!enabled || !combo) return;\n    const el: EventTarget | null = target ?? (typeof document === \"undefined\" ? null : document);\n    if (!el) return;\n    const parts = combo.split(\"+\");\n    const apple = isApplePlatform();\n    const hasModifier = parts.some((k) =>\n      /^(mod|meta|cmd|command|ctrl|control|alt|option)$/i.test(k),\n    );\n    const inInputs = allowInInputs ?? hasModifier;\n    let down = false;\n\n    const onDown = (event: Event) => {\n      const e = event as KeyboardEvent;\n      if (!inInputs && isEditable(e.target)) return;\n      if (!matchesKeyCombo(e, parts, apple)) return;\n      if (preventDefault) e.preventDefault();\n      down = true;\n      handlerRef.current(e);\n    };\n    const onUp = (event: Event) => {\n      if (!down) return;\n      down = false;\n      keyUpRef.current?.(event as KeyboardEvent);\n    };\n    el.addEventListener(\"keydown\", onDown);\n    el.addEventListener(\"keyup\", onUp);\n    return () => {\n      el.removeEventListener(\"keydown\", onDown);\n      el.removeEventListener(\"keyup\", onUp);\n    };\n  }, [combo, enabled, preventDefault, allowInInputs, target]);\n}\n"
        }
      ]
    },
    {
      "name": "useIsApplePlatform",
      "file": "use-platform",
      "description": "Hydration-safe macOS/iOS detection for rendering ⌘ vs Ctrl.",
      "signature": "useIsApplePlatform(): boolean",
      "returns": "true on Apple platforms (false during SSR and the first client render).",
      "example": "const apple = useIsApplePlatform();\nconst label = apple ? \"⌘K\" : \"Ctrl K\";",
      "tags": [
        "platform",
        "keyboard"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-platform.json",
      "files": [
        {
          "target": "hooks/kura/use-platform.ts",
          "type": "registry:hook",
          "content": "import { useSyncExternalStore } from \"react\";\nimport { isApplePlatform } from \"@/lib/kura/keys\";\n\nconst noopSubscribe = () => () => {};\n\n/**\n * `true` on macOS/iOS. Hydration-safe: renders as non-Apple on the server and on the first client\n * pass, then updates — so \"⌘\" vs \"Ctrl\" never causes a hydration mismatch.\n */\nexport function useIsApplePlatform(): boolean {\n  return useSyncExternalStore(noopSubscribe, isApplePlatform, () => false);\n}\n"
        }
      ]
    },
    {
      "name": "useAudioAnalyser",
      "file": "use-audio-analyser",
      "description": "Web Audio analysis of a mic MediaStream or <audio>/<video> element → level, log-spaced bands and waveform, with a deterministic simulated mode (speech | music | silence) for demos and tests. SSR-safe.",
      "signature": "useAudioAnalyser({ source?: MediaStream | HTMLMediaElement | null, simulate?: boolean | 'speech' | 'music' | 'silence', bands?: number = 32, fftSize?: number = 1024, smoothing?: number = 0.8, minDecibels?: number = -90, maxDecibels?: number = -10, enabled?: boolean = true }): { status: 'idle' | 'running' | 'simulated' | 'unsupported' | 'error', error: Error | null, read(): AudioFrame, subscribe(listener: (frame: AudioFrame) => void): () => void }",
      "returns": "AudioFrame = { level: 0..1, peak: 0..1, bands: Float32Array (0..1), waveform: Float32Array (-1..1), time: ms }",
      "notes": [
        "Frames are not React state. Use subscribe() for canvas/WebGL, or useAudioFrame(analyser, { fps }) for throttled React rendering.",
        "Arrays in a frame are reused between frames — copy them if you keep them.",
        "A media element is wired once and kept audible; mic streams are never connected to speakers."
      ],
      "example": "const rec = useMediaRecorder();\nconst analyser = useAudioAnalyser({ source: rec.stream, simulate: !rec.stream && 'silence', bands: 24 });\nconst { bands } = useAudioFrame(analyser, { fps: 30 });",
      "tags": [
        "audio",
        "voice",
        "waveform",
        "visualizer",
        "microphone",
        "web-audio"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-audio-analyser.json",
      "files": [
        {
          "target": "hooks/kura/use-audio-analyser.ts",
          "type": "registry:hook",
          "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n/** One analysed audio frame. All arrays are reused between frames — copy them if you keep them. */\nexport interface AudioFrame {\n  /** Smoothed loudness 0..1 (RMS, perceptually scaled). Drive scale/opacity with this. */\n  level: number;\n  /** Instant peak 0..1 of the current frame. */\n  peak: number;\n  /** `bands` values 0..1, log-spaced from ~60 Hz to ~8 kHz. Drive bars/orbs with this. */\n  bands: Float32Array;\n  /** Time-domain samples -1..1. Drive oscilloscope-style waveforms with this. */\n  waveform: Float32Array;\n  /** `performance.now()` at analysis time (ms). */\n  time: number;\n}\n\nexport type AudioAnalyserStatus =\n  /** No source yet (or disabled). Frames are silent. */\n  | \"idle\"\n  /** Analysing a real MediaStream / media element. */\n  | \"running\"\n  /** Generating synthetic data (demos, tests, SSR previews, permission denied fallbacks). */\n  | \"simulated\"\n  /** Web Audio is not available (SSR, old browsers, jsdom). Frames are silent. */\n  | \"unsupported\"\n  | \"error\";\n\nexport type AudioSimulation = \"speech\" | \"music\" | \"silence\";\n\nexport interface UseAudioAnalyserOptions {\n  /** Mic/WebRTC stream or an <audio>/<video> element. `null`/`undefined` → idle. */\n  source?: MediaStream | HTMLMediaElement | null;\n  /**\n   * Generate realistic synthetic data instead of reading `source`. `true` = \"speech\".\n   * Use for docs, previews, Storybook, tests and \"before permission\" states.\n   */\n  simulate?: boolean | AudioSimulation;\n  /** Number of frequency bands in `frame.bands`. Default 32. */\n  bands?: number;\n  /** AnalyserNode FFT size (power of two, 32–32768). Default 1024. */\n  fftSize?: number;\n  /** AnalyserNode smoothing 0..1. Default 0.8. */\n  smoothing?: number;\n  /** Lower bound of the dB range mapped to 0. Default -90. */\n  minDecibels?: number;\n  /** Upper bound of the dB range mapped to 1. Default -10. */\n  maxDecibels?: number;\n  /** Set false to stop analysing without unmounting. Default true. */\n  enabled?: boolean;\n}\n\nexport interface AudioAnalyser {\n  status: AudioAnalyserStatus;\n  error: Error | null;\n  /** Analyse and return the current frame. Cheap; safe to call inside requestAnimationFrame. */\n  read: () => AudioFrame;\n  /** Called once per animation frame while subscribed. Returns an unsubscribe function. */\n  subscribe: (listener: (frame: AudioFrame) => void) => () => void;\n}\n\ntype AudioContextCtor = typeof AudioContext;\n\nfunction getAudioContextCtor(): AudioContextCtor | null {\n  if (typeof window === \"undefined\") return null;\n  const w = window as unknown as {\n    AudioContext?: AudioContextCtor;\n    webkitAudioContext?: AudioContextCtor;\n  };\n  return w.AudioContext ?? w.webkitAudioContext ?? null;\n}\n\n// A media element can only be wired to one MediaElementAudioSourceNode, ever. Cache it.\nconst elementSources = new WeakMap<\n  HTMLMediaElement,\n  { context: AudioContext; node: MediaElementAudioSourceNode }\n>();\n\n/** Deterministic hash noise in [0, 1). */\nfunction noise(x: number): number {\n  const s = Math.sin(x * 12.9898 + 78.233) * 43758.5453;\n  return s - Math.floor(s);\n}\n\n/** Smooth value noise in [0, 1). */\nfunction smoothNoise(x: number): number {\n  const i = Math.floor(x);\n  const f = x - i;\n  const u = f * f * (3 - 2 * f);\n  return noise(i) * (1 - u) + noise(i + 1) * u;\n}\n\nfunction clamp01(v: number): number {\n  return Math.min(1, Math.max(0, v));\n}\n\n/** Simulated level envelope 0..1 for time `t` in seconds. */\nfunction simulatedLevel(kind: AudioSimulation, t: number): number {\n  if (kind === \"silence\") return 0.02 * smoothNoise(t * 3);\n  if (kind === \"music\") {\n    const beat = Math.max(0, Math.sin(t * Math.PI * 2 * 2)) ** 6; // 120 bpm\n    return clamp01(0.45 + 0.2 * smoothNoise(t * 1.3) + 0.35 * beat);\n  }\n  // speech: syllables (~4 Hz) inside phrases (~0.4 Hz) with pauses between phrases\n  const phrase = smoothNoise(t * 0.45);\n  const gate = phrase > 0.32 ? 1 : 0.08;\n  const syllable = 0.55 + 0.45 * Math.sin(t * Math.PI * 2 * 4.2 + 2 * smoothNoise(t * 2));\n  return clamp01(gate * syllable * (0.55 + 0.45 * smoothNoise(t * 7)));\n}\n\n/**\n * Real-time audio analysis via Web Audio for a mic `MediaStream` or an `<audio>`/`<video>`\n * element, with a deterministic simulated mode for demos and tests. SSR-safe.\n *\n * Per-frame data is NOT React state (that would re-render 60×/s). Read it imperatively:\n *\n *   const analyser = useAudioAnalyser({ source: stream, bands: 24 });\n *   useEffect(() => analyser.subscribe((f) => { el.style.scale = String(1 + f.level) }), [analyser]);\n *\n * Or, for low-frequency React rendering, use `useAudioFrame(analyser, { fps: 30 })`.\n */\nexport function useAudioAnalyser(options: UseAudioAnalyserOptions = {}): AudioAnalyser {\n  const {\n    source = null,\n    simulate = false,\n    bands = 32,\n    fftSize = 1024,\n    smoothing = 0.8,\n    minDecibels = -90,\n    maxDecibels = -10,\n    enabled = true,\n  } = options;\n  const simulation: AudioSimulation | null =\n    simulate === true ? \"speech\" : simulate === false ? null : simulate;\n\n  const [status, setStatus] = useState<AudioAnalyserStatus>(simulation ? \"simulated\" : \"idle\");\n  const [error, setError] = useState<Error | null>(null);\n\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const freqRef = useRef<Uint8Array<ArrayBuffer> | null>(null);\n  const timeRef = useRef<Float32Array<ArrayBuffer> | null>(null);\n  const simRef = useRef(simulation);\n  simRef.current = enabled ? simulation : null;\n  const smoothedLevel = useRef(0);\n\n  const frame = useMemo<AudioFrame>(\n    () => ({\n      level: 0,\n      peak: 0,\n      bands: new Float32Array(bands),\n      waveform: new Float32Array(fftSize),\n      time: 0,\n    }),\n    [bands, fftSize],\n  );\n\n  // Wire up the Web Audio graph for real sources.\n  useEffect(() => {\n    if (!enabled || simulation || !source) {\n      analyserRef.current = null;\n      setStatus(simulation && enabled ? \"simulated\" : \"idle\");\n      return;\n    }\n    const Ctor = getAudioContextCtor();\n    if (!Ctor) {\n      setStatus(\"unsupported\");\n      return;\n    }\n    let context: AudioContext;\n    let input: AudioNode;\n    let ownsContext = true;\n    const cleanups: Array<() => void> = [];\n    try {\n      if (typeof MediaStream !== \"undefined\" && source instanceof MediaStream) {\n        context = new Ctor();\n        input = context.createMediaStreamSource(source);\n      } else {\n        const element = source as HTMLMediaElement;\n        let cached = elementSources.get(element);\n        if (!cached) {\n          const ctx = new Ctor();\n          const node = ctx.createMediaElementSource(element);\n          node.connect(ctx.destination); // keep the element audible\n          cached = { context: ctx, node };\n          elementSources.set(element, cached);\n        }\n        context = cached.context;\n        input = cached.node;\n        ownsContext = false;\n        const resume = () => void context.resume().catch(() => {});\n        element.addEventListener(\"play\", resume);\n        cleanups.push(() => element.removeEventListener(\"play\", resume));\n      }\n      const analyser = context.createAnalyser();\n      analyser.fftSize = fftSize;\n      analyser.smoothingTimeConstant = smoothing;\n      analyser.minDecibels = minDecibels;\n      analyser.maxDecibels = maxDecibels;\n      input.connect(analyser); // never connect a mic to destination (feedback)\n      analyserRef.current = analyser;\n      freqRef.current = new Uint8Array(analyser.frequencyBinCount);\n      timeRef.current = new Float32Array(analyser.fftSize);\n      void context.resume().catch(() => {});\n      setStatus(\"running\");\n      setError(null);\n      cleanups.push(() => {\n        input.disconnect(analyser);\n        if (ownsContext) void context.close().catch(() => {});\n      });\n    } catch (err) {\n      setError(err instanceof Error ? err : new Error(String(err)));\n      setStatus(\"error\");\n    }\n    return () => {\n      analyserRef.current = null;\n      for (const fn of cleanups) fn();\n    };\n  }, [source, simulation, enabled, fftSize, smoothing, minDecibels, maxDecibels]);\n\n  const read = useCallback((): AudioFrame => {\n    const now = typeof performance !== \"undefined\" ? performance.now() : Date.now();\n    frame.time = now;\n    const sim = simRef.current;\n    const analyser = analyserRef.current;\n    const n = frame.bands.length;\n\n    if (sim) {\n      const t = now / 1000;\n      const level = simulatedLevel(sim, t);\n      for (let i = 0; i < n; i++) {\n        const x = i / Math.max(1, n - 1);\n        // spectral tilt: speech energy concentrates low-mid, music spreads wider\n        const tilt = sim === \"music\" ? 1 - x * 0.6 : Math.exp(-((x - 0.22) ** 2) / 0.08);\n        const jitter = 0.6 + 0.4 * smoothNoise(t * 9 + i * 1.7);\n        frame.bands[i] = clamp01(level * tilt * jitter * 1.15);\n      }\n      const len = frame.waveform.length;\n      for (let i = 0; i < len; i++) {\n        const p = i / len;\n        frame.waveform[i] =\n          level *\n          (0.6 * Math.sin(p * Math.PI * 2 * 3 + t * 9) +\n            0.3 * Math.sin(p * Math.PI * 2 * 7 + t * 13) +\n            0.1 * (noise(i + t) * 2 - 1));\n      }\n      frame.peak = level;\n      smoothedLevel.current += (level - smoothedLevel.current) * 0.35;\n      frame.level = smoothedLevel.current;\n      return frame;\n    }\n\n    if (!analyser || !freqRef.current || !timeRef.current) {\n      frame.bands.fill(0);\n      frame.waveform.fill(0);\n      frame.level = 0;\n      frame.peak = 0;\n      smoothedLevel.current = 0;\n      return frame;\n    }\n\n    const freq = freqRef.current;\n    const td = timeRef.current;\n    analyser.getByteFrequencyData(freq);\n    analyser.getFloatTimeDomainData(td);\n\n    // log-spaced bands between ~60 Hz and ~8 kHz\n    const sampleRate = analyser.context.sampleRate;\n    const binHz = sampleRate / analyser.fftSize;\n    const minHz = 60;\n    const maxHz = Math.min(8000, sampleRate / 2);\n    for (let i = 0; i < n; i++) {\n      const lo = minHz * (maxHz / minHz) ** (i / n);\n      const hi = minHz * (maxHz / minHz) ** ((i + 1) / n);\n      const a = Math.max(0, Math.floor(lo / binHz));\n      const b = Math.min(freq.length - 1, Math.max(a, Math.ceil(hi / binHz)));\n      let sum = 0;\n      for (let k = a; k <= b; k++) sum += freq[k] ?? 0;\n      frame.bands[i] = sum / ((b - a + 1) * 255);\n    }\n\n    let sumSq = 0;\n    let peak = 0;\n    const wlen = frame.waveform.length;\n    for (let i = 0; i < td.length; i++) {\n      const v = td[i] ?? 0;\n      sumSq += v * v;\n      const abs = Math.abs(v);\n      if (abs > peak) peak = abs;\n      if (i < wlen) frame.waveform[i] = v;\n    }\n    const rms = Math.sqrt(sumSq / td.length);\n    // perceptual scaling: speech RMS sits around 0.02–0.3\n    const level = clamp01(Math.sqrt(rms) * 1.6);\n    smoothedLevel.current += (level - smoothedLevel.current) * 0.3;\n    frame.level = smoothedLevel.current;\n    frame.peak = clamp01(peak);\n    return frame;\n  }, [frame]);\n\n  // Shared rAF loop that only runs while someone is subscribed.\n  const listeners = useRef(new Set<(frame: AudioFrame) => void>());\n  const rafRef = useRef<number | null>(null);\n  const tick = useCallback(() => {\n    const f = read();\n    for (const l of listeners.current) l(f);\n    rafRef.current = requestAnimationFrame(tick);\n  }, [read]);\n\n  const subscribe = useCallback(\n    (listener: (frame: AudioFrame) => void) => {\n      listeners.current.add(listener);\n      if (rafRef.current === null && typeof requestAnimationFrame !== \"undefined\") {\n        rafRef.current = requestAnimationFrame(tick);\n      }\n      return () => {\n        listeners.current.delete(listener);\n        if (listeners.current.size === 0 && rafRef.current !== null) {\n          cancelAnimationFrame(rafRef.current);\n          rafRef.current = null;\n        }\n      };\n    },\n    [tick],\n  );\n\n  useEffect(\n    () => () => {\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n    },\n    [],\n  );\n\n  return useMemo(() => ({ status, error, read, subscribe }), [status, error, read, subscribe]);\n}\n\nexport interface UseAudioFrameOptions {\n  /** Max React updates per second. Default 30. */\n  fps?: number;\n}\n\n/** A snapshot of the analyser as React state, throttled to `fps`. Copies the arrays. */\nexport interface AudioFrameSnapshot {\n  level: number;\n  peak: number;\n  bands: number[];\n  waveform: number[];\n}\n\n/**\n * Render audio data with plain React (e.g. 24 bars). Throttled, so it is fine for small DOM\n * visualisations. For canvas/WebGL use `analyser.subscribe` instead.\n */\nexport function useAudioFrame(\n  analyser: AudioAnalyser,\n  { fps = 30 }: UseAudioFrameOptions = {},\n): AudioFrameSnapshot {\n  const [snapshot, setSnapshot] = useState<AudioFrameSnapshot>(() => ({\n    level: 0,\n    peak: 0,\n    bands: [],\n    waveform: [],\n  }));\n  useEffect(() => {\n    let last = 0;\n    const interval = 1000 / fps;\n    return analyser.subscribe((f) => {\n      if (f.time - last < interval) return;\n      last = f.time;\n      setSnapshot({\n        level: f.level,\n        peak: f.peak,\n        bands: Array.from(f.bands),\n        waveform: Array.from(f.waveform),\n      });\n    });\n  }, [analyser, fps]);\n  return snapshot;\n}\n"
        }
      ]
    },
    {
      "name": "useAudioFrame",
      "file": "use-audio-analyser",
      "description": "Throttled React-state snapshot of a useAudioAnalyser for simple DOM visualisations.",
      "signature": "useAudioFrame(analyser: AudioAnalyser, { fps?: number = 30 }): { level: number; peak: number; bands: number[]; waveform: number[] }",
      "returns": "A copied snapshot, updated at most `fps` times per second.",
      "example": "const { level } = useAudioFrame(analyser, { fps: 20 });",
      "tags": [
        "audio",
        "visualizer"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-audio-analyser.json",
      "files": [
        {
          "target": "hooks/kura/use-audio-analyser.ts",
          "type": "registry:hook",
          "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n/** One analysed audio frame. All arrays are reused between frames — copy them if you keep them. */\nexport interface AudioFrame {\n  /** Smoothed loudness 0..1 (RMS, perceptually scaled). Drive scale/opacity with this. */\n  level: number;\n  /** Instant peak 0..1 of the current frame. */\n  peak: number;\n  /** `bands` values 0..1, log-spaced from ~60 Hz to ~8 kHz. Drive bars/orbs with this. */\n  bands: Float32Array;\n  /** Time-domain samples -1..1. Drive oscilloscope-style waveforms with this. */\n  waveform: Float32Array;\n  /** `performance.now()` at analysis time (ms). */\n  time: number;\n}\n\nexport type AudioAnalyserStatus =\n  /** No source yet (or disabled). Frames are silent. */\n  | \"idle\"\n  /** Analysing a real MediaStream / media element. */\n  | \"running\"\n  /** Generating synthetic data (demos, tests, SSR previews, permission denied fallbacks). */\n  | \"simulated\"\n  /** Web Audio is not available (SSR, old browsers, jsdom). Frames are silent. */\n  | \"unsupported\"\n  | \"error\";\n\nexport type AudioSimulation = \"speech\" | \"music\" | \"silence\";\n\nexport interface UseAudioAnalyserOptions {\n  /** Mic/WebRTC stream or an <audio>/<video> element. `null`/`undefined` → idle. */\n  source?: MediaStream | HTMLMediaElement | null;\n  /**\n   * Generate realistic synthetic data instead of reading `source`. `true` = \"speech\".\n   * Use for docs, previews, Storybook, tests and \"before permission\" states.\n   */\n  simulate?: boolean | AudioSimulation;\n  /** Number of frequency bands in `frame.bands`. Default 32. */\n  bands?: number;\n  /** AnalyserNode FFT size (power of two, 32–32768). Default 1024. */\n  fftSize?: number;\n  /** AnalyserNode smoothing 0..1. Default 0.8. */\n  smoothing?: number;\n  /** Lower bound of the dB range mapped to 0. Default -90. */\n  minDecibels?: number;\n  /** Upper bound of the dB range mapped to 1. Default -10. */\n  maxDecibels?: number;\n  /** Set false to stop analysing without unmounting. Default true. */\n  enabled?: boolean;\n}\n\nexport interface AudioAnalyser {\n  status: AudioAnalyserStatus;\n  error: Error | null;\n  /** Analyse and return the current frame. Cheap; safe to call inside requestAnimationFrame. */\n  read: () => AudioFrame;\n  /** Called once per animation frame while subscribed. Returns an unsubscribe function. */\n  subscribe: (listener: (frame: AudioFrame) => void) => () => void;\n}\n\ntype AudioContextCtor = typeof AudioContext;\n\nfunction getAudioContextCtor(): AudioContextCtor | null {\n  if (typeof window === \"undefined\") return null;\n  const w = window as unknown as {\n    AudioContext?: AudioContextCtor;\n    webkitAudioContext?: AudioContextCtor;\n  };\n  return w.AudioContext ?? w.webkitAudioContext ?? null;\n}\n\n// A media element can only be wired to one MediaElementAudioSourceNode, ever. Cache it.\nconst elementSources = new WeakMap<\n  HTMLMediaElement,\n  { context: AudioContext; node: MediaElementAudioSourceNode }\n>();\n\n/** Deterministic hash noise in [0, 1). */\nfunction noise(x: number): number {\n  const s = Math.sin(x * 12.9898 + 78.233) * 43758.5453;\n  return s - Math.floor(s);\n}\n\n/** Smooth value noise in [0, 1). */\nfunction smoothNoise(x: number): number {\n  const i = Math.floor(x);\n  const f = x - i;\n  const u = f * f * (3 - 2 * f);\n  return noise(i) * (1 - u) + noise(i + 1) * u;\n}\n\nfunction clamp01(v: number): number {\n  return Math.min(1, Math.max(0, v));\n}\n\n/** Simulated level envelope 0..1 for time `t` in seconds. */\nfunction simulatedLevel(kind: AudioSimulation, t: number): number {\n  if (kind === \"silence\") return 0.02 * smoothNoise(t * 3);\n  if (kind === \"music\") {\n    const beat = Math.max(0, Math.sin(t * Math.PI * 2 * 2)) ** 6; // 120 bpm\n    return clamp01(0.45 + 0.2 * smoothNoise(t * 1.3) + 0.35 * beat);\n  }\n  // speech: syllables (~4 Hz) inside phrases (~0.4 Hz) with pauses between phrases\n  const phrase = smoothNoise(t * 0.45);\n  const gate = phrase > 0.32 ? 1 : 0.08;\n  const syllable = 0.55 + 0.45 * Math.sin(t * Math.PI * 2 * 4.2 + 2 * smoothNoise(t * 2));\n  return clamp01(gate * syllable * (0.55 + 0.45 * smoothNoise(t * 7)));\n}\n\n/**\n * Real-time audio analysis via Web Audio for a mic `MediaStream` or an `<audio>`/`<video>`\n * element, with a deterministic simulated mode for demos and tests. SSR-safe.\n *\n * Per-frame data is NOT React state (that would re-render 60×/s). Read it imperatively:\n *\n *   const analyser = useAudioAnalyser({ source: stream, bands: 24 });\n *   useEffect(() => analyser.subscribe((f) => { el.style.scale = String(1 + f.level) }), [analyser]);\n *\n * Or, for low-frequency React rendering, use `useAudioFrame(analyser, { fps: 30 })`.\n */\nexport function useAudioAnalyser(options: UseAudioAnalyserOptions = {}): AudioAnalyser {\n  const {\n    source = null,\n    simulate = false,\n    bands = 32,\n    fftSize = 1024,\n    smoothing = 0.8,\n    minDecibels = -90,\n    maxDecibels = -10,\n    enabled = true,\n  } = options;\n  const simulation: AudioSimulation | null =\n    simulate === true ? \"speech\" : simulate === false ? null : simulate;\n\n  const [status, setStatus] = useState<AudioAnalyserStatus>(simulation ? \"simulated\" : \"idle\");\n  const [error, setError] = useState<Error | null>(null);\n\n  const analyserRef = useRef<AnalyserNode | null>(null);\n  const freqRef = useRef<Uint8Array<ArrayBuffer> | null>(null);\n  const timeRef = useRef<Float32Array<ArrayBuffer> | null>(null);\n  const simRef = useRef(simulation);\n  simRef.current = enabled ? simulation : null;\n  const smoothedLevel = useRef(0);\n\n  const frame = useMemo<AudioFrame>(\n    () => ({\n      level: 0,\n      peak: 0,\n      bands: new Float32Array(bands),\n      waveform: new Float32Array(fftSize),\n      time: 0,\n    }),\n    [bands, fftSize],\n  );\n\n  // Wire up the Web Audio graph for real sources.\n  useEffect(() => {\n    if (!enabled || simulation || !source) {\n      analyserRef.current = null;\n      setStatus(simulation && enabled ? \"simulated\" : \"idle\");\n      return;\n    }\n    const Ctor = getAudioContextCtor();\n    if (!Ctor) {\n      setStatus(\"unsupported\");\n      return;\n    }\n    let context: AudioContext;\n    let input: AudioNode;\n    let ownsContext = true;\n    const cleanups: Array<() => void> = [];\n    try {\n      if (typeof MediaStream !== \"undefined\" && source instanceof MediaStream) {\n        context = new Ctor();\n        input = context.createMediaStreamSource(source);\n      } else {\n        const element = source as HTMLMediaElement;\n        let cached = elementSources.get(element);\n        if (!cached) {\n          const ctx = new Ctor();\n          const node = ctx.createMediaElementSource(element);\n          node.connect(ctx.destination); // keep the element audible\n          cached = { context: ctx, node };\n          elementSources.set(element, cached);\n        }\n        context = cached.context;\n        input = cached.node;\n        ownsContext = false;\n        const resume = () => void context.resume().catch(() => {});\n        element.addEventListener(\"play\", resume);\n        cleanups.push(() => element.removeEventListener(\"play\", resume));\n      }\n      const analyser = context.createAnalyser();\n      analyser.fftSize = fftSize;\n      analyser.smoothingTimeConstant = smoothing;\n      analyser.minDecibels = minDecibels;\n      analyser.maxDecibels = maxDecibels;\n      input.connect(analyser); // never connect a mic to destination (feedback)\n      analyserRef.current = analyser;\n      freqRef.current = new Uint8Array(analyser.frequencyBinCount);\n      timeRef.current = new Float32Array(analyser.fftSize);\n      void context.resume().catch(() => {});\n      setStatus(\"running\");\n      setError(null);\n      cleanups.push(() => {\n        input.disconnect(analyser);\n        if (ownsContext) void context.close().catch(() => {});\n      });\n    } catch (err) {\n      setError(err instanceof Error ? err : new Error(String(err)));\n      setStatus(\"error\");\n    }\n    return () => {\n      analyserRef.current = null;\n      for (const fn of cleanups) fn();\n    };\n  }, [source, simulation, enabled, fftSize, smoothing, minDecibels, maxDecibels]);\n\n  const read = useCallback((): AudioFrame => {\n    const now = typeof performance !== \"undefined\" ? performance.now() : Date.now();\n    frame.time = now;\n    const sim = simRef.current;\n    const analyser = analyserRef.current;\n    const n = frame.bands.length;\n\n    if (sim) {\n      const t = now / 1000;\n      const level = simulatedLevel(sim, t);\n      for (let i = 0; i < n; i++) {\n        const x = i / Math.max(1, n - 1);\n        // spectral tilt: speech energy concentrates low-mid, music spreads wider\n        const tilt = sim === \"music\" ? 1 - x * 0.6 : Math.exp(-((x - 0.22) ** 2) / 0.08);\n        const jitter = 0.6 + 0.4 * smoothNoise(t * 9 + i * 1.7);\n        frame.bands[i] = clamp01(level * tilt * jitter * 1.15);\n      }\n      const len = frame.waveform.length;\n      for (let i = 0; i < len; i++) {\n        const p = i / len;\n        frame.waveform[i] =\n          level *\n          (0.6 * Math.sin(p * Math.PI * 2 * 3 + t * 9) +\n            0.3 * Math.sin(p * Math.PI * 2 * 7 + t * 13) +\n            0.1 * (noise(i + t) * 2 - 1));\n      }\n      frame.peak = level;\n      smoothedLevel.current += (level - smoothedLevel.current) * 0.35;\n      frame.level = smoothedLevel.current;\n      return frame;\n    }\n\n    if (!analyser || !freqRef.current || !timeRef.current) {\n      frame.bands.fill(0);\n      frame.waveform.fill(0);\n      frame.level = 0;\n      frame.peak = 0;\n      smoothedLevel.current = 0;\n      return frame;\n    }\n\n    const freq = freqRef.current;\n    const td = timeRef.current;\n    analyser.getByteFrequencyData(freq);\n    analyser.getFloatTimeDomainData(td);\n\n    // log-spaced bands between ~60 Hz and ~8 kHz\n    const sampleRate = analyser.context.sampleRate;\n    const binHz = sampleRate / analyser.fftSize;\n    const minHz = 60;\n    const maxHz = Math.min(8000, sampleRate / 2);\n    for (let i = 0; i < n; i++) {\n      const lo = minHz * (maxHz / minHz) ** (i / n);\n      const hi = minHz * (maxHz / minHz) ** ((i + 1) / n);\n      const a = Math.max(0, Math.floor(lo / binHz));\n      const b = Math.min(freq.length - 1, Math.max(a, Math.ceil(hi / binHz)));\n      let sum = 0;\n      for (let k = a; k <= b; k++) sum += freq[k] ?? 0;\n      frame.bands[i] = sum / ((b - a + 1) * 255);\n    }\n\n    let sumSq = 0;\n    let peak = 0;\n    const wlen = frame.waveform.length;\n    for (let i = 0; i < td.length; i++) {\n      const v = td[i] ?? 0;\n      sumSq += v * v;\n      const abs = Math.abs(v);\n      if (abs > peak) peak = abs;\n      if (i < wlen) frame.waveform[i] = v;\n    }\n    const rms = Math.sqrt(sumSq / td.length);\n    // perceptual scaling: speech RMS sits around 0.02–0.3\n    const level = clamp01(Math.sqrt(rms) * 1.6);\n    smoothedLevel.current += (level - smoothedLevel.current) * 0.3;\n    frame.level = smoothedLevel.current;\n    frame.peak = clamp01(peak);\n    return frame;\n  }, [frame]);\n\n  // Shared rAF loop that only runs while someone is subscribed.\n  const listeners = useRef(new Set<(frame: AudioFrame) => void>());\n  const rafRef = useRef<number | null>(null);\n  const tick = useCallback(() => {\n    const f = read();\n    for (const l of listeners.current) l(f);\n    rafRef.current = requestAnimationFrame(tick);\n  }, [read]);\n\n  const subscribe = useCallback(\n    (listener: (frame: AudioFrame) => void) => {\n      listeners.current.add(listener);\n      if (rafRef.current === null && typeof requestAnimationFrame !== \"undefined\") {\n        rafRef.current = requestAnimationFrame(tick);\n      }\n      return () => {\n        listeners.current.delete(listener);\n        if (listeners.current.size === 0 && rafRef.current !== null) {\n          cancelAnimationFrame(rafRef.current);\n          rafRef.current = null;\n        }\n      };\n    },\n    [tick],\n  );\n\n  useEffect(\n    () => () => {\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n    },\n    [],\n  );\n\n  return useMemo(() => ({ status, error, read, subscribe }), [status, error, read, subscribe]);\n}\n\nexport interface UseAudioFrameOptions {\n  /** Max React updates per second. Default 30. */\n  fps?: number;\n}\n\n/** A snapshot of the analyser as React state, throttled to `fps`. Copies the arrays. */\nexport interface AudioFrameSnapshot {\n  level: number;\n  peak: number;\n  bands: number[];\n  waveform: number[];\n}\n\n/**\n * Render audio data with plain React (e.g. 24 bars). Throttled, so it is fine for small DOM\n * visualisations. For canvas/WebGL use `analyser.subscribe` instead.\n */\nexport function useAudioFrame(\n  analyser: AudioAnalyser,\n  { fps = 30 }: UseAudioFrameOptions = {},\n): AudioFrameSnapshot {\n  const [snapshot, setSnapshot] = useState<AudioFrameSnapshot>(() => ({\n    level: 0,\n    peak: 0,\n    bands: [],\n    waveform: [],\n  }));\n  useEffect(() => {\n    let last = 0;\n    const interval = 1000 / fps;\n    return analyser.subscribe((f) => {\n      if (f.time - last < interval) return;\n      last = f.time;\n      setSnapshot({\n        level: f.level,\n        peak: f.peak,\n        bands: Array.from(f.bands),\n        waveform: Array.from(f.waveform),\n      });\n    });\n  }, [analyser, fps]);\n  return snapshot;\n}\n"
        }
      ]
    },
    {
      "name": "useMediaRecorder",
      "file": "use-media-recorder",
      "description": "MediaRecorder wrapper with explicit permission/denied/unsupported states, pause/resume, duration and a final Blob.",
      "signature": "useMediaRecorder({ constraints?: MediaStreamConstraints = { audio: true }, mimeType?: string, timeslice?: number, onDataAvailable?, onStop?, onError? }): { status: 'idle' | 'requesting' | 'recording' | 'paused' | 'stopped' | 'denied' | 'unsupported' | 'error', isSupported, isRecording, stream, blob, mimeType, error, durationMs, start(): Promise<MediaStream | null>, stop(): Promise<Blob | null>, pause(), resume(), reset() }",
      "returns": "Controls and state; `stream` is live while recording (feed it to useAudioAnalyser).",
      "notes": [
        "Tracks are stopped on stop/reset/unmount so the mic indicator turns off."
      ],
      "example": "const rec = useMediaRecorder({ onStop: (blob) => upload(blob) });\n<button onClick={rec.isRecording ? rec.stop : rec.start}>…</button>",
      "tags": [
        "audio",
        "recording",
        "microphone",
        "voice"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-media-recorder.json",
      "files": [
        {
          "target": "hooks/kura/use-media-recorder.ts",
          "type": "registry:hook",
          "content": "import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\n\nexport type MediaRecorderStatus =\n  | \"idle\"\n  /** Waiting for the permission prompt. */\n  | \"requesting\"\n  | \"recording\"\n  | \"paused\"\n  /** Finished; `blob` holds the recording. */\n  | \"stopped\"\n  /** The user or browser denied mic/camera access. */\n  | \"denied\"\n  /** No MediaRecorder or getUserMedia (SSR, old browsers, insecure origin, jsdom). */\n  | \"unsupported\"\n  | \"error\";\n\nexport interface UseMediaRecorderOptions {\n  /** Passed to getUserMedia. Default `{ audio: true }`. */\n  constraints?: MediaStreamConstraints;\n  /** Preferred mime type; falls back to the first supported of webm/opus, mp4, ogg. */\n  mimeType?: string;\n  /** Emit chunks every N ms (for streaming upload). Default: one chunk on stop. */\n  timeslice?: number;\n  onDataAvailable?: (chunk: Blob) => void;\n  onStop?: (blob: Blob) => void;\n  onError?: (error: Error) => void;\n}\n\nexport interface MediaRecorderControls {\n  status: MediaRecorderStatus;\n  isSupported: boolean;\n  isRecording: boolean;\n  /** Live stream while recording — feed it to `useAudioAnalyser({ source: stream })`. */\n  stream: MediaStream | null;\n  /** Final recording after `stop()`. */\n  blob: Blob | null;\n  mimeType: string | null;\n  error: Error | null;\n  /** Elapsed recording time in ms (excludes paused time). Updates ~4×/s. */\n  durationMs: number;\n  /** Ask for permission and start. Resolves with the stream, or null on failure/unsupported. */\n  start: () => Promise<MediaStream | null>;\n  /** Stop and resolve with the final blob (null if nothing was recording). */\n  stop: () => Promise<Blob | null>;\n  pause: () => void;\n  resume: () => void;\n  /** Stop tracks and clear blob/error, back to \"idle\". */\n  reset: () => void;\n}\n\nconst noopSubscribe = () => () => {};\n\nfunction detectSupport(): boolean {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.MediaRecorder !== \"undefined\" &&\n    typeof navigator !== \"undefined\" &&\n    !!navigator.mediaDevices?.getUserMedia\n  );\n}\n\nfunction pickMimeType(preferred?: string): string | undefined {\n  if (typeof MediaRecorder === \"undefined\" || typeof MediaRecorder.isTypeSupported !== \"function\") {\n    return preferred;\n  }\n  const candidates = [\n    preferred,\n    \"audio/webm;codecs=opus\",\n    \"audio/webm\",\n    \"audio/mp4\",\n    \"audio/ogg;codecs=opus\",\n  ];\n  return candidates.find((t): t is string => !!t && MediaRecorder.isTypeSupported(t));\n}\n\nfunction toError(err: unknown): Error {\n  return err instanceof Error ? err : new Error(String(err));\n}\n\n/**\n * Record audio (or video) with MediaRecorder, with explicit states for permission, denial and\n * unsupported environments. SSR-safe: `isSupported` is false on the server and hydrates.\n */\nexport function useMediaRecorder(options: UseMediaRecorderOptions = {}): MediaRecorderControls {\n  const optsRef = useRef(options);\n  optsRef.current = options;\n  const isSupported = useSyncExternalStore(noopSubscribe, detectSupport, () => false);\n\n  const [status, setStatus] = useState<MediaRecorderStatus>(\"idle\");\n  const [stream, setStream] = useState<MediaStream | null>(null);\n  const [blob, setBlob] = useState<Blob | null>(null);\n  const [mimeType, setMimeType] = useState<string | null>(null);\n  const [error, setError] = useState<Error | null>(null);\n  const [durationMs, setDurationMs] = useState(0);\n\n  const recorderRef = useRef<MediaRecorder | null>(null);\n  const streamRef = useRef<MediaStream | null>(null);\n  const chunksRef = useRef<Blob[]>([]);\n  const stopResolvers = useRef<Array<(blob: Blob | null) => void>>([]);\n  const clock = useRef({ startedAt: 0, accumulated: 0 });\n\n  const stopTracks = useCallback(() => {\n    for (const track of streamRef.current?.getTracks() ?? []) track.stop();\n    streamRef.current = null;\n    setStream(null);\n  }, []);\n\n  const fail = useCallback((err: unknown, next: MediaRecorderStatus = \"error\") => {\n    const e = toError(err);\n    setError(e);\n    setStatus(next);\n    optsRef.current.onError?.(e);\n  }, []);\n\n  const start = useCallback(async (): Promise<MediaStream | null> => {\n    if (!detectSupport()) {\n      setStatus(\"unsupported\");\n      return null;\n    }\n    if (recorderRef.current && recorderRef.current.state !== \"inactive\") return streamRef.current;\n    setStatus(\"requesting\");\n    setError(null);\n    setBlob(null);\n    let media: MediaStream;\n    try {\n      media = await navigator.mediaDevices.getUserMedia(\n        optsRef.current.constraints ?? { audio: true },\n      );\n    } catch (err) {\n      const name = (err as { name?: string })?.name;\n      fail(err, name === \"NotAllowedError\" || name === \"SecurityError\" ? \"denied\" : \"error\");\n      return null;\n    }\n    try {\n      const type = pickMimeType(optsRef.current.mimeType);\n      const recorder = new MediaRecorder(media, type ? { mimeType: type } : undefined);\n      chunksRef.current = [];\n      recorder.ondataavailable = (event) => {\n        if (event.data && event.data.size > 0) {\n          chunksRef.current.push(event.data);\n          optsRef.current.onDataAvailable?.(event.data);\n        }\n      };\n      recorder.onstop = () => {\n        const out = new Blob(chunksRef.current, { type: recorder.mimeType || type || \"\" });\n        setBlob(out);\n        setStatus(\"stopped\");\n        optsRef.current.onStop?.(out);\n        for (const resolve of stopResolvers.current) resolve(out);\n        stopResolvers.current = [];\n        stopTracks();\n      };\n      recorder.onerror = (event) => fail((event as unknown as { error?: unknown }).error ?? event);\n      recorderRef.current = recorder;\n      streamRef.current = media;\n      setStream(media);\n      setMimeType(recorder.mimeType || type || null);\n      clock.current = { startedAt: performance.now(), accumulated: 0 };\n      setDurationMs(0);\n      recorder.start(optsRef.current.timeslice);\n      setStatus(\"recording\");\n      return media;\n    } catch (err) {\n      for (const track of media.getTracks()) track.stop();\n      fail(err);\n      return null;\n    }\n  }, [fail, stopTracks]);\n\n  const stop = useCallback((): Promise<Blob | null> => {\n    const recorder = recorderRef.current;\n    if (!recorder || recorder.state === \"inactive\") return Promise.resolve(null);\n    return new Promise((resolve) => {\n      stopResolvers.current.push(resolve);\n      recorder.stop();\n    });\n  }, []);\n\n  const pause = useCallback(() => {\n    const recorder = recorderRef.current;\n    if (recorder?.state !== \"recording\") return;\n    recorder.pause();\n    clock.current.accumulated += performance.now() - clock.current.startedAt;\n    setStatus(\"paused\");\n  }, []);\n\n  const resume = useCallback(() => {\n    const recorder = recorderRef.current;\n    if (recorder?.state !== \"paused\") return;\n    recorder.resume();\n    clock.current.startedAt = performance.now();\n    setStatus(\"recording\");\n  }, []);\n\n  const reset = useCallback(() => {\n    const recorder = recorderRef.current;\n    if (recorder && recorder.state !== \"inactive\") {\n      recorder.onstop = null;\n      recorder.stop();\n    }\n    recorderRef.current = null;\n    stopTracks();\n    setBlob(null);\n    setError(null);\n    setDurationMs(0);\n    setStatus(\"idle\");\n  }, [stopTracks]);\n\n  useEffect(() => {\n    if (status !== \"recording\") return;\n    const id = setInterval(() => {\n      setDurationMs(clock.current.accumulated + performance.now() - clock.current.startedAt);\n    }, 250);\n    return () => clearInterval(id);\n  }, [status]);\n\n  // Release the mic on unmount.\n  useEffect(\n    () => () => {\n      const recorder = recorderRef.current;\n      if (recorder && recorder.state !== \"inactive\") {\n        recorder.onstop = null;\n        recorder.stop();\n      }\n      for (const track of streamRef.current?.getTracks() ?? []) track.stop();\n    },\n    [],\n  );\n\n  return {\n    status: isSupported || status !== \"idle\" ? status : \"unsupported\",\n    isSupported,\n    isRecording: status === \"recording\",\n    stream,\n    blob,\n    mimeType,\n    error,\n    durationMs,\n    start,\n    stop,\n    pause,\n    resume,\n    reset,\n  };\n}\n"
        }
      ]
    },
    {
      "name": "useSpeechRecognition",
      "file": "use-speech-recognition",
      "description": "Web Speech API speech-to-text with interim results; reports unsupported (e.g. Firefox) so you can fall back to recording + server transcription.",
      "signature": "useSpeechRecognition({ lang?: string, continuous?: boolean = true, interimResults?: boolean = true, onResult?, onEnd?, onError? }): { status: 'idle' | 'listening' | 'denied' | 'unsupported' | 'error', isSupported, isListening, transcript, finalTranscript, interimTranscript, error, start(), stop(), abort(), reset() }",
      "returns": "Transcripts and controls. `transcript` = final + interim.",
      "example": "const speech = useSpeechRecognition({ lang: 'en-US' });\n<textarea value={speech.transcript} readOnly />",
      "tags": [
        "speech",
        "dictation",
        "voice",
        "transcription"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-speech-recognition.json",
      "files": [
        {
          "target": "hooks/kura/use-speech-recognition.ts",
          "type": "registry:hook",
          "content": "import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\n\nexport type SpeechRecognitionStatus =\n  | \"idle\"\n  | \"listening\"\n  /** Mic permission denied or the service is not allowed. */\n  | \"denied\"\n  /** No Web Speech API (Firefox, SSR, jsdom). Fall back to recording + server transcription. */\n  | \"unsupported\"\n  | \"error\";\n\nexport interface SpeechRecognitionResultChunk {\n  transcript: string;\n  isFinal: boolean;\n  confidence: number;\n}\n\nexport interface UseSpeechRecognitionOptions {\n  /** BCP-47 language tag. Default: the document language or browser default. */\n  lang?: string;\n  /** Keep listening across pauses. Default true. */\n  continuous?: boolean;\n  /** Emit partial results while speaking. Default true. */\n  interimResults?: boolean;\n  onResult?: (result: SpeechRecognitionResultChunk) => void;\n  onEnd?: (finalTranscript: string) => void;\n  onError?: (error: Error) => void;\n}\n\nexport interface SpeechRecognitionControls {\n  status: SpeechRecognitionStatus;\n  isSupported: boolean;\n  isListening: boolean;\n  /** finalTranscript + interimTranscript — what to show in the input. */\n  transcript: string;\n  finalTranscript: string;\n  interimTranscript: string;\n  error: Error | null;\n  start: () => void;\n  /** Stop and finalise pending results. */\n  stop: () => void;\n  /** Stop and discard pending results. */\n  abort: () => void;\n  /** Clear transcripts and errors. */\n  reset: () => void;\n}\n\n// Minimal Web Speech API typings (not in lib.dom).\ninterface SpeechRecognitionAlternativeLike {\n  transcript: string;\n  confidence: number;\n}\ninterface SpeechRecognitionResultLike {\n  isFinal: boolean;\n  length: number;\n  [index: number]: SpeechRecognitionAlternativeLike;\n}\ninterface SpeechRecognitionEventLike {\n  resultIndex: number;\n  results: { length: number; [index: number]: SpeechRecognitionResultLike };\n}\ninterface SpeechRecognitionLike {\n  lang: string;\n  continuous: boolean;\n  interimResults: boolean;\n  start(): void;\n  stop(): void;\n  abort(): void;\n  onresult: ((event: SpeechRecognitionEventLike) => void) | null;\n  onerror: ((event: { error: string; message?: string }) => void) | null;\n  onend: (() => void) | null;\n  onstart: (() => void) | null;\n}\ntype SpeechRecognitionCtor = new () => SpeechRecognitionLike;\n\nfunction getCtor(): SpeechRecognitionCtor | null {\n  if (typeof window === \"undefined\") return null;\n  const w = window as unknown as {\n    SpeechRecognition?: SpeechRecognitionCtor;\n    webkitSpeechRecognition?: SpeechRecognitionCtor;\n  };\n  return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n}\n\nconst noopSubscribe = () => () => {};\n\n/**\n * Browser speech-to-text (Web Speech API) with interim results and graceful fallback.\n * When `isSupported` is false, record with `useMediaRecorder` and transcribe on your server.\n */\nexport function useSpeechRecognition(\n  options: UseSpeechRecognitionOptions = {},\n): SpeechRecognitionControls {\n  const optsRef = useRef(options);\n  optsRef.current = options;\n  const isSupported = useSyncExternalStore(\n    noopSubscribe,\n    () => getCtor() !== null,\n    () => false,\n  );\n\n  const [status, setStatus] = useState<SpeechRecognitionStatus>(\"idle\");\n  const [finalTranscript, setFinal] = useState(\"\");\n  const [interimTranscript, setInterim] = useState(\"\");\n  const [error, setError] = useState<Error | null>(null);\n  const recognitionRef = useRef<SpeechRecognitionLike | null>(null);\n  const finalRef = useRef(\"\");\n\n  const start = useCallback(() => {\n    const Ctor = getCtor();\n    if (!Ctor) {\n      setStatus(\"unsupported\");\n      return;\n    }\n    if (recognitionRef.current) return;\n    const { lang, continuous = true, interimResults = true } = optsRef.current;\n    const recognition = new Ctor();\n    recognition.lang =\n      lang ?? (typeof document !== \"undefined\" ? document.documentElement.lang : \"\") ?? \"\";\n    recognition.continuous = continuous;\n    recognition.interimResults = interimResults;\n    recognition.onstart = () => setStatus(\"listening\");\n    recognition.onresult = (event) => {\n      let interim = \"\";\n      for (let i = event.resultIndex; i < event.results.length; i++) {\n        const result = event.results[i];\n        const alt = result?.[0];\n        if (!result || !alt) continue;\n        if (result.isFinal) {\n          finalRef.current += alt.transcript;\n          setFinal(finalRef.current);\n        } else {\n          interim += alt.transcript;\n        }\n        optsRef.current.onResult?.({\n          transcript: alt.transcript,\n          isFinal: result.isFinal,\n          confidence: alt.confidence,\n        });\n      }\n      setInterim(interim);\n    };\n    recognition.onerror = (event) => {\n      const e = new Error(event.message || event.error);\n      e.name = event.error;\n      setError(e);\n      setStatus(\n        event.error === \"not-allowed\" || event.error === \"service-not-allowed\" ? \"denied\" : \"error\",\n      );\n      optsRef.current.onError?.(e);\n    };\n    recognition.onend = () => {\n      recognitionRef.current = null;\n      setInterim(\"\");\n      setStatus((s) => (s === \"listening\" ? \"idle\" : s));\n      optsRef.current.onEnd?.(finalRef.current);\n    };\n    recognitionRef.current = recognition;\n    setError(null);\n    setStatus(\"listening\");\n    try {\n      recognition.start();\n    } catch (err) {\n      recognitionRef.current = null;\n      const e = err instanceof Error ? err : new Error(String(err));\n      setError(e);\n      setStatus(\"error\");\n    }\n  }, []);\n\n  const stop = useCallback(() => recognitionRef.current?.stop(), []);\n  const abort = useCallback(() => {\n    recognitionRef.current?.abort();\n    setInterim(\"\");\n  }, []);\n  const reset = useCallback(() => {\n    finalRef.current = \"\";\n    setFinal(\"\");\n    setInterim(\"\");\n    setError(null);\n  }, []);\n\n  useEffect(() => () => recognitionRef.current?.abort(), []);\n\n  const effectiveStatus = !isSupported && status === \"idle\" ? \"unsupported\" : status;\n  return {\n    status: effectiveStatus,\n    isSupported,\n    isListening: status === \"listening\",\n    transcript: finalTranscript + interimTranscript,\n    finalTranscript,\n    interimTranscript,\n    error,\n    start,\n    stop,\n    abort,\n    reset,\n  };\n}\n"
        }
      ]
    }
  ],
  "libs": [
    {
      "name": "keys",
      "registryUrl": "https://kuro.abstergo.fyi/r/kura-keys.json",
      "files": [
        {
          "target": "lib/kura/keys.ts",
          "type": "registry:lib",
          "content": "/**\n * Keyboard helpers shared by Kbd, KbdShortcut, Tooltip and any component that shows or binds keys.\n * Key names follow `KeyboardEvent.key` (case-insensitive) plus the aliases `mod`, `cmd`, `ctrl`,\n * `alt`/`option`, `shift`, `enter`/`return`, `esc`, arrows (`up`, `down`, `left`, `right`).\n * `mod` means ⌘ on Apple platforms and Ctrl elsewhere.\n */\n\nexport type KeyName = string;\n\nconst MODIFIERS = [\"mod\", \"meta\", \"cmd\", \"command\", \"ctrl\", \"control\", \"alt\", \"option\", \"shift\"];\n\n/** True on macOS / iOS. SSR-safe: returns false on the server. */\nexport function isApplePlatform(): boolean {\n  if (typeof navigator === \"undefined\") return false;\n  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n  const platform = nav.userAgentData?.platform ?? nav.platform ?? \"\";\n  return /mac|iphone|ipad|ipod/i.test(platform);\n}\n\n/** Normalize an alias to a canonical key id. */\nexport function normalizeKey(key: KeyName): string {\n  // `KeyboardEvent.key` for the space bar is \" \": trimming it first would turn it into \"\".\n  if (key.length > 0 && key.trim() === \"\") return \" \";\n  const k = key.trim().toLowerCase();\n  switch (k) {\n    case \"cmd\":\n    case \"command\":\n    case \"meta\":\n      return \"meta\";\n    case \"control\":\n      return \"ctrl\";\n    case \"option\":\n      return \"alt\";\n    case \"return\":\n      return \"enter\";\n    case \"esc\":\n      return \"escape\";\n    case \"up\":\n      return \"arrowup\";\n    case \"down\":\n      return \"arrowdown\";\n    case \"left\":\n      return \"arrowleft\";\n    case \"right\":\n      return \"arrowright\";\n    case \"space\":\n    case \"spacebar\":\n      return \" \";\n    default:\n      return k;\n  }\n}\n\nconst APPLE_SYMBOLS: Record<string, string> = {\n  mod: \"⌘\",\n  meta: \"⌘\",\n  ctrl: \"⌃\",\n  alt: \"⌥\",\n  shift: \"⇧\",\n};\n\nconst SYMBOLS: Record<string, string> = {\n  enter: \"↵\",\n  escape: \"Esc\",\n  backspace: \"⌫\",\n  delete: \"⌦\",\n  tab: \"⇥\",\n  arrowup: \"↑\",\n  arrowdown: \"↓\",\n  arrowleft: \"←\",\n  arrowright: \"→\",\n  \" \": \"Space\",\n  capslock: \"⇪\",\n  pageup: \"PgUp\",\n  pagedown: \"PgDn\",\n};\n\nconst LABELS: Record<string, string> = {\n  mod: \"Command\",\n  meta: \"Command\",\n  ctrl: \"Control\",\n  alt: \"Option\",\n  shift: \"Shift\",\n  enter: \"Enter\",\n  escape: \"Escape\",\n  backspace: \"Backspace\",\n  delete: \"Delete\",\n  tab: \"Tab\",\n  arrowup: \"Up arrow\",\n  arrowdown: \"Down arrow\",\n  arrowleft: \"Left arrow\",\n  arrowright: \"Right arrow\",\n  \" \": \"Space\",\n};\n\n/** Visible glyph for a key, platform-aware (⌘ vs Ctrl). */\nexport function formatKey(key: KeyName, apple = isApplePlatform()): string {\n  const k = normalizeKey(key);\n  if (apple && APPLE_SYMBOLS[k]) return APPLE_SYMBOLS[k];\n  if (!apple) {\n    if (k === \"mod\" || k === \"ctrl\") return \"Ctrl\";\n    if (k === \"meta\") return \"Win\";\n    if (k === \"alt\") return \"Alt\";\n    if (k === \"shift\") return \"Shift\";\n  }\n  if (SYMBOLS[k]) return SYMBOLS[k];\n  return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);\n}\n\n/** Spoken label for a key, used for aria-label (\"Command K\"). */\nexport function keyLabel(key: KeyName, apple = isApplePlatform()): string {\n  const k = normalizeKey(key);\n  if (k === \"mod\") return apple ? \"Command\" : \"Control\";\n  if (!apple && k === \"alt\") return \"Alt\";\n  if (LABELS[k]) return LABELS[k];\n  return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);\n}\n\nexport function isModifier(key: KeyName): boolean {\n  return MODIFIERS.includes(key.trim().toLowerCase());\n}\n\n/**\n * Does a keyboard event match a combo like [\"mod\", \"k\"]? Modifiers must match exactly,\n * so [\"mod\", \"k\"] does not fire on ⌘⇧K.\n */\nexport function matchesKeyCombo(\n  event: Pick<KeyboardEvent, \"key\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">,\n  keys: readonly KeyName[],\n  apple = isApplePlatform(),\n): boolean {\n  const want = { meta: false, ctrl: false, alt: false, shift: false };\n  let main: string | undefined;\n  for (const raw of keys) {\n    const k = normalizeKey(raw);\n    if (k === \"mod\") {\n      if (apple) want.meta = true;\n      else want.ctrl = true;\n    } else if (k === \"meta\" || k === \"ctrl\" || k === \"alt\" || k === \"shift\") {\n      want[k] = true;\n    } else {\n      main = k;\n    }\n  }\n  if (event.metaKey !== want.meta || event.ctrlKey !== want.ctrl || event.altKey !== want.alt) {\n    return false;\n  }\n  if (!main) return false;\n  const pressed = normalizeKey(event.key);\n  // Shift is exact for letters, digits and named keys. For symbols (\"?\", \"@\") the shift state\n  // is implied by the character itself, so only a required shift is enforced.\n  const isSymbol = main.length === 1 && !/[a-z0-9]/.test(main);\n  if (!isSymbol && event.shiftKey !== want.shift) return false;\n  if (isSymbol && want.shift && !event.shiftKey) return false;\n  return pressed === main;\n}\n"
        }
      ]
    },
    {
      "name": "merge-refs",
      "registryUrl": "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
      "files": [
        {
          "target": "lib/kura/merge-refs.ts",
          "type": "registry:lib",
          "content": "import type * as React from \"react\";\n\n/** Combine several refs (callback or object) into one callback ref. React 19 cleanup-aware. */\nexport function mergeRefs<T>(\n  ...refs: Array<React.Ref<T> | undefined | null>\n): React.RefCallback<T> {\n  return (value: T | null) => {\n    const cleanups: Array<() => void> = [];\n    for (const ref of refs) {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(value);\n        if (typeof cleanup === \"function\") cleanups.push(cleanup);\n      } else if (ref) {\n        (ref as React.RefObject<T | null>).current = value;\n      }\n    }\n    if (cleanups.length === 0) return;\n    return () => {\n      for (const c of cleanups) c();\n    };\n  };\n}\n"
        }
      ]
    },
    {
      "name": "motion",
      "registryUrl": "https://kuro.abstergo.fyi/r/kura-motion.json",
      "files": [
        {
          "target": "lib/kura/motion.ts",
          "type": "registry:lib",
          "content": "/**\n * Kura motion presets. Use these instead of ad-hoc numbers so every surface moves the same way.\n * See DESIGN.md → Motion. CSS equivalents live in styles/kura.css (--kura-duration-*, --ease-kura-*).\n *\n *   import { springs } from \"@kura-ui/react/utils\";\n *   <motion.div transition={springs.snappy} />\n */\n\n/** Spring presets for `motion/react`. `visualDuration` is the perceived duration in seconds. */\nexport const springs = {\n  /** Presses, toggles, small state flips. Fast and nearly critically damped. */\n  snappy: { type: \"spring\", visualDuration: 0.2, bounce: 0.1 },\n  /** Default for layout changes, panels, popovers. No overshoot. */\n  smooth: { type: \"spring\", visualDuration: 0.35, bounce: 0 },\n  /** Large surfaces (sheets, drawers, hero elements). */\n  gentle: { type: \"spring\", visualDuration: 0.5, bounce: 0.05 },\n  /** Playful confirmation and \"alive\" elements (orbs, success ticks). Use sparingly. */\n  bouncy: { type: \"spring\", visualDuration: 0.4, bounce: 0.25 },\n} as const;\n\n/** Durations in seconds (motion) — mirror of --kura-duration-* in CSS (ms). */\nexport const durations = {\n  instant: 0.09,\n  fast: 0.15,\n  base: 0.22,\n  slow: 0.32,\n  slower: 0.48,\n} as const;\n\n/** Cubic-bezier easings — mirror of --ease-kura-* in CSS. */\nexport const easings = {\n  /** Default. Strong ease-out: responsive start, soft landing. Enter + user-initiated motion. */\n  out: [0.23, 1, 0.32, 1],\n  /** Elements moving on-screen from A to B. */\n  inOut: [0.77, 0, 0.175, 1],\n  /** Exits only. Never for enters. */\n  in: [0.55, 0, 1, 0.45],\n} as const satisfies Record<string, readonly [number, number, number, number]>;\n\nexport type SpringPreset = keyof typeof springs;\n\n/** Transition to use when the user prefers reduced motion: instant, or a short fade. */\nexport const reducedTransition = { duration: 0 } as const;\n"
        }
      ]
    },
    {
      "name": "shader-color",
      "registryUrl": "https://kuro.abstergo.fyi/r/kura-shader-color.json",
      "files": [
        {
          "target": "lib/kura/shader-color.ts",
          "type": "registry:lib",
          "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Colour plumbing for shaders: CSS colour strings (tokens, OKLCH, hex, rgb…) → linear-light RGB\n * and OKLab, resolved against the live theme. Shaders cannot read CSS variables, so components\n * resolve them here and re-resolve when the theme flips.\n */\n\nexport type Vec3 = [number, number, number];\n\nexport interface ResolvedColor {\n  /** Linear-light sRGB, 0–1 (clamped to gamut). */\n  linear: Vec3;\n  /** OKLab (L 0–1, a/b roughly ±0.4). Blend in this space for even, band-free gradients. */\n  oklab: Vec3;\n  alpha: number;\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\nfunction srgbToLinear(c: number): number {\n  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\nexport function oklabToLinear([L, a, b]: Vec3): Vec3 {\n  const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;\n  const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;\n  const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;\n  return [\n    clamp01(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),\n    clamp01(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),\n    clamp01(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),\n  ];\n}\n\nexport function linearToOklab([r, g, b]: Vec3): Vec3 {\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n  return [\n    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n  ];\n}\n\nfunction num(token: string | undefined, percentScale = 1): number {\n  if (!token || token === \"none\") return 0;\n  if (token.endsWith(\"%\")) return (Number.parseFloat(token) / 100) * percentScale;\n  return Number.parseFloat(token);\n}\n\nfunction hue(token: string | undefined): number {\n  if (!token || token === \"none\") return 0;\n  const v = Number.parseFloat(token);\n  if (token.endsWith(\"rad\")) return (v * 180) / Math.PI;\n  if (token.endsWith(\"turn\")) return v * 360;\n  if (token.endsWith(\"grad\")) return v * 0.9;\n  return v;\n}\n\n/** Parse a concrete CSS colour (no `var()`) into linear RGB. Returns null for unknown syntax. */\nexport function parseColor(input: string): { linear: Vec3; alpha: number } | null {\n  const str = input.trim().toLowerCase();\n  if (str.startsWith(\"#\")) {\n    let hex = str.slice(1);\n    if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join(\"\");\n    if (hex.length !== 6 && hex.length !== 8) return null;\n    const v = Number.parseInt(hex, 16);\n    if (Number.isNaN(v)) return null;\n    const has = hex.length === 8;\n    const n = has ? v >>> 8 : v;\n    return {\n      linear: [\n        srgbToLinear(((n >> 16) & 255) / 255),\n        srgbToLinear(((n >> 8) & 255) / 255),\n        srgbToLinear((n & 255) / 255),\n      ],\n      alpha: has ? (v & 255) / 255 : 1,\n    };\n  }\n  const match = /^([a-z-]+)\\((.*)\\)$/.exec(str);\n  if (!match) return null;\n  const fn = match[1];\n  const [body = \"\", alphaPart] = (match[2] ?? \"\").split(\"/\");\n  const parts = body.replace(/,/g, \" \").trim().split(/\\s+/);\n  let alpha = alphaPart ? num(alphaPart.trim()) : 1;\n  if (fn === \"rgb\" || fn === \"rgba\") {\n    if (!alphaPart && parts.length === 4) alpha = num(parts[3]);\n    const c = (t: string | undefined) => srgbToLinear(clamp01(num(t, 255) / 255));\n    return { linear: [c(parts[0]), c(parts[1]), c(parts[2])], alpha };\n  }\n  if (fn === \"oklch\" || fn === \"oklab\") {\n    const L = num(parts[0]);\n    let a: number;\n    let b: number;\n    if (fn === \"oklch\") {\n      const C = num(parts[1], 0.4);\n      const h = (hue(parts[2]) * Math.PI) / 180;\n      a = C * Math.cos(h);\n      b = C * Math.sin(h);\n    } else {\n      a = num(parts[1], 0.4);\n      b = num(parts[2], 0.4);\n    }\n    return { linear: oklabToLinear([L, a, b]), alpha };\n  }\n  if (fn === \"color\") {\n    const space = parts.shift();\n    const [r, g, b] = [num(parts[0]), num(parts[1]), num(parts[2])];\n    if (space === \"srgb\") return { linear: [r, g, b].map(srgbToLinear) as Vec3, alpha };\n    if (space === \"srgb-linear\") return { linear: [r, g, b], alpha };\n    if (space === \"display-p3\") {\n      const [pr, pg, pb] = [r, g, b].map(srgbToLinear) as Vec3;\n      return {\n        linear: [\n          clamp01(1.2249 * pr - 0.2247 * pg),\n          clamp01(-0.042 * pr + 1.0419 * pg),\n          clamp01(-0.0197 * pr - 0.0786 * pg + 1.0979 * pb),\n        ],\n        alpha,\n      };\n    }\n  }\n  return null;\n}\n\nlet canvasCtx: CanvasRenderingContext2D | null | undefined;\n\n/**\n * Resolve any CSS colour — including `var(--kura-accent)`, named colours and `color-mix()` — in\n * the context of `host` (so theme classes apply). Falls back to mid-gray if the browser cannot.\n */\nexport function resolveColor(value: string, host: Element): ResolvedColor {\n  let parsed = parseColor(value);\n  if (!parsed && typeof document !== \"undefined\") {\n    const probe = document.createElement(\"span\");\n    probe.style.cssText = \"position:absolute;width:0;height:0;visibility:hidden;\";\n    probe.style.color = value;\n    host.appendChild(probe);\n    const computed = getComputedStyle(probe).color;\n    probe.remove();\n    parsed = parseColor(computed);\n    if (!parsed) {\n      if (canvasCtx === undefined) {\n        canvasCtx = document.createElement(\"canvas\").getContext(\"2d\", {\n          willReadFrequently: true,\n        });\n      }\n      if (canvasCtx) {\n        canvasCtx.clearRect(0, 0, 1, 1);\n        canvasCtx.fillStyle = computed;\n        canvasCtx.fillRect(0, 0, 1, 1);\n        const [r = 0, g = 0, b = 0, a = 255] = canvasCtx.getImageData(0, 0, 1, 1).data;\n        parsed = {\n          linear: [srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255)],\n          alpha: a / 255,\n        };\n      }\n    }\n  }\n  const linear = parsed?.linear ?? [0.2, 0.2, 0.2];\n  return { linear, oklab: linearToOklab(linear), alpha: parsed?.alpha ?? 1 };\n}\n\n/**\n * A counter that bumps whenever the document theme may have changed: `class`, `data-theme` or\n * `style` on <html>/<body>, or the OS colour scheme. Use it as an effect dependency.\n */\nexport function useThemeVersion(): number {\n  const [version, setVersion] = React.useState(0);\n  React.useEffect(() => {\n    const bump = () => setVersion((v) => v + 1);\n    const mo = new MutationObserver(bump);\n    const opts = { attributes: true, attributeFilter: [\"class\", \"data-theme\", \"style\"] };\n    mo.observe(document.documentElement, opts);\n    if (document.body) mo.observe(document.body, opts);\n    const mql = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n    mql?.addEventListener?.(\"change\", bump);\n    return () => {\n      mo.disconnect();\n      mql?.removeEventListener?.(\"change\", bump);\n    };\n  }, []);\n  return version;\n}\n\n/**\n * Resolve `colors` against `hostRef` whenever they or the theme change. Returns a ref (read it\n * inside shader uniform functions) and the theme version (use it to redraw static frames).\n */\nexport function useResolvedColors(\n  hostRef: React.RefObject<Element | null>,\n  colors: readonly string[],\n): { colors: React.RefObject<ResolvedColor[]>; version: number } {\n  const version = useThemeVersion();\n  const resolved = React.useRef<ResolvedColor[]>([]);\n  const key = colors.join(\"|\");\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `key` captures `colors`; `version` re-resolves on theme change.\n  React.useLayoutEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    resolved.current = colors.map((c) => resolveColor(c, host));\n  }, [key, version, hostRef]);\n  return { colors: resolved, version };\n}\n\n/** GLSL: OKLab → linear sRGB, linear → sRGB transfer, and interleaved-gradient-noise dither. */\nexport const GLSL_COLOR = /* glsl */ `\nvec3 oklabToLinear(vec3 c) {\n  float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z;\n  float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z;\n  float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z;\n  l = l * l * l; m = m * m * m; s = s * s * s;\n  return vec3(\n    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,\n    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,\n    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s);\n}\nvec3 linearToSrgb(vec3 c) {\n  c = clamp(c, 0.0, 1.0);\n  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));\n}\nfloat ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }\nvec3 dither(vec3 c) { return c + (ign(gl_FragCoord.xy) - 0.5) / 255.0; }\n`;\n"
        }
      ]
    }
  ],
  "tokens": {
    "theme": {
      "--font-kura-sans": "var(--font-geist-sans, \"Geist\"), \"Geist Variable\", \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif",
      "--font-kura-mono": "var(--font-geist-mono, \"Geist Mono\"), \"Geist Mono Variable\", ui-monospace, \"SF Mono\", \"JetBrains Mono\", Menlo, monospace",
      "--radius-kura-xs": "4px",
      "--radius-kura-sm": "6px",
      "--radius-kura-md": "8px",
      "--radius-kura-lg": "12px",
      "--radius-kura-xl": "16px",
      "--radius-kura-2xl": "20px",
      "--ease-kura-out": "cubic-bezier(0.23, 1, 0.32, 1)",
      "--ease-kura-in-out": "cubic-bezier(0.77, 0, 0.175, 1)",
      "--ease-kura-in": "cubic-bezier(0.55, 0, 1, 0.45)",
      "--ease-kura-spring": "linear( 0, 0.0409, 0.137, 0.2584, 0.3854, 0.5063, 0.6145, 0.707, 0.7835, 0.8447, 0.8923, 0.9284, 0.955, 0.974, 0.9872, 0.9959, 1.0013, 1.0044, 1.0059, 1.0063, 1.0061, 1.0055, 1.0047, 1.0039, 1.0032, 1.0025, 1.0019, 1.0014, 1.001, 1.0007, 1.0005, 1.0003, 1.0002 )",
      "--animate-kura-pop-in": "kura-pop-in var(--kura-duration-fast) var(--ease-kura-out) both",
      "--animate-kura-pop-out": "kura-pop-out var(--kura-duration-instant) var(--ease-kura-in) both",
      "--animate-kura-fade-in": "kura-fade-in var(--kura-duration-base) var(--ease-kura-out) both",
      "--animate-kura-fade-out": "kura-fade-out var(--kura-duration-fast) var(--ease-kura-in) both",
      "--animate-kura-rise-in": "kura-rise-in var(--kura-duration-slow) var(--ease-kura-out) both",
      "--animate-kura-spin": "kura-spin 0.8s linear infinite",
      "--animate-kura-shimmer": "kura-shimmer var(--kura-shimmer-duration, 2s) linear infinite",
      "--animate-kura-pulse": "kura-pulse 1.6s var(--ease-kura-in-out) infinite",
      "--color-kura-canvas": "var(--kura-canvas)",
      "--color-kura-surface": "var(--kura-surface)",
      "--color-kura-surface-raised": "var(--kura-surface-raised)",
      "--color-kura-surface-sunken": "var(--kura-surface-sunken)",
      "--color-kura-overlay": "var(--kura-overlay)",
      "--color-kura-hover": "var(--kura-hover)",
      "--color-kura-active": "var(--kura-active)",
      "--color-kura-fg": "var(--kura-fg)",
      "--color-kura-fg-muted": "var(--kura-fg-muted)",
      "--color-kura-fg-subtle": "var(--kura-fg-subtle)",
      "--color-kura-fg-inverted": "var(--kura-fg-inverted)",
      "--color-kura-border": "var(--kura-border)",
      "--color-kura-border-subtle": "var(--kura-border-subtle)",
      "--color-kura-border-strong": "var(--kura-border-strong)",
      "--color-kura-ink": "var(--kura-ink)",
      "--color-kura-ink-hover": "var(--kura-ink-hover)",
      "--color-kura-accent": "var(--kura-accent)",
      "--color-kura-accent-hover": "var(--kura-accent-hover)",
      "--color-kura-accent-fg": "var(--kura-accent-fg)",
      "--color-kura-accent-subtle": "var(--kura-accent-subtle)",
      "--color-kura-accent-text": "var(--kura-accent-text)",
      "--color-kura-danger": "var(--kura-danger)",
      "--color-kura-danger-subtle": "var(--kura-danger-subtle)",
      "--color-kura-danger-text": "var(--kura-danger-text)",
      "--color-kura-success": "var(--kura-success)",
      "--color-kura-success-text": "var(--kura-success-text)",
      "--color-kura-warning": "var(--kura-warning)",
      "--color-kura-warning-text": "var(--kura-warning-text)",
      "--color-kura-ring": "var(--kura-ring)",
      "--color-kura-gray-1": "var(--kura-gray-1)",
      "--color-kura-gray-2": "var(--kura-gray-2)",
      "--color-kura-gray-3": "var(--kura-gray-3)",
      "--color-kura-gray-4": "var(--kura-gray-4)",
      "--color-kura-gray-5": "var(--kura-gray-5)",
      "--color-kura-gray-6": "var(--kura-gray-6)",
      "--color-kura-gray-7": "var(--kura-gray-7)",
      "--color-kura-gray-8": "var(--kura-gray-8)",
      "--color-kura-gray-9": "var(--kura-gray-9)",
      "--color-kura-gray-10": "var(--kura-gray-10)",
      "--color-kura-gray-11": "var(--kura-gray-11)",
      "--color-kura-gray-12": "var(--kura-gray-12)",
      "--shadow-kura-xs": "var(--kura-shadow-xs)",
      "--shadow-kura-sm": "var(--kura-shadow-sm)",
      "--shadow-kura-md": "var(--kura-shadow-md)",
      "--shadow-kura-lg": "var(--kura-shadow-lg)",
      "--shadow-kura-focus": "var(--kura-shadow-focus)"
    },
    "light": {
      "--kura-gray-1": "oklch(0.993 0.002 85)",
      "--kura-gray-2": "oklch(0.982 0.003 85)",
      "--kura-gray-3": "oklch(0.962 0.004 85)",
      "--kura-gray-4": "oklch(0.942 0.005 85)",
      "--kura-gray-5": "oklch(0.92 0.005 85)",
      "--kura-gray-6": "oklch(0.895 0.006 85)",
      "--kura-gray-7": "oklch(0.86 0.006 85)",
      "--kura-gray-8": "oklch(0.8 0.007 85)",
      "--kura-gray-9": "oklch(0.64 0.008 85)",
      "--kura-gray-10": "oklch(0.58 0.008 85)",
      "--kura-gray-11": "oklch(0.47 0.008 85)",
      "--kura-gray-12": "oklch(0.205 0.006 85)",
      "--kura-canvas": "var(--kura-gray-1)",
      "--kura-surface": "oklch(1 0 0)",
      "--kura-surface-raised": "oklch(1 0 0)",
      "--kura-surface-sunken": "var(--kura-gray-2)",
      "--kura-overlay": "oklch(1 0 0 / 0.92)",
      "--kura-hover": "oklch(0.205 0.006 85 / 0.05)",
      "--kura-active": "oklch(0.205 0.006 85 / 0.09)",
      "--kura-fg": "var(--kura-gray-12)",
      "--kura-fg-muted": "var(--kura-gray-11)",
      "--kura-fg-subtle": "var(--kura-gray-10)",
      "--kura-fg-inverted": "var(--kura-gray-1)",
      "--kura-border": "oklch(0.205 0.006 85 / 0.12)",
      "--kura-border-subtle": "oklch(0.205 0.006 85 / 0.07)",
      "--kura-border-strong": "oklch(0.205 0.006 85 / 0.2)",
      "--kura-ink": "var(--kura-gray-12)",
      "--kura-ink-hover": "oklch(0.3 0.006 85)",
      "--kura-accent": "oklch(0.55 0.115 172)",
      "--kura-accent-hover": "oklch(0.5 0.11 172)",
      "--kura-accent-fg": "oklch(0.99 0.01 172)",
      "--kura-accent-subtle": "oklch(0.955 0.03 172)",
      "--kura-accent-text": "oklch(0.47 0.1 172)",
      "--kura-danger": "oklch(0.58 0.2 25)",
      "--kura-danger-subtle": "oklch(0.96 0.025 25)",
      "--kura-danger-text": "oklch(0.5 0.18 25)",
      "--kura-success": "oklch(0.62 0.15 150)",
      "--kura-success-text": "oklch(0.47 0.12 150)",
      "--kura-warning": "oklch(0.78 0.15 75)",
      "--kura-warning-text": "oklch(0.52 0.12 65)",
      "--kura-ring": "oklch(0.55 0.115 172 / 0.55)",
      "--kura-shadow-xs": "0 0 0 1px var(--kura-border-subtle), 0 1px 1px 0 oklch(0 0 0 / 0.03)",
      "--kura-shadow-sm": "0 0 0 1px var(--kura-border), 0 1px 2px -1px oklch(0 0 0 / 0.08), 0 2px 4px -2px oklch(0 0 0 / 0.04)",
      "--kura-shadow-md": "0 0 0 1px var(--kura-border), 0 2px 4px -2px oklch(0 0 0 / 0.06), 0 8px 16px -6px oklch(0 0 0 / 0.08)",
      "--kura-shadow-lg": "0 0 0 1px var(--kura-border), 0 4px 8px -4px oklch(0 0 0 / 0.06), 0 16px 32px -12px oklch(0 0 0 / 0.14), 0 32px 64px -24px oklch(0 0 0 / 0.1)",
      "--kura-shadow-focus": "0 0 0 1px var(--kura-canvas), 0 0 0 3px var(--kura-ring)",
      "--kura-duration-instant": "90ms",
      "--kura-duration-fast": "150ms",
      "--kura-duration-base": "220ms",
      "--kura-duration-slow": "320ms",
      "--kura-duration-slower": "480ms",
      "--kura-pop-scale": "0.96",
      "--kura-pop-y": "2px",
      "--kura-rise-y": "6px",
      "--kura-rise-blur": "2px",
      "color-scheme": "light"
    },
    "dark": {
      "--kura-gray-1": "oklch(0.155 0.004 85)",
      "--kura-gray-2": "oklch(0.185 0.004 85)",
      "--kura-gray-3": "oklch(0.215 0.005 85)",
      "--kura-gray-4": "oklch(0.24 0.005 85)",
      "--kura-gray-5": "oklch(0.265 0.006 85)",
      "--kura-gray-6": "oklch(0.295 0.006 85)",
      "--kura-gray-7": "oklch(0.345 0.007 85)",
      "--kura-gray-8": "oklch(0.42 0.008 85)",
      "--kura-gray-9": "oklch(0.56 0.008 85)",
      "--kura-gray-10": "oklch(0.64 0.008 85)",
      "--kura-gray-11": "oklch(0.76 0.007 85)",
      "--kura-gray-12": "oklch(0.955 0.004 85)",
      "--kura-canvas": "var(--kura-gray-1)",
      "--kura-surface": "var(--kura-gray-2)",
      "--kura-surface-raised": "var(--kura-gray-3)",
      "--kura-surface-sunken": "oklch(0.135 0.004 85)",
      "--kura-overlay": "oklch(0.21 0.005 85 / 0.92)",
      "--kura-hover": "oklch(0.955 0.004 85 / 0.06)",
      "--kura-active": "oklch(0.955 0.004 85 / 0.1)",
      "--kura-fg": "var(--kura-gray-12)",
      "--kura-fg-muted": "var(--kura-gray-11)",
      "--kura-fg-subtle": "var(--kura-gray-10)",
      "--kura-fg-inverted": "var(--kura-gray-1)",
      "--kura-border": "oklch(0.955 0.004 85 / 0.12)",
      "--kura-border-subtle": "oklch(0.955 0.004 85 / 0.07)",
      "--kura-border-strong": "oklch(0.955 0.004 85 / 0.2)",
      "--kura-ink": "var(--kura-gray-12)",
      "--kura-ink-hover": "oklch(0.88 0.004 85)",
      "--kura-accent": "oklch(0.78 0.13 170)",
      "--kura-accent-hover": "oklch(0.83 0.12 170)",
      "--kura-accent-fg": "oklch(0.2 0.04 172)",
      "--kura-accent-subtle": "oklch(0.27 0.045 172)",
      "--kura-accent-text": "oklch(0.82 0.12 170)",
      "--kura-danger": "oklch(0.66 0.19 25)",
      "--kura-danger-subtle": "oklch(0.27 0.06 25)",
      "--kura-danger-text": "oklch(0.74 0.16 25)",
      "--kura-success": "oklch(0.72 0.15 150)",
      "--kura-success-text": "oklch(0.78 0.14 150)",
      "--kura-warning": "oklch(0.82 0.14 80)",
      "--kura-warning-text": "oklch(0.84 0.13 80)",
      "--kura-ring": "oklch(0.78 0.13 170 / 0.6)",
      "--kura-shadow-xs": "0 0 0 1px var(--kura-border-subtle), inset 0 1px 0 0 oklch(1 0 0 / 0.03)",
      "--kura-shadow-sm": "0 0 0 1px var(--kura-border), inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.4)",
      "--kura-shadow-md": "0 0 0 1px var(--kura-border), inset 0 1px 0 0 oklch(1 0 0 / 0.05), 0 8px 16px -6px oklch(0 0 0 / 0.5)",
      "--kura-shadow-lg": "0 0 0 1px var(--kura-border), inset 0 1px 0 0 oklch(1 0 0 / 0.06), 0 16px 32px -12px oklch(0 0 0 / 0.6), 0 32px 64px -24px oklch(0 0 0 / 0.5)",
      "color-scheme": "dark"
    },
    "utilities": {
      "colors": [
        "bg-|text-|border-kura-canvas",
        "bg-|text-|border-kura-surface",
        "bg-|text-|border-kura-surface-raised",
        "bg-|text-|border-kura-surface-sunken",
        "bg-|text-|border-kura-overlay",
        "bg-|text-|border-kura-hover",
        "bg-|text-|border-kura-active",
        "bg-|text-|border-kura-fg",
        "bg-|text-|border-kura-fg-muted",
        "bg-|text-|border-kura-fg-subtle",
        "bg-|text-|border-kura-fg-inverted",
        "bg-|text-|border-kura-border",
        "bg-|text-|border-kura-border-subtle",
        "bg-|text-|border-kura-border-strong",
        "bg-|text-|border-kura-ink",
        "bg-|text-|border-kura-ink-hover",
        "bg-|text-|border-kura-accent",
        "bg-|text-|border-kura-accent-hover",
        "bg-|text-|border-kura-accent-fg",
        "bg-|text-|border-kura-accent-subtle",
        "bg-|text-|border-kura-accent-text",
        "bg-|text-|border-kura-danger",
        "bg-|text-|border-kura-danger-subtle",
        "bg-|text-|border-kura-danger-text",
        "bg-|text-|border-kura-success",
        "bg-|text-|border-kura-success-text",
        "bg-|text-|border-kura-warning",
        "bg-|text-|border-kura-warning-text",
        "bg-|text-|border-kura-ring",
        "bg-|text-|border-kura-gray-1",
        "bg-|text-|border-kura-gray-2",
        "bg-|text-|border-kura-gray-3",
        "bg-|text-|border-kura-gray-4",
        "bg-|text-|border-kura-gray-5",
        "bg-|text-|border-kura-gray-6",
        "bg-|text-|border-kura-gray-7",
        "bg-|text-|border-kura-gray-8",
        "bg-|text-|border-kura-gray-9",
        "bg-|text-|border-kura-gray-10",
        "bg-|text-|border-kura-gray-11",
        "bg-|text-|border-kura-gray-12"
      ],
      "radius": [
        "rounded-kura-xs",
        "rounded-kura-sm",
        "rounded-kura-md",
        "rounded-kura-lg",
        "rounded-kura-xl",
        "rounded-kura-2xl"
      ],
      "shadow": [
        "shadow-kura-xs",
        "shadow-kura-sm",
        "shadow-kura-md",
        "shadow-kura-lg",
        "shadow-kura-focus"
      ],
      "font": [
        "font-kura-sans",
        "font-kura-mono"
      ],
      "ease": [
        "ease-kura-out",
        "ease-kura-in-out",
        "ease-kura-in",
        "ease-kura-spring"
      ],
      "animate": [
        "animate-kura-pop-in",
        "animate-kura-pop-out",
        "animate-kura-fade-in",
        "animate-kura-fade-out",
        "animate-kura-rise-in",
        "animate-kura-spin",
        "animate-kura-shimmer",
        "animate-kura-pulse"
      ]
    },
    "motion": {
      "springs": {
        "snappy": {
          "type": "spring",
          "visualDuration": 0.2,
          "bounce": 0.1
        },
        "smooth": {
          "type": "spring",
          "visualDuration": 0.35,
          "bounce": 0
        },
        "gentle": {
          "type": "spring",
          "visualDuration": 0.5,
          "bounce": 0.05
        },
        "bouncy": {
          "type": "spring",
          "visualDuration": 0.4,
          "bounce": 0.25
        }
      },
      "durations": {
        "instant": 0.09,
        "fast": 0.15,
        "base": 0.22,
        "slow": 0.32,
        "slower": 0.48
      },
      "easings": {
        "out": [
          0.23,
          1,
          0.32,
          1
        ],
        "inOut": [
          0.77,
          0,
          0.175,
          1
        ],
        "in": [
          0.55,
          0,
          1,
          0.45
        ]
      }
    }
  }
}
