{
  "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": "agent",
      "title": "Agent",
      "description": "Runs, plans, fleets of subagents, traces, approvals, checkpoints and queues."
    },
    {
      "id": "workspace",
      "title": "Workspace",
      "description": "The terminal, diffs, files and browser an agent works in, made reviewable."
    },
    {
      "id": "conversation",
      "title": "Conversation",
      "description": "Messages, reasoning, streaming output, code, citations and artifacts."
    },
    {
      "id": "input",
      "title": "Input",
      "description": "The composer and the pickers and menus around it."
    },
    {
      "id": "voice",
      "title": "Voice",
      "description": "Dictation, audio visualisation and voice-agent state."
    },
    {
      "id": "feedback",
      "title": "Feedback",
      "description": "Thinking and working indicators."
    },
    {
      "id": "primitives",
      "title": "Primitives",
      "description": "Internal building blocks the other components are made from. Not listed in the docs."
    }
  ],
  "components": [
    {
      "slug": "agent-fleet",
      "name": "AgentFleet",
      "category": "agent",
      "status": "experimental",
      "description": "Panel of parallel subagents: status, name and role, live activity, thin progress, elapsed time, tokens and cost, with a fleet summary and selection.",
      "whenToUse": [
        "An orchestrator delegates work to several subagents at once (explorer, test-writer, reviewer) and the human needs to see who is doing what.",
        "A master/detail layout where choosing a subagent shows its transcript or tool calls next to the list.",
        "Spotting at a glance which subagent is waiting for approval or has failed ('3 running · 1 waiting · 2 done')."
      ],
      "whenNotToUse": [
        "Sequential steps of one agent — use task-list or agent-timeline.",
        "Timing and nesting of spans inside a run — use trace-waterfall.",
        "The run as a whole (title, cost, stop) — use run-header, and put AgentFleet under it."
      ],
      "props": [
        {
          "name": "agents",
          "type": "FleetAgent[]",
          "required": true,
          "description": "{ id, name, role?, status, activity?, progress? (0–1), startedAt?, endedAt?, tokens?, cost? }, in display order. activity is the current action while running, or the result once done/failed."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Subagents\"",
          "description": "Header title; the count follows it."
        },
        {
          "name": "selectedId",
          "type": "string | null",
          "description": "Selected agent id (controlled)."
        },
        {
          "name": "defaultSelectedId",
          "type": "string | null",
          "default": "null",
          "description": "Initially selected agent id when uncontrolled."
        },
        {
          "name": "onSelect",
          "type": "(id: string) => void",
          "description": "Called when a row is chosen by click, Enter or Space. When set (or selectedId is given) the list becomes a single-select listbox."
        },
        {
          "name": "density",
          "type": "\"compact\" | \"comfortable\"",
          "default": "\"comfortable\"",
          "description": "comfortable = two lines (name + role, then activity; tokens, cost and clock on the right, progress under them). compact = one 36px row (name, activity, progress, tokens, clock)."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Replaces the default empty state ('No subagents yet')."
        }
      ],
      "parts": [
        {
          "name": "AgentFleet",
          "description": "Root panel with `data-density`. A size container: cost, tokens and summary drop out when narrow.",
          "dataSlot": "agent-fleet"
        },
        {
          "name": "Header",
          "description": "Title, count and agent-fleet-summary ('3 running · 1 waiting · 2 done'; waiting in amber, failed in red).",
          "dataSlot": "agent-fleet-header"
        },
        {
          "name": "Agent",
          "description": "One row (<li>, role=option when selectable) with `data-status`, `data-selected`. Selected = active fill + 2px blue bar on the left.",
          "dataSlot": "agent-fleet-agent"
        },
        {
          "name": "Name",
          "description": "Agent name.",
          "dataSlot": "agent-fleet-name"
        },
        {
          "name": "Role",
          "description": "Role (comfortable only).",
          "dataSlot": "agent-fleet-role"
        },
        {
          "name": "Activity",
          "description": "One truncated line; shimmers while running, amber when waiting, red when failed.",
          "dataSlot": "agent-fleet-activity"
        },
        {
          "name": "Progress",
          "description": "3px bar while live: blue running, amber waiting, grey paused.",
          "dataSlot": "agent-fleet-progress"
        },
        {
          "name": "Tokens",
          "description": "Mono token total, e.g. 84.2k.",
          "dataSlot": "agent-fleet-tokens"
        },
        {
          "name": "Cost",
          "description": "Mono cost (comfortable, wide containers).",
          "dataSlot": "agent-fleet-cost"
        },
        {
          "name": "Elapsed",
          "description": "Mono clock; ticks while live, frozen at endedAt − startedAt.",
          "dataSlot": "agent-fleet-elapsed"
        },
        {
          "name": "Empty",
          "description": "Empty state.",
          "dataSlot": "agent-fleet-empty"
        }
      ],
      "a11y": {
        "role": "listbox (selectable) or list",
        "notes": [
          "The list is labelled by the title. Selectable rows are options with aria-selected and a roving tabindex (one Tab stop).",
          "Each row's status glyph is a labelled image ('Running', 'Waiting for you'), so status is not colour-only; progress is read as a percentage.",
          "A polite status region announces only transitions out of running ('client-patcher done', 'test-writer failed'), not ticking clocks or progress."
        ],
        "keyboard": [
          {
            "keys": "ArrowDown / ArrowUp (j / k)",
            "action": "Move focus between agents"
          },
          {
            "keys": "Home / End",
            "action": "First / last agent"
          },
          {
            "keys": "Enter / Space",
            "action": "Select the focused agent"
          }
        ],
        "reducedMotion": "New rows fade in without rising; running glyphs, activity shimmer and progress width transitions stop."
      },
      "dependencies": {
        "npm": [],
        "kura": [
          "shimmer-text",
          "status-glyph"
        ]
      },
      "related": [
        "run-header",
        "task-list",
        "trace-waterfall",
        "tool-call",
        "agent-timeline"
      ],
      "tags": [
        "agent",
        "subagents",
        "fleet",
        "parallel",
        "orchestration",
        "status",
        "listbox",
        "progress"
      ],
      "examples": [
        {
          "name": "AgentFleetMinimal",
          "title": "Three subagents",
          "description": "Done, running and queued; not selectable.",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AgentFleet } from \"@kura-ui/react/agent-fleet\";\n\nexport function AgentFleetMinimal() {\n  const [t] = React.useState(() => Date.now());\n  return (\n    <AgentFleet\n      className=\"w-full max-w-xl\"\n      agents={[\n        {\n          id: \"explorer\",\n          name: \"explorer\",\n          role: \"Maps call sites\",\n          status: \"done\",\n          activity: \"Found 23 calls to fetchUser() in 9 files\",\n          startedAt: t - 540_000,\n          endedAt: t - 446_000,\n          tokens: 84_200,\n        },\n        {\n          id: \"test-writer\",\n          name: \"test-writer\",\n          role: \"Updates tests\",\n          status: \"running\",\n          activity: \"Rewriting session mocks in login.test.ts\",\n          progress: 0.4,\n          startedAt: t - 128_000,\n          tokens: 31_900,\n        },\n        {\n          id: \"reviewer\",\n          name: \"reviewer\",\n          role: \"Reviews the diff\",\n          status: \"queued\",\n          activity: \"Starts when the patches land\",\n        },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "AgentFleetFull",
          "title": "Live fleet",
          "description": "Six subagents advance: an approval clears, patches land, tests fail, the reviewer starts. Controlled selection.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AgentFleet, type FleetAgent } from \"@kura-ui/react/agent-fleet\";\n\nconst BEATS: Beat[] = [\n  {},\n  {\n    client: { progress: 0.71, tokens: 61_400, cost: 0.17 },\n    tests: { progress: 0.34, tokens: 22_800 },\n  },\n  {\n    middleware: {\n      status: \"running\",\n      activity: \"Moving session refresh into the edge middleware\",\n      progress: 0.42,\n    },\n    tests: { progress: 0.48, tokens: 29_300 },\n  },\n  {\n    client: {\n      status: \"done\",\n      activity: \"Patched 9 call sites · +64 −41\",\n      progress: 1,\n      tokens: 72_900,\n      cost: 0.2,\n    },\n    middleware: { progress: 0.7, tokens: 38_100 },\n  },\n  {\n    tests: {\n      activity: \"Running pnpm test --filter web\",\n      progress: 0.81,\n      tokens: 40_600,\n    },\n    middleware: {\n      status: \"done\",\n      activity: \"Patched src/middleware.ts · +38 −12\",\n      progress: 1,\n      tokens: 44_500,\n      cost: 0.12,\n    },\n  },\n  {\n    tests: {\n      status: \"failed\",\n      activity: \"1 failed · login.test.ts › refreshes an expired session\",\n      tokens: 42_300,\n      cost: 0.11,\n    },\n  },\n  {\n    reviewer: {\n      status: \"running\",\n      activity: \"Reading the diff: 7 files, +142 −38\",\n      progress: 0.36,\n      tokens: 18_200,\n      cost: 0.05,\n    },\n  },\n];\n\nfunction initialFleet(): FleetAgent[] {\n  return [\n    {\n      id: \"explorer\",\n      name: \"explorer\",\n      role: \"Maps call sites\",\n      status: \"done\",\n      activity: \"Found 23 calls to fetchUser() in 9 files\",\n      startedAt: ago(612),\n      endedAt: ago(518),\n      tokens: 84_200,\n      cost: 0.21,\n    },\n    {\n      id: \"docs\",\n      name: \"docs-reader\",\n      role: \"Reads @acme/auth docs\",\n      status: \"done\",\n      activity: \"Summarised the session and refresh APIs\",\n      startedAt: ago(605),\n      endedAt: ago(561),\n      tokens: 26_700,\n      cost: 0.07,\n    },\n    {\n      id: \"client\",\n      name: \"client-patcher\",\n      role: \"Patches src/auth/client.ts\",\n      status: \"running\",\n      activity: \"Replacing fetchUser() with auth.getSession()\",\n      progress: 0.55,\n      startedAt: ago(214),\n      tokens: 53_100,\n      cost: 0.15,\n    },\n    {\n      id: \"middleware\",\n      name: \"middleware-patcher\",\n      role: \"Patches src/middleware.ts\",\n      status: \"waiting\",\n      activity: \"Needs approval to edit src/middleware.ts\",\n      progress: 0.3,\n      startedAt: ago(201),\n      tokens: 31_400,\n      cost: 0.09,\n    },\n    {\n      id: \"tests\",\n      name: \"test-writer\",\n      role: \"Updates tests\",\n      status: \"running\",\n      activity: \"Rewriting session mocks in login.test.ts\",\n      progress: 0.22,\n      startedAt: ago(96),\n      tokens: 17_600,\n      cost: 0.05,\n    },\n    {\n      id: \"reviewer\",\n      name: \"reviewer\",\n      role: \"Reviews the diff\",\n      status: \"queued\",\n      activity: \"Starts when the patches land\",\n    },\n  ];\n}\n\nconst ago = (s: number) => Date.now() - s * 1000;\n\nexport function AgentFleetFull() {\n  const [agents, setAgents] = React.useState<FleetAgent[]>(initialFleet);\n  const [beat, setBeat] = React.useState(0);\n  const [selectedId, setSelectedId] = React.useState<string | null>(\"client\");\n\n  React.useEffect(() => {\n    if (beat >= BEATS.length - 1) return;\n    const t = setTimeout(() => {\n      const next = beat + 1;\n      const patch = BEATS[next] ?? {};\n      const now = Date.now();\n      setAgents((list) =>\n        list.map((a) => {\n          const p = patch[a.id];\n          if (!p) return a;\n          const merged = { ...a, ...p };\n          if (p.status === \"running\" && a.startedAt == null) merged.startedAt = now;\n          if ((p.status === \"done\" || p.status === \"failed\") && a.endedAt == null)\n            merged.endedAt = now;\n          return merged;\n        }),\n      );\n      setBeat(next);\n    }, 1600);\n    return () => clearTimeout(t);\n  }, [beat]);\n\n  return (\n    <AgentFleet\n      className=\"w-full max-w-2xl\"\n      agents={agents}\n      selectedId={selectedId}\n      onSelect={setSelectedId}\n    />\n  );\n}\n"
        },
        {
          "name": "AgentFleetCompact",
          "title": "Compact",
          "description": "One 36px row per agent.",
          "kind": "variant",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AgentFleet, type FleetAgent } from \"@kura-ui/react/agent-fleet\";\n\nfunction initialFleet(): FleetAgent[] {\n  return [\n    {\n      id: \"explorer\",\n      name: \"explorer\",\n      role: \"Maps call sites\",\n      status: \"done\",\n      activity: \"Found 23 calls to fetchUser() in 9 files\",\n      startedAt: ago(612),\n      endedAt: ago(518),\n      tokens: 84_200,\n      cost: 0.21,\n    },\n    {\n      id: \"docs\",\n      name: \"docs-reader\",\n      role: \"Reads @acme/auth docs\",\n      status: \"done\",\n      activity: \"Summarised the session and refresh APIs\",\n      startedAt: ago(605),\n      endedAt: ago(561),\n      tokens: 26_700,\n      cost: 0.07,\n    },\n    {\n      id: \"client\",\n      name: \"client-patcher\",\n      role: \"Patches src/auth/client.ts\",\n      status: \"running\",\n      activity: \"Replacing fetchUser() with auth.getSession()\",\n      progress: 0.55,\n      startedAt: ago(214),\n      tokens: 53_100,\n      cost: 0.15,\n    },\n    {\n      id: \"middleware\",\n      name: \"middleware-patcher\",\n      role: \"Patches src/middleware.ts\",\n      status: \"waiting\",\n      activity: \"Needs approval to edit src/middleware.ts\",\n      progress: 0.3,\n      startedAt: ago(201),\n      tokens: 31_400,\n      cost: 0.09,\n    },\n    {\n      id: \"tests\",\n      name: \"test-writer\",\n      role: \"Updates tests\",\n      status: \"running\",\n      activity: \"Rewriting session mocks in login.test.ts\",\n      progress: 0.22,\n      startedAt: ago(96),\n      tokens: 17_600,\n      cost: 0.05,\n    },\n    {\n      id: \"reviewer\",\n      name: \"reviewer\",\n      role: \"Reviews the diff\",\n      status: \"queued\",\n      activity: \"Starts when the patches land\",\n    },\n  ];\n}\n\nconst ago = (s: number) => Date.now() - s * 1000;\n\nexport function AgentFleetCompact() {\n  const [agents] = React.useState(initialFleet);\n  return (\n    <AgentFleet\n      className=\"w-full max-w-2xl\"\n      density=\"compact\"\n      agents={agents}\n      defaultSelectedId=\"middleware\"\n      onSelect={() => {}}\n    />\n  );\n}\n"
        },
        {
          "name": "AgentFleetEmpty",
          "title": "Empty",
          "description": "Before any delegation.",
          "kind": "state",
          "code": "import { AgentFleet } from \"@kura-ui/react/agent-fleet\";\n\nexport function AgentFleetEmpty() {\n  return <AgentFleet className=\"w-full max-w-xl\" agents={[]} />;\n}\n"
        },
        {
          "name": "AgentFleetComposition",
          "title": "Master / detail with tool-call",
          "description": "Selecting a subagent shows its recent tool calls.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { FilePen, FileSearch, SquareTerminal } from \"lucide-react\";\nimport * as React from \"react\";\nimport { ToolCall } from \"@kura-ui/react/tool-call\";\nimport { AgentFleet, type FleetAgent } from \"@kura-ui/react/agent-fleet\";\n\nconst CALLS: Record<string, React.ReactNode> = {\n  explorer: (\n    <>\n      <ToolCall\n        name=\"grep\"\n        icon={<FileSearch />}\n        args={{ pattern: \"fetchUser\\\\(\", path: \"src\" }}\n        status=\"success\"\n        duration={420}\n        summary=\"fetchUser\\( in src/ · 23 matches\"\n        variant=\"inline\"\n      />\n      <ToolCall\n        name=\"read_file\"\n        icon={<FileSearch />}\n        args={{ path: \"src/routes/api/session.ts\" }}\n        status=\"success\"\n        duration={180}\n        variant=\"inline\"\n      />\n    </>\n  ),\n  \"test-writer\": (\n    <>\n      <ToolCall\n        name=\"edit_file\"\n        icon={<FilePen />}\n        args={{ path: \"src/auth/__tests__/login.test.ts\" }}\n        status=\"success\"\n        duration={2_400}\n        summary=\"login.test.ts · +18 −9\"\n        variant=\"inline\"\n      />\n      <ToolCall\n        name=\"bash\"\n        icon={<SquareTerminal />}\n        args={{ command: \"pnpm test --filter web\" }}\n        status=\"running\"\n        variant=\"inline\"\n      />\n    </>\n  ),\n  reviewer: <p className=\"px-1.5 text-[13px] text-kura-fg-subtle\">No tool calls yet.</p>,\n};\n\nexport function AgentFleetComposition() {\n  const [t] = React.useState(() => Date.now());\n  const [selectedId, setSelectedId] = React.useState(\"test-writer\");\n  const agents: FleetAgent[] = [\n    {\n      id: \"explorer\",\n      name: \"explorer\",\n      role: \"Maps call sites\",\n      status: \"done\",\n      activity: \"Found 23 calls to fetchUser() in 9 files\",\n      startedAt: t - 540_000,\n      endedAt: t - 446_000,\n      tokens: 84_200,\n      cost: 0.21,\n    },\n    {\n      id: \"test-writer\",\n      name: \"test-writer\",\n      role: \"Updates tests\",\n      status: \"running\",\n      activity: \"Running pnpm test --filter web\",\n      progress: 0.78,\n      startedAt: t - 151_000,\n      tokens: 40_600,\n      cost: 0.11,\n    },\n    {\n      id: \"reviewer\",\n      name: \"reviewer\",\n      role: \"Reviews the diff\",\n      status: \"queued\",\n      activity: \"Starts when tests pass\",\n    },\n  ];\n  const agent = agents.find((a) => a.id === selectedId);\n  return (\n    <div className=\"grid w-full max-w-3xl gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]\">\n      <AgentFleet\n        agents={agents}\n        density=\"compact\"\n        selectedId={selectedId}\n        onSelect={setSelectedId}\n      />\n      <section\n        aria-labelledby=\"fleet-detail-title\"\n        className=\"flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm\"\n      >\n        <div className=\"flex h-10 items-center gap-2 border-kura-border-subtle border-b px-3.5\">\n          <h4 id=\"fleet-detail-title\" className=\"font-medium text-[13px]\">\n            {agent?.name}\n          </h4>\n          <span className=\"truncate text-[12px] text-kura-fg-subtle\">{agent?.role}</span>\n        </div>\n        <div className=\"flex flex-col gap-0.5 p-2\">{CALLS[selectedId]}</div>\n      </section>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Give every agent a stable id; rows key on it and the announcer diffs statuses by id.",
        "Write activity in present tense while running ('Rewriting mocks in login.test.ts') and as a result when done ('Found 23 calls in 9 files'), or the failure when failed.",
        "Pass startedAt from the harness (not Date.now() at render) and endedAt when the agent stops so clocks stay correct across reloads.",
        "progress is optional; omit it for open-ended work rather than inventing a number."
      ],
      "title": "Agent Fleet",
      "importPath": "@kura-ui/react/agent-fleet",
      "exports": [
        "AgentFleet",
        "AgentFleetProps",
        "FleetAgent"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/agent-fleet",
      "markdownUrl": "https://kuro.abstergo.fyi/components/agent-fleet.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/agent-fleet.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/agent-fleet.json",
        "cli": "npx @kura-ui/cli add agent-fleet",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-format",
        "shimmer-text",
        "status-glyph",
        "use-controllable-state",
        "use-elapsed",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "agents": {
            "description": "{ id, name, role?, status, activity?, progress? (0–1), startedAt?, endedAt?, tokens?, cost? }, in display order. activity is the current action while running, or the result once done/failed.",
            "x-ts-type": "FleetAgent[]",
            "type": "array"
          },
          "title": {
            "description": "Header title; the count follows it.",
            "x-ts-type": "React.ReactNode",
            "default": "Subagents"
          },
          "selectedId": {
            "description": "Selected agent id (controlled).",
            "x-ts-type": "string | null"
          },
          "defaultSelectedId": {
            "description": "Initially selected agent id when uncontrolled.",
            "x-ts-type": "string | null",
            "default": null
          },
          "onSelect": {
            "description": "Called when a row is chosen by click, Enter or Space. When set (or selectedId is given) the list becomes a single-select listbox.",
            "x-ts-type": "(id: string) => void"
          },
          "density": {
            "description": "comfortable = two lines (name + role, then activity; tokens, cost and clock on the right, progress under them). compact = one 36px row (name, activity, progress, tokens, clock).",
            "x-ts-type": "\"compact\" | \"comfortable\"",
            "type": "string",
            "enum": [
              "comfortable",
              "compact"
            ],
            "default": "comfortable"
          },
          "empty": {
            "description": "Replaces the default empty state ('No subagents yet').",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "agents"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/agent-fleet/agent-fleet.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock, formatCost, formatTokens } from \"@/lib/kura/format\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\n\nexport interface FleetAgent {\n  /** Stable id, used for selection and React keys. */\n  id: string;\n  /** Short name, e.g. \"explorer\". */\n  name: string;\n  /** What this agent is for, e.g. \"Maps call sites\". */\n  role?: string;\n  status: AgentStatus;\n  /** What it is doing right now (running) or its last result (done / failed). One line. */\n  activity?: string;\n  /** 0–1. Shown as a thin bar while the agent is live. */\n  progress?: number;\n  startedAt?: Date | number;\n  /** Freezes the elapsed time at endedAt − startedAt. */\n  endedAt?: Date | number;\n  /** Total tokens used. */\n  tokens?: number;\n  /** Spend in USD. */\n  cost?: number;\n}\n\nexport interface AgentFleetProps\n  extends Omit<React.ComponentProps<\"div\">, \"onSelect\" | \"title\" | \"children\"> {\n  /** The subagents, in display order. */\n  agents: FleetAgent[];\n  /** Panel title. Default \"Subagents\". */\n  title?: React.ReactNode;\n  /** Selected agent id (controlled). */\n  selectedId?: string | null;\n  /** Initially selected agent id when uncontrolled. */\n  defaultSelectedId?: string | null;\n  /** Called with the agent id when a row is chosen (click, Enter or Space). Makes rows selectable. */\n  onSelect?: (id: string) => void;\n  /** `compact` = one 36px row per agent; `comfortable` = two lines with role and activity. Default \"comfortable\". */\n  density?: \"compact\" | \"comfortable\";\n  /** Shown when `agents` is empty. */\n  empty?: React.ReactNode;\n}\n\nconst LIVE: AgentStatus[] = [\"running\", \"waiting\", \"paused\"];\n\nconst SUMMARY_ORDER: { status: AgentStatus; word: string; tone?: string }[] = [\n  { status: \"running\", word: \"running\" },\n  { status: \"waiting\", word: \"waiting\", tone: \"text-kura-warning-text\" },\n  { status: \"paused\", word: \"paused\" },\n  { status: \"failed\", word: \"failed\", tone: \"text-kura-danger-text\" },\n  { status: \"done\", word: \"done\" },\n  { status: \"queued\", word: \"queued\" },\n  { status: \"skipped\", word: \"skipped\" },\n];\n\nconst SPOKEN: Record<AgentStatus, string> = {\n  queued: \"queued\",\n  running: \"running\",\n  waiting: \"waiting for you\",\n  paused: \"paused\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n};\n\nfunction useAnnouncements(agents: FleetAgent[]) {\n  const prev = React.useRef<Map<string, AgentStatus> | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n  React.useEffect(() => {\n    const next = new Map(agents.map((a) => [a.id, a.status]));\n    const before = prev.current;\n    prev.current = next;\n    if (!before) return;\n    const changes = agents\n      .filter((a) => before.has(a.id) && before.get(a.id) !== a.status && a.status !== \"running\")\n      .map((a) => `${a.name} ${SPOKEN[a.status]}`);\n    if (changes.length) setMessage(changes.join(\", \"));\n  }, [agents]);\n  return message;\n}\n\n/**\n * Parallel subagents at a glance: one row per agent with its status glyph, name and role, what it\n * is doing now (shimmering while it runs), a thin progress bar, elapsed time, tokens and cost.\n * The header sums the fleet (\"3 running · 1 waiting · 2 done\"). Rows are a selectable listbox\n * when `onSelect` is given.\n */\nexport function AgentFleet({\n  agents,\n  title = \"Subagents\",\n  selectedId: selectedProp,\n  defaultSelectedId = null,\n  onSelect,\n  density = \"comfortable\",\n  empty,\n  className,\n  ...props\n}: AgentFleetProps) {\n  const id = React.useId();\n  const [selected, setSelected] = useControllableState<string | null>({\n    value: selectedProp,\n    defaultValue: defaultSelectedId,\n    onChange: (v) => v != null && onSelect?.(v),\n  });\n  const selectable = onSelect != null || selectedProp !== undefined;\n  const [focusId, setFocusId] = React.useState<string | null>(null);\n  const rowRefs = React.useRef(new Map<string, HTMLLIElement>());\n  const message = useAnnouncements(agents);\n\n  const counts = new Map<AgentStatus, number>();\n  for (const a of agents) counts.set(a.status, (counts.get(a.status) ?? 0) + 1);\n  const summary = SUMMARY_ORDER.filter((s) => counts.get(s.status));\n\n  const tabStop =\n    agents.find((a) => a.id === focusId)?.id ??\n    agents.find((a) => a.id === selected)?.id ??\n    agents[0]?.id;\n\n  const moveFocus = (to: number) => {\n    const a = agents[Math.max(0, Math.min(agents.length - 1, to))];\n    if (!a) return;\n    setFocusId(a.id);\n    rowRefs.current.get(a.id)?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {\n    const index = agents.findIndex((a) => a.id === (e.target as HTMLElement).dataset.agentId);\n    if (index < 0) return;\n    if (e.key === \"ArrowDown\" || e.key === \"j\") moveFocus(index + 1);\n    else if (e.key === \"ArrowUp\" || e.key === \"k\") moveFocus(index - 1);\n    else if (e.key === \"Home\") moveFocus(0);\n    else if (e.key === \"End\") moveFocus(agents.length - 1);\n    else if (e.key === \"Enter\" || e.key === \" \") setSelected(agents[index]?.id ?? null);\n    else return;\n    e.preventDefault();\n  };\n\n  return (\n    <div\n      data-slot=\"agent-fleet\"\n      data-density={density}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"agent-fleet-header\"\n        className=\"flex h-10 shrink-0 items-center gap-3 border-kura-border-subtle border-b px-3.5\"\n      >\n        <h3 id={`${id}-title`} className=\"flex items-baseline gap-2 font-medium text-[13px]\">\n          {title}\n          <span className=\"font-kura-mono font-normal text-[12px] text-kura-fg-subtle tabular-nums\">\n            {agents.length}\n          </span>\n        </h3>\n        {summary.length > 0 && (\n          <p\n            data-slot=\"agent-fleet-summary\"\n            className=\"ml-auto hidden min-w-0 truncate text-[12px] text-kura-fg-subtle @xs:block\"\n          >\n            {summary.map((s, i) => (\n              <React.Fragment key={s.status}>\n                {i > 0 && <span aria-hidden> · </span>}\n                <span className={cn(\"whitespace-nowrap\", s.tone)}>\n                  <span className=\"font-kura-mono tabular-nums\">{counts.get(s.status)}</span>{\" \"}\n                  {s.word}\n                </span>\n              </React.Fragment>\n            ))}\n          </p>\n        )}\n      </div>\n\n      {agents.length === 0 ? (\n        <div\n          data-slot=\"agent-fleet-empty\"\n          className=\"flex min-h-24 flex-col items-center justify-center gap-1 px-6 py-6 text-center\"\n        >\n          {empty ?? (\n            <>\n              <p className=\"font-medium text-[13px] text-kura-fg-muted\">No subagents yet</p>\n              <p className=\"text-[12px] text-kura-fg-subtle\">\n                Delegated work shows up here as it starts.\n              </p>\n            </>\n          )}\n        </div>\n      ) : (\n        <ul\n          role={selectable ? \"listbox\" : \"list\"}\n          aria-labelledby={`${id}-title`}\n          onKeyDown={selectable ? onKeyDown : undefined}\n          className=\"flex flex-col divide-y divide-kura-border-subtle\"\n        >\n          {agents.map((agent) => (\n            <AgentRow\n              key={agent.id}\n              agent={agent}\n              density={density}\n              selectable={selectable}\n              selected={selected === agent.id}\n              tabbable={tabStop === agent.id}\n              rowRef={(el) => {\n                if (el) rowRefs.current.set(agent.id, el);\n                else rowRefs.current.delete(agent.id);\n              }}\n              onFocus={() => setFocusId(agent.id)}\n              onChoose={() => setSelected(agent.id)}\n            />\n          ))}\n        </ul>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nfunction AgentRow({\n  agent,\n  density,\n  selectable,\n  selected,\n  tabbable,\n  rowRef,\n  onFocus,\n  onChoose,\n}: {\n  agent: FleetAgent;\n  density: \"compact\" | \"comfortable\";\n  selectable: boolean;\n  selected: boolean;\n  tabbable: boolean;\n  rowRef: (el: HTMLLIElement | null) => void;\n  onFocus: () => void;\n  onChoose: () => void;\n}) {\n  const live = LIVE.includes(agent.status);\n  const ticking = useElapsed(agent.startedAt, live && agent.endedAt == null);\n  const elapsed =\n    agent.startedAt != null && agent.endedAt != null\n      ? Math.max(0, +new Date(agent.endedAt) - +new Date(agent.startedAt))\n      : agent.startedAt != null\n        ? ticking\n        : null;\n  const running = agent.status === \"running\";\n  const compact = density === \"compact\";\n  const showProgress = agent.progress != null && live;\n\n  const activity = agent.activity ? (\n    <span\n      data-slot=\"agent-fleet-activity\"\n      className={cn(\n        \"block min-w-0 truncate\",\n        agent.status === \"waiting\" && \"text-kura-warning-text\",\n        agent.status === \"failed\" && \"text-kura-danger-text\",\n        (agent.status === \"done\" ||\n          agent.status === \"queued\" ||\n          agent.status === \"skipped\" ||\n          agent.status === \"paused\") &&\n          \"text-kura-fg-subtle\",\n      )}\n    >\n      {running ? (\n        <ShimmerText className=\"block max-w-full truncate\">{agent.activity}</ShimmerText>\n      ) : (\n        agent.activity\n      )}\n    </span>\n  ) : null;\n\n  const progress = showProgress ? (\n    <span\n      data-slot=\"agent-fleet-progress\"\n      aria-hidden\n      className={cn(\n        \"flex h-[3px] shrink-0 overflow-hidden rounded-full bg-kura-hover\",\n        compact ? \"w-12\" : \"w-20\",\n      )}\n    >\n      <span\n        className={cn(\n          \"h-full rounded-full transition-[width] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\",\n          agent.status === \"running\" && \"bg-kura-accent\",\n          agent.status === \"waiting\" && \"bg-kura-warning\",\n          agent.status === \"paused\" && \"bg-kura-fg-subtle\",\n        )}\n        style={{ width: `${Math.min(1, Math.max(0, agent.progress ?? 0)) * 100}%` }}\n      />\n    </span>\n  ) : null;\n\n  const numbers = (\n    <span className=\"flex items-center gap-3 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n      {agent.tokens != null && (\n        <span\n          data-slot=\"agent-fleet-tokens\"\n          className={cn(\n            \"min-w-[5ch] text-right\",\n            compact ? \"hidden @md:inline-block\" : \"inline-block\",\n          )}\n        >\n          {formatTokens(agent.tokens)}\n        </span>\n      )}\n      {agent.cost != null && !compact && (\n        <span\n          data-slot=\"agent-fleet-cost\"\n          className=\"hidden min-w-[5ch] text-right @lg:inline-block\"\n        >\n          {formatCost(agent.cost)}\n        </span>\n      )}\n      <span\n        data-slot=\"agent-fleet-elapsed\"\n        className={cn(\"inline-block min-w-[4ch] text-right\", live && \"text-kura-fg-muted\")}\n      >\n        {elapsed != null ? formatClock(elapsed) : \"–\"}\n      </span>\n    </span>\n  );\n\n  const name = (\n    <span\n      data-slot=\"agent-fleet-name\"\n      className={cn(\n        \"shrink-0 font-medium text-[13px]\",\n        (agent.status === \"done\" || agent.status === \"skipped\") && \"text-kura-fg-muted\",\n      )}\n    >\n      {agent.name}\n    </span>\n  );\n\n  const progressText =\n    showProgress && agent.progress != null\n      ? `, ${Math.round(Math.min(1, Math.max(0, agent.progress)) * 100)}%`\n      : \"\";\n\n  return (\n    // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is \"option\" whenever aria-selected is set\n    // biome-ignore lint/a11y/useKeyWithClickEvents: Enter/Space are handled by the listbox's onKeyDown\n    <li\n      ref={rowRef}\n      role={selectable ? \"option\" : undefined}\n      aria-selected={selectable ? selected : undefined}\n      tabIndex={selectable ? (tabbable ? 0 : -1) : undefined}\n      data-slot=\"agent-fleet-agent\"\n      data-agent-id={agent.id}\n      data-status={agent.status}\n      data-selected={selected || undefined}\n      onClick={selectable ? onChoose : undefined}\n      onFocus={selectable ? onFocus : undefined}\n      className={cn(\n        \"relative min-w-0 px-3.5 outline-none\",\n        \"animate-[kura-rise-in_var(--kura-duration-base)_var(--ease-kura-out)_both] [--kura-rise-blur:0px] [--kura-rise-y:2px] motion-reduce:[--kura-rise-y:0px]\",\n        selectable &&\n          \"cursor-pointer transition-colors duration-(--kura-duration-instant) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n        selected &&\n          \"bg-kura-active before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-kura-accent hover:bg-kura-active\",\n        compact\n          ? \"grid h-9 grid-cols-[16px_auto_minmax(0,1fr)_auto] items-center gap-x-2.5\"\n          : \"grid grid-cols-[16px_minmax(0,1fr)_auto] gap-x-3 py-2.5\",\n      )}\n    >\n      <StatusGlyph status={agent.status} className={cn(!compact && \"mt-0.5\")} />\n      <span className=\"sr-only\">{progressText}</span>\n      {compact ? (\n        <>\n          {name}\n          <span className=\"min-w-0 text-[12.5px] text-kura-fg-muted\">{activity}</span>\n          <span className=\"flex items-center gap-3\">\n            {progress && <span className=\"hidden @sm:flex\">{progress}</span>}\n            {numbers}\n          </span>\n        </>\n      ) : (\n        <>\n          <span className=\"flex min-w-0 flex-col gap-0.5\">\n            <span className=\"flex min-w-0 items-baseline gap-2 leading-5\">\n              {name}\n              {agent.role && (\n                <span\n                  data-slot=\"agent-fleet-role\"\n                  className=\"hidden min-w-0 truncate text-[12px] text-kura-fg-subtle @md:block\"\n                >\n                  {agent.role}\n                </span>\n              )}\n            </span>\n            {activity && (\n              <span className=\"min-w-0 text-[12.5px] text-kura-fg-muted leading-[18px]\">\n                {activity}\n              </span>\n            )}\n          </span>\n          <span className=\"flex flex-col items-end justify-between gap-1.5 self-stretch py-0.5\">\n            {numbers}\n            {progress}\n          </span>\n        </>\n      )}\n    </li>\n  );\n}\n"
        },
        {
          "target": "components/kura/agent-fleet/index.ts",
          "type": "registry:ui",
          "content": "export { AgentFleet, type AgentFleetProps, type FleetAgent } from \"./agent-fleet\";\n"
        }
      ]
    },
    {
      "slug": "agent-orb",
      "name": "AgentOrb",
      "category": "voice",
      "status": "beta",
      "description": "Calm, softly glassy voice-agent sphere drawn in a shader: slow colour clouds that breathe when idle, swell with audio and turn 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.",
        "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 swells with the input level, thinking turns and twists the clouds, speaking lifts with the output level, error shifts to the danger colour and calms down."
        },
        {
          "name": "colors",
          "type": "\"signal\" | \"pearl\" | \"graphite\" | \"ocean\" | \"amber\" | readonly [string, string, string, string]",
          "default": "\"signal\"",
          "description": "A palette preset, or four CSS colours `[deep, mid, bright, glint]` = densest clouds, body, light between clouds, rim/sheen (OKLCH, hex, `var(--token)`, `color-mix()`…). `signal` follows the theme: pearl with blue clouds on light pages, graphite with blue clouds on dark. `pearl` and `graphite` are neutral, `ocean` is a deeper blue, `amber` a soft warm option."
        },
        {
          "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 (aria-hidden). The sphere fills ~84% of the box; the rest holds a soft contact shadow on light pages and a faint halo on dark ones.",
          "dataSlot": "agent-orb-canvas"
        },
        {
          "name": "Fallback",
          "description": "CSS gradient sphere in the same palette, used when WebGL2 is unavailable; its clouds turn slowly 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",
        "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[] = [\"signal\", \"pearl\", \"graphite\", \"ocean\", \"amber\"];\n\nexport function AgentOrbFull() {\n  const [state, setState] = React.useState<AgentOrbState>(\"speaking\");\n  const [preset, setPreset] = React.useState<AgentOrbPreset>(\"signal\");\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 ~84% so the shadow/halo has room — do not clip it with overflow-hidden.",
        "Keep the default `signal` palette when the orb stands for your AI: it is the only blue on a neutral page. Use `pearl` or `graphite` for a quieter, brand-neutral orb.",
        "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 swells with the input level, thinking turns and twists the clouds, speaking lifts with the output level, error shifts to the danger colour 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]` = densest clouds, body, light between clouds, rim/sheen (OKLCH, hex, `var(--token)`, `color-mix()`…). `signal` follows the theme: pearl with blue clouds on light pages, graphite with blue clouds on dark. `pearl` and `graphite` are neutral, `ocean` is a deeper blue, `amber` a soft warm option.",
            "x-ts-type": "\"signal\" | \"pearl\" | \"graphite\" | \"ocean\" | \"amber\" | readonly [string, string, string, string]",
            "default": "signal"
          },
          "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) holding slow, soft clouds of colour. Per pixel we\n *   1. find the (breathing, audio-swollen) silhouette and the sphere normal,\n *   2. sample two octaves of domain-warped 3D simplex noise *on the sphere surface*, turning\n *      slowly like a globe and twisting around the view axis while thinking — so the clouds wrap\n *      around the curvature instead of sliding across a disc,\n *   3. blend the palette in OKLab (pearl → mid → signal tone), no muddy midpoints,\n *   4. light it quietly: a little wrap diffuse, a pearly fresnel rim, one broad window sheen,\n *   5. outside the sphere: a soft contact shadow on light backgrounds, a faint halo on dark ones,\n *      written as premultiplied alpha so it sits on any surface.\n * One sRGB encode + IGN dither at the end.\n */\nexport const AGENT_ORB_FRAG = /* glsl */ `\nuniform vec3 u_deep;    // OKLab: the signal tone (densest clouds)\nuniform vec3 u_mid;     // OKLab: body\nuniform vec3 u_bright;  // OKLab: the light between clouds\nuniform vec3 u_glint;   // OKLab: rim and sheen\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\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: a slow breath when idle, a gentle swell with the voice.\n  float R = 0.84 * (1.0 + 0.012 * u_breath * sin(u_time * 1.3) + 0.04 * u_level);\n  float r = length(p);\n  vec2 dir = r > 0.0 ? p / r : vec2(0.0, 1.0);\n  float edge = R * (1.0 + snoise(vec3(dir * 1.1, t * 0.6)) * 0.012 * u_level);\n  float mask = smoothstep(edge + px, edge - px, r);\n\n  vec3 lab = u_mid;\n  if (mask > 0.0) {\n    vec2 q = p / edge;\n    float rq = min(length(q), 1.0);\n    float z = sqrt(max(0.0, 1.0 - rq * rq));\n    vec3 n = normalize(vec3(q, z));\n\n    // Clouds live on the surface: turn the sphere slowly, twist it while thinking.\n    vec3 P = n;\n    P.xy = rot(u_spin + u_swirl * 1.8 * (1.0 - rq)) * P.xy;\n    P.xz = rot(t * 0.16) * P.xz;\n    vec3 w = P * 0.72;\n    float n1 = snoise(w + vec3(0.0, t * 0.17, t * 0.1));\n    float n2 = snoise(w * 1.45 + n1 * (0.45 + 0.6 * u_energy) + vec3(t * 0.12, 3.1, -t * 0.08));\n    float clouds = smoothstep(-0.75, 0.85, n1 * 0.7 + n2 * 0.38);\n    float gaps = smoothstep(0.05, 0.95, snoise(w * 0.8 - vec3(2.7, t * 0.15, 1.3) + n2 * 0.25));\n\n    vec3 body = mix(u_bright, u_mid, clouds);\n    body = mix(body, u_deep, smoothstep(0.5, 1.0, clouds) * (0.62 + 0.3 * u_energy));\n    body = mix(body, u_bright, gaps * (0.22 + 0.3 * u_level));\n\n    // Volume: soft wrap diffuse from the top left, slightly deeper toward the limb.\n    vec3 L = normalize(vec3(-0.45, 0.6, 0.66));\n    float diff = dot(n, L) * 0.5 + 0.5;\n    body.x *= mix(0.86, 1.03, diff) * (1.0 - 0.08 * pow(1.0 - z, 1.5));\n    // The voice lifts the core a touch.\n    body.x += exp(-dot(q, q) * 2.4) * 0.05 * u_level;\n\n    // Glass: a pearly fresnel rim, one broad window sheen, a small soft highlight.\n    float fres = pow(1.0 - z, 3.0);\n    body = mix(body, u_glint, fres * mix(0.35, 0.55, u_light));\n    vec3 H = normalize(vec3(-0.5, 0.62, 0.6));\n    float nh = max(dot(n, H), 0.0);\n    body = mix(body, u_glint, smoothstep(0.84, 0.99, nh) * 0.2 + pow(nh, 80.0) * 0.3);\n\n    // A hairline just inside the edge keeps a pale orb crisp on a pale page.\n    body.x -= 0.045 * u_light * smoothstep(edge - px * 2.5, edge - px * 0.5, r);\n    lab = body;\n  }\n\n  // Outside: contact shadow on light backgrounds, a faint halo on dark ones.\n  float outside = max(r - edge, 0.0);\n  float sd = max(length(p - vec2(0.0, -0.04)) - edge, 0.0);\n  // Both fade to zero well inside the square box, so no edge ever shows.\n  float room = 1.0 - edge;\n  float shadowA = u_light * exp(-sd * 18.0) * 0.1 * smoothstep(room, room * 0.3, sd) * (1.0 - mask);\n  float haloA = (1.0 - u_light) * u_halo * exp(-outside * 14.0) * 0.22\n              * smoothstep(room, room * 0.2, outside) * (1.0 - mask);\n  vec3 orb = linearToSrgb(oklabToLinear(lab));\n  vec3 haloC = linearToSrgb(oklabToLinear(u_deep));\n  float a = mask + haloA + shadowA;\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 = \"signal\" | \"pearl\" | \"graphite\" | \"ocean\" | \"amber\";\n\n/**\n * Palettes as [deep, mid, bright, glint]: the densest clouds, the body, the light between the\n * clouds, and the rim/sheen. `signal` is built from Kura tokens and follows the theme — pearl with\n * blue clouds on light pages, graphite with blue clouds on dark ones. The rest are fixed.\n */\nexport const agentOrbPresets: Record<AgentOrbPreset, readonly [string, string, string, string]> = {\n  signal: [\n    \"var(--kura-accent)\",\n    \"color-mix(in oklab, var(--kura-accent) 38%, var(--kura-surface))\",\n    \"color-mix(in oklab, var(--kura-accent) 9%, var(--kura-surface))\",\n    \"oklch(0.99 0.004 255)\",\n  ],\n  pearl: [\n    \"oklch(0.7 0.008 255)\",\n    \"oklch(0.85 0.006 255)\",\n    \"oklch(0.975 0.003 255)\",\n    \"oklch(1 0 0)\",\n  ],\n  graphite: [\n    \"oklch(0.2 0.004 255)\",\n    \"oklch(0.33 0.008 255)\",\n    \"oklch(0.56 0.02 255)\",\n    \"oklch(0.9 0.01 255)\",\n  ],\n  ocean: [\n    \"oklch(0.4 0.16 264)\",\n    \"oklch(0.57 0.17 258)\",\n    \"oklch(0.82 0.08 245)\",\n    \"oklch(0.97 0.02 240)\",\n  ],\n  amber: [\"oklch(0.7 0.12 58)\", \"oklch(0.85 0.06 70)\", \"oklch(0.97 0.015 85)\", \"oklch(1 0 0)\"],\n};\n\n/** Error follows the theme like `signal`, with the danger token as the cloud colour. */\nconst ERROR_PALETTE = [\n  \"var(--kura-danger)\",\n  \"color-mix(in oklab, var(--kura-danger) 40%, var(--kura-surface))\",\n  \"color-mix(in oklab, var(--kura-danger) 6%, var(--kura-surface))\",\n  \"oklch(0.99 0.004 25)\",\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.15, swirl: 0.05, spinSpeed: 0.04, flow: 0.2, breath: 1, halo: 0.2, error: 0 },\n  listening: {\n    energy: 0.4,\n    swirl: 0.08,\n    spinSpeed: 0.08,\n    flow: 0.4,\n    breath: 0.4,\n    halo: 0.35,\n    error: 0,\n  },\n  thinking: {\n    energy: 0.5,\n    swirl: 0.6,\n    spinSpeed: 0.7,\n    flow: 0.5,\n    breath: 0.3,\n    halo: 0.35,\n    error: 0,\n  },\n  speaking: {\n    energy: 0.5,\n    swirl: 0.12,\n    spinSpeed: 0.14,\n    flow: 0.5,\n    breath: 0.2,\n    halo: 0.55,\n    error: 0,\n  },\n  error: { energy: 0.1, swirl: 0, spinSpeed: 0.02, flow: 0.12, breath: 0.6, halo: 0.25, 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 \"signal\". */\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 calm, softly glassy sphere of slow colour clouds, rendered in a fragment\n * shader. It breathes when idle, swells with the microphone while listening, turns while thinking,\n * lifts with the agent's voice while speaking and shifts to the danger colour on error — every\n * transition is eased on the GPU uniforms. Decorative canvas plus a polite text equivalent.\n */\nexport function AgentOrb({\n  state = \"idle\",\n  colors = \"signal\",\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      // Blend toward the error palette in OKLab; the shader works in OKLab too.\n      const c = resolved.current;\n      const mix = (i: number, out: Vec3) => {\n        const a = c[i]?.oklab ?? [0.6, 0, 0];\n        const b = c[i + 4]?.oklab ?? 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-[8%] overflow-hidden rounded-full shadow-kura-md transition-[scale] duration-(--kura-duration-slower) ease-kura-out motion-reduce:transition-none\",\n              state === \"speaking\" && \"scale-[1.03] motion-reduce:scale-100\",\n            )}\n            style={{ background: bright }}\n          >\n            <div\n              className={cn(\n                \"absolute inset-0 rounded-full blur-md\",\n                state === \"thinking\" &&\n                  \"animate-kura-spin [animation-duration:3.2s] motion-reduce:animate-none\",\n              )}\n              style={{\n                background: `radial-gradient(60% 55% at 70% 72%, ${deep}, transparent 70%), radial-gradient(55% 50% at 28% 62%, ${mid}, transparent 72%), radial-gradient(50% 45% at 60% 22%, ${mid}, transparent 70%)`,\n              }}\n            />\n            <div\n              className=\"absolute inset-0 rounded-full\"\n              style={{\n                background: `radial-gradient(42% 32% at 34% 24%, color-mix(in oklab, ${glint} 55%, transparent), transparent 70%), radial-gradient(circle at 50% 50%, transparent 66%, color-mix(in oklab, ${glint} 45%, 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), blue 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": [],
        "kura": [
          "shimmer-text",
          "status-glyph"
        ]
      },
      "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 rounded-kura-xl bg-kura-surface p-4 shadow-kura-sm\">\n      <div className=\"flex items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h3 className=\"font-medium text-kura-fg text-sm tracking-[-0.01em]\">\n            Migrate to the new auth client\n          </h3>\n          <p className=\"text-kura-fg-subtle text-xs tabular-nums\">\n            {done ? \"Finished\" : \"Running\"} · {Math.min(cursor, PLAN.length)} of {PLAN.length} steps\n          </p>\n        </div>\n        {done && (\n          <Button\n            variant=\"secondary\"\n            size=\"xs\"\n            className=\"animate-kura-fade-in\"\n            onClick={() => setRun((r) => r + 1)}\n          >\n            <RotateCcw /> Replay\n          </Button>\n        )}\n      </div>\n      <AgentTimeline steps={stepsAt(cursor, startedAt)} label=\"Migration plan\" size=\"md\" />\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 blue 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",
        "status-glyph",
        "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 * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\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\nconst GLYPH: Record<AgentStepStatus, AgentStatus> = {\n  queued: \"queued\",\n  active: \"running\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\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=\"relative z-10 grid shrink-0 place-items-center rounded-full bg-kura-surface\"\n    >\n      <StatusGlyph status={GLYPH[status]} size={small ? \"sm\" : \"md\"} label={null} />\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-[20px] bottom-[2px] left-[7.5px]\" : \"top-[25px] bottom-px 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-gray-8\",\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 (blue 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": "Blue “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, Always allow, custom labels and ⌘↵ / Esc; “Ask again” resets it.",
          "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  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        onAlwaysAllow={() => {}}\n        shortcuts\n        approveLabel=\"Run anyway\"\n        denyLabel=\"Deny\"\n        alwaysAllowLabel=\"Always allow\"\n      />\n      {decision !== \"pending\" && (\n        <Button\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"animate-kura-fade-in self-end\"\n          onClick={() => setDecision(\"pending\")}\n        >\n          <RotateCcw /> Ask again\n        </Button>\n      )}\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-current/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, Pencil } 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      <Button\n        size=\"xs\"\n        variant=\"ghost\"\n        className=\"self-end\"\n        disabled={busy}\n        onClick={() => {\n          setRun((r) => r + 1);\n          setVersion(run + 2);\n        }}\n      >\n        <Pencil />\n        Ask for an edit\n      </Button>\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-raised 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-sm 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-raised 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-raised 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-raised 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=\"13.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 blue accent (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          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 blue accent (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        const lw = 1.5;\n        ctx.globalAlpha = 1;\n        ctx.lineWidth = lw;\n        ctx.lineJoin = \"round\";\n        ctx.lineCap = \"round\";\n        ctx.beginPath();\n        const data = f?.waveform;\n        const points = Math.min(160, Math.max(24, Math.floor(w / 4)));\n        // Keep the stroke (including its width) inside the box.\n        const top = lw / 2 + 0.5;\n        const bottom = h - lw / 2 - 0.5;\n        const half = Math.max(0, (bottom - top) / 2);\n        let px = 0;\n        let py = h / 2;\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 = Math.min(bottom, Math.max(top, h / 2 + v * half * taper * 1.6));\n          if (i === 0) ctx.moveTo(x, y);\n          else ctx.quadraticCurveTo(px, py, (px + x) / 2, (py + y) / 2);\n          px = x;\n          py = y;\n        }\n        ctx.lineTo(px, py);\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      // Older samples fade out toward the left edge.\n      const fade = Math.min(w * 0.22, 72);\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.08 + 0.92 * edge * 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": "browser-session",
      "name": "BrowserSession",
      "category": "workspace",
      "status": "experimental",
      "description": "A computer/browser-use session made legible: tidy browser frame, the page, the agent's cursor with click ripple and target box, and a step log with take-over.",
      "whenToUse": [
        "Show what a browser-use or computer-use agent is doing on a web page (Operator, Claude computer use, Browserbase, Playwright-driven agents).",
        "Stream screenshots of a remote browser (`src`) with the agent's next action drawn on top (`cursor`, `highlight`).",
        "Replay a recorded browser run step by step with the log of navigate / click / type / scroll / extract actions.",
        "Hand control to the human when the agent hits a login, 2FA or CAPTCHA (`status=\"paused\"` + `onResume`), or let them take over (`onTakeOver`)."
      ],
      "whenNotToUse": [
        "Terminal or shell output from the agent — use terminal.",
        "Code edits made by the agent — use diff-view or file-changes.",
        "A plan or todo list of the whole run — use task-list.",
        "A static rendered artifact (HTML preview, document) — use artifact-card."
      ],
      "props": [
        {
          "name": "url",
          "type": "string",
          "required": true,
          "description": "Page URL in the address bar. The host is emphasised, the path muted; https shows a lock."
        },
        {
          "name": "title",
          "type": "string",
          "description": "Page title next to the address bar (hidden in narrow containers)."
        },
        {
          "name": "children",
          "type": "React.ReactNode",
          "description": "Viewport content: a live page, or a mock rendered as markup."
        },
        {
          "name": "src",
          "type": "string",
          "description": "A screenshot URL to show instead of children (e.g. the latest frame from a remote browser)."
        },
        {
          "name": "alt",
          "type": "string",
          "description": "Alt text for `src`. Default “Screenshot of {url}”."
        },
        {
          "name": "viewport",
          "type": "{ width: number; height: number }",
          "description": "Design size of the page in CSS px (e.g. the agent's 1280×800 screen). Children render at this size and scale to fit the frame, so % coordinates stay exact at any width. Omit to let children flow at the frame's width."
        },
        {
          "name": "aspectRatio",
          "type": "number",
          "default": "16 / 10",
          "description": "Viewport aspect ratio when `viewport` is not given."
        },
        {
          "name": "scrollTop",
          "type": "number",
          "default": "0",
          "description": "Vertical page scroll in viewport px; eases on change (use for scroll steps)."
        },
        {
          "name": "cursor",
          "type": "BrowserPoint | null",
          "description": "The agent's cursor { x, y } in % of the viewport. Glides to new positions."
        },
        {
          "name": "cursorLabel",
          "type": "string",
          "description": "Name tag on the cursor, e.g. the agent's name."
        },
        {
          "name": "clickKey",
          "type": "string | number",
          "description": "Change it to play a click ripple at the cursor. Default: the active step's id when that step is a click."
        },
        {
          "name": "highlight",
          "type": "BrowserHighlight | null",
          "description": "Box around the target element: { x, y, width, height } in % plus an optional `label` tag (e.g. `button \"Create pull request\"`)."
        },
        {
          "name": "steps",
          "type": "readonly BrowserStep[]",
          "description": "The action log. BrowserStep = { id, kind: \"navigate\" | \"click\" | \"type\" | \"scroll\" | \"extract\", label, status: AgentStatus, detail? (mono: URL, selector, typed text) }. Omit to hide the log."
        },
        {
          "name": "activeStep",
          "type": "string",
          "description": "Id of the step being executed. Default: the first running step."
        },
        {
          "name": "status",
          "type": "\"browsing\" | \"paused\" | \"done\" | \"failed\"",
          "default": "\"browsing\"",
          "description": "browsing = “Agent is browsing” (blue, shimmer); paused = “Paused for you” (amber, the viewport gets an amber edge); done; failed."
        },
        {
          "name": "statusText",
          "type": "React.ReactNode",
          "description": "Secondary text after the status, e.g. “Enter the 2FA code, then hand back”."
        },
        {
          "name": "startedAt",
          "type": "Date | number",
          "description": "Session start (wall clock) for the elapsed clock; ticks while browsing."
        },
        {
          "name": "loading",
          "type": "boolean",
          "description": "Show the loading bar under the address bar. Default: true while the active step is a running navigate."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra controls in the status line (e.g. open in new tab, replay)."
        },
        {
          "name": "onTakeOver",
          "type": "() => void",
          "description": "Shows a “Take over” button while browsing."
        },
        {
          "name": "onResume",
          "type": "() => void",
          "description": "Shows a “Resume agent” button while paused."
        },
        {
          "name": "layout",
          "type": "\"auto\" | \"side\" | \"stacked\"",
          "default": "\"auto\"",
          "description": "Step log beside the browser (side), below it (stacked), or beside only when the container is ≥ 48rem (auto)."
        }
      ],
      "parts": [
        {
          "name": "BrowserSession",
          "description": "Root panel; `data-status`, `data-layout`; container-query context; polite status region.",
          "dataSlot": "browser-session"
        },
        {
          "name": "Header",
          "description": "Status line: glyph, “Agent is browsing” / “Paused for you”, statusText, done/total, elapsed, actions, Take over / Resume.",
          "dataSlot": "browser-session-header"
        },
        {
          "name": "Elapsed",
          "description": "Mono clock from startedAt.",
          "dataSlot": "browser-session-elapsed"
        },
        {
          "name": "Browser",
          "description": "Column with the chrome and the viewport.",
          "dataSlot": "browser-session-browser"
        },
        {
          "name": "Chrome",
          "description": "Title (favicon letter + text) and the address bar.",
          "dataSlot": "browser-session-chrome"
        },
        {
          "name": "Address",
          "description": "role=\"group\" “Address”: lock, host, muted path, in mono.",
          "dataSlot": "browser-session-address"
        },
        {
          "name": "Loading",
          "description": "2px accent sweep under the chrome while a page loads.",
          "dataSlot": "browser-session-loading"
        },
        {
          "name": "Viewport",
          "description": "Fixed-aspect frame (no layout shift); amber inner edge while paused.",
          "dataSlot": "browser-session-viewport"
        },
        {
          "name": "Page",
          "description": "Holds children/src; scaled to fit when `viewport` is set; translated by scrollTop.",
          "dataSlot": "browser-session-page"
        },
        {
          "name": "Overlay",
          "description": "aria-hidden layer for the highlight, ripple and cursor.",
          "dataSlot": "browser-session-overlay"
        },
        {
          "name": "Highlight",
          "description": "Accent box + mono tag around the target; eases between targets.",
          "dataSlot": "browser-session-highlight"
        },
        {
          "name": "Cursor",
          "description": "Pointer arrow with an optional accent name tag; glides between points.",
          "dataSlot": "browser-session-cursor"
        },
        {
          "name": "Ripple",
          "description": "Accent ring that expands and fades after the cursor arrives.",
          "dataSlot": "browser-session-ripple"
        },
        {
          "name": "Log",
          "description": "Scrollable step log; keeps the active step in view.",
          "dataSlot": "browser-session-log"
        },
        {
          "name": "Step",
          "description": "<li> with `data-kind`, `data-status`, `data-active`; aria-current=\"step\" on the active one.",
          "dataSlot": "browser-session-step"
        }
      ],
      "a11y": {
        "notes": [
          "The overlay (cursor, ripple, highlight) is decorative and aria-hidden; what the agent is doing is carried by the step log and the status line.",
          "The step log is an <ol> labelled “Browser actions”; each step speaks its status after the label, and the active one has aria-current=\"step\".",
          "A polite status region announces the active step's label (when it is a string), “Paused for you” / your statusText, “Session ended” or “Session failed” — never the ticking clock.",
          "The address bar is a labelled group; the lock icon is labelled “Secure connection”. With `src`, the image gets alt text (default “Screenshot of {url}”).",
          "Take over / Resume agent are real buttons with visible focus. Paused state is signalled by text and glyph, not only the amber edge."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Moves to the status-line actions (Take over / Resume agent) and any controls inside the page."
          }
        ],
        "reducedMotion": "The cursor and highlight jump instead of gliding (the highlight still fades), the click ripple is skipped, page scroll is instant, the loading sweep becomes a static bar and the shimmer stops."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button",
          "shimmer-text",
          "status-glyph"
        ]
      },
      "related": [
        "terminal",
        "task-list",
        "run-header",
        "approval-prompt",
        "tool-call"
      ],
      "tags": [
        "agent",
        "browser",
        "computer-use",
        "browser-use",
        "operator",
        "cursor",
        "screenshot",
        "takeover"
      ],
      "examples": [
        {
          "name": "BrowserSessionMinimal",
          "title": "A page with the agent's cursor",
          "description": "A mock PR page at a fixed 800×500 design size, the cursor and a target box.",
          "kind": "minimal",
          "code": "import { Check, GitPullRequest } from \"lucide-react\";\nimport { BrowserSession } from \"@kura-ui/react/browser-session\";\n\nconst W = 800;\n\nconst H = 500;\n\nconst at = (x: number, y: number) => ({ x: (x / W) * 100, y: (y / H) * 100 });\n\nconst box = (x: number, y: number, w: number, h: number, label?: string) => ({\n  x: (x / W) * 100,\n  y: (y / H) * 100,\n  width: (w / W) * 100,\n  height: (h / H) * 100,\n  label,\n});\n\nfunction PullRequestPage() {\n  return (\n    <div className=\"h-[500px] bg-kura-surface font-kura-sans text-kura-fg\">\n      <SiteBar />\n      <div className=\"px-8 pt-6\">\n        <h1 data-target=\"pr-h1\" className=\"font-semibold text-[20px] leading-7 tracking-[-0.015em]\">\n          {PR_TITLE} <span className=\"font-normal text-kura-fg-subtle\">#482</span>\n        </h1>\n        <div className=\"mt-2 flex items-center gap-2 text-[13px] text-kura-fg-muted\">\n          <span className=\"flex items-center gap-1 rounded-full bg-kura-success/15 px-2 py-0.5 font-medium text-[12px] text-kura-success-text\">\n            <GitPullRequest className=\"size-3\" strokeWidth={2} /> Open\n          </span>\n          <span>\n            <span className=\"font-medium text-kura-fg\">kura</span> wants to merge 4 commits into{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">main</span> from{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">kura/auth-client</span>\n          </span>\n        </div>\n        <div className=\"mt-4 flex gap-5 border-kura-border-subtle border-b text-[13px] text-kura-fg-muted\">\n          <span className=\"-mb-px border-kura-fg border-b-2 pb-2 font-medium text-kura-fg\">\n            Conversation\n          </span>\n          <span>Commits 4</span>\n          <span>Checks 3</span>\n          <span>Files changed 6</span>\n        </div>\n        <div className=\"mt-4 rounded-[10px] p-4 text-[13px] text-kura-fg-muted leading-5 shadow-kura-xs\">\n          {PR_BODY}\n        </div>\n        <div className=\"mt-3 flex flex-col rounded-[10px] text-[13px] shadow-kura-xs\">\n          <div className=\"flex h-10 items-center gap-2 border-kura-border-subtle border-b px-4 font-medium\">\n            <Check className=\"size-4 text-kura-success\" strokeWidth={2.25} /> All checks have passed\n          </div>\n          {[\n            [\"test\", \"142 passed · 41s\"],\n            [\"typecheck\", \"0 errors · 18s\"],\n            [\"lint\", \"clean · 6s\"],\n          ].map(([name, meta]) => (\n            <div key={name} className=\"flex h-9 items-center gap-2 px-4\">\n              <Check className=\"size-3.5 text-kura-success\" strokeWidth={2.25} />\n              <span className=\"font-kura-mono text-[12.5px]\">{name}</span>\n              <span className=\"ml-auto font-kura-mono text-[12px] text-kura-fg-subtle\">{meta}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst PR_TITLE = \"Migrate session API to @acme/auth client\";\n\nconst PR_BODY =\n  \"Replaces fetchUser() and the legacy /api/session REST calls with createAuthClient(). Refreshes expired sessions with the rotated token.\";\n\nfunction SiteBar() {\n  return (\n    <div className=\"flex h-12 items-center gap-3 border-kura-border-subtle border-b px-8 text-[14px]\">\n      <span className=\"size-5 rounded-[5px] bg-kura-fg\" />\n      <span className=\"text-kura-fg-muted\">acme</span>\n      <span className=\"text-kura-fg-subtle\">/</span>\n      <span className=\"font-semibold\">web</span>\n      <span className=\"ml-auto flex items-center gap-5 text-[13px] text-kura-fg-muted\">\n        <span>Code</span>\n        <span className=\"font-medium text-kura-fg\">Pull requests</span>\n        <span>Actions</span>\n        <span className=\"size-6 rounded-full bg-kura-gray-5\" />\n      </span>\n    </div>\n  );\n}\n\nexport function BrowserSessionMinimal() {\n  return (\n    <BrowserSession\n      className=\"w-full max-w-xl\"\n      url=\"https://github.com/acme/web/pull/482\"\n      title=\"Migrate session API · acme/web\"\n      viewport={{ width: W, height: H }}\n      cursor={at(468, 94)}\n      highlight={box(24, 66, 478, 40, \"h1\")}\n    >\n      <PullRequestPage />\n    </BrowserSession>\n  );\n}\n"
        },
        {
          "name": "BrowserSessionFull",
          "title": "Opening the pull request",
          "description": "Kura opens the compare view, types the title and description, clicks “Create pull request” and reads the PR number. The cursor glides, clicks ripple, the log advances and the session settles.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Check, GitBranch, GitPullRequest, RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { BrowserSession, type BrowserStep } from \"@kura-ui/react/browser-session\";\n\nconst W = 800;\n\nconst H = 500;\n\nconst PR_TITLE = \"Migrate session API to @acme/auth client\";\n\nconst PR_BODY =\n  \"Replaces fetchUser() and the legacy /api/session REST calls with createAuthClient(). Refreshes expired sessions with the rotated token.\";\n\nfunction ComparePage({\n  title,\n  body,\n  focus,\n}: {\n  title: string;\n  body: string;\n  focus: \"title\" | \"body\" | null;\n}) {\n  return (\n    <div className=\"h-[500px] bg-kura-surface font-kura-sans text-kura-fg\">\n      <SiteBar />\n      <div className=\"px-8 pt-6\">\n        <h1 className=\"font-semibold text-[20px] leading-7 tracking-[-0.015em]\">\n          Open a pull request\n        </h1>\n        <p className=\"mt-1 text-[13px] text-kura-fg-muted leading-5\">\n          Create a new pull request by comparing changes across two branches.\n        </p>\n        <div className=\"mt-4 flex h-10 items-center gap-2 rounded-[8px] bg-kura-surface-sunken px-3 text-[12.5px]\">\n          <GitBranch className=\"size-3.5 text-kura-fg-subtle\" strokeWidth={1.75} />\n          <span className=\"rounded-[5px] bg-kura-surface px-1.5 py-0.5 font-kura-mono shadow-kura-xs\">\n            base: main\n          </span>\n          <span className=\"text-kura-fg-subtle\">←</span>\n          <span className=\"rounded-[5px] bg-kura-surface px-1.5 py-0.5 font-kura-mono shadow-kura-xs\">\n            compare: kura/auth-client\n          </span>\n          <span className=\"ml-auto flex items-center gap-1 font-medium text-kura-success-text\">\n            <Check className=\"size-3.5\" strokeWidth={2.25} /> Able to merge\n          </span>\n        </div>\n        <div className=\"mt-4 flex flex-col gap-1.5 rounded-[10px] p-4 shadow-kura-xs\">\n          <span className=\"font-medium text-[12.5px] leading-[18px]\">Title</span>\n          <Field value={title} focused={focus === \"title\"} />\n          <span className=\"mt-1.5 font-medium text-[12.5px] leading-[18px]\">Description</span>\n          <Field value={body} focused={focus === \"body\"} tall />\n          <div className=\"mt-1.5 flex h-8 items-center justify-between\">\n            <span className=\"text-[12px] text-kura-fg-subtle\">Markdown is supported</span>\n            <span\n              data-target=\"submit\"\n              className=\"flex h-8 w-[160px] items-center justify-center rounded-[7px] bg-kura-ink font-medium text-[13px] text-kura-fg-inverted\"\n            >\n              Create pull request\n            </span>\n          </div>\n        </div>\n        <p className=\"mt-3 font-kura-mono text-[12px] text-kura-fg-subtle\">\n          4 commits · 6 files changed · +142 −38\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction PullRequestPage() {\n  return (\n    <div className=\"h-[500px] bg-kura-surface font-kura-sans text-kura-fg\">\n      <SiteBar />\n      <div className=\"px-8 pt-6\">\n        <h1 data-target=\"pr-h1\" className=\"font-semibold text-[20px] leading-7 tracking-[-0.015em]\">\n          {PR_TITLE} <span className=\"font-normal text-kura-fg-subtle\">#482</span>\n        </h1>\n        <div className=\"mt-2 flex items-center gap-2 text-[13px] text-kura-fg-muted\">\n          <span className=\"flex items-center gap-1 rounded-full bg-kura-success/15 px-2 py-0.5 font-medium text-[12px] text-kura-success-text\">\n            <GitPullRequest className=\"size-3\" strokeWidth={2} /> Open\n          </span>\n          <span>\n            <span className=\"font-medium text-kura-fg\">kura</span> wants to merge 4 commits into{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">main</span> from{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">kura/auth-client</span>\n          </span>\n        </div>\n        <div className=\"mt-4 flex gap-5 border-kura-border-subtle border-b text-[13px] text-kura-fg-muted\">\n          <span className=\"-mb-px border-kura-fg border-b-2 pb-2 font-medium text-kura-fg\">\n            Conversation\n          </span>\n          <span>Commits 4</span>\n          <span>Checks 3</span>\n          <span>Files changed 6</span>\n        </div>\n        <div className=\"mt-4 rounded-[10px] p-4 text-[13px] text-kura-fg-muted leading-5 shadow-kura-xs\">\n          {PR_BODY}\n        </div>\n        <div className=\"mt-3 flex flex-col rounded-[10px] text-[13px] shadow-kura-xs\">\n          <div className=\"flex h-10 items-center gap-2 border-kura-border-subtle border-b px-4 font-medium\">\n            <Check className=\"size-4 text-kura-success\" strokeWidth={2.25} /> All checks have passed\n          </div>\n          {[\n            [\"test\", \"142 passed · 41s\"],\n            [\"typecheck\", \"0 errors · 18s\"],\n            [\"lint\", \"clean · 6s\"],\n          ].map(([name, meta]) => (\n            <div key={name} className=\"flex h-9 items-center gap-2 px-4\">\n              <Check className=\"size-3.5 text-kura-success\" strokeWidth={2.25} />\n              <span className=\"font-kura-mono text-[12.5px]\">{name}</span>\n              <span className=\"ml-auto font-kura-mono text-[12px] text-kura-fg-subtle\">{meta}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst COMPARE_URL = \"https://github.com/acme/web/compare/main...kura/auth-client\";\n\nconst PR_URL = \"https://github.com/acme/web/pull/482\";\n\nconst STEPS: Omit<BrowserStep, \"status\">[] = [\n  { id: \"open\", kind: \"navigate\", label: \"Open the compare view\", detail: COMPARE_URL.slice(8) },\n  { id: \"focus\", kind: \"click\", label: \"Focus the title field\", detail: \"input#pr-title\" },\n  { id: \"title\", kind: \"type\", label: \"Type the PR title\", detail: `“${PR_TITLE}”` },\n  {\n    id: \"body\",\n    kind: \"type\",\n    label: \"Write the description\",\n    detail: `“${PR_BODY.slice(0, 48)}…”`,\n  },\n  {\n    id: \"submit\",\n    kind: \"click\",\n    label: \"Click “Create pull request”\",\n    detail: \"button[type=submit]\",\n  },\n  { id: \"read\", kind: \"extract\", label: \"Read the PR number\", detail: \"#482 · Open\" },\n];\n\nconst TARGETS = {\n  open: { cursor: at(400, 300), highlight: null },\n  focus: { cursor: at(140, 256), highlight: box(48, 236, 704, 36, \"input#pr-title\") },\n  title: { cursor: at(140, 256), highlight: box(48, 236, 704, 36, \"input#pr-title\") },\n  body: { cursor: at(160, 330), highlight: box(48, 308, 704, 96, \"textarea#pr-body\") },\n  submit: {\n    cursor: at(690, 434),\n    highlight: box(592, 416, 160, 32, 'button \"Create pull request\"'),\n  },\n  read: { cursor: at(468, 94), highlight: box(24, 66, 478, 40, \"h1 · #482\") },\n} as const;\n\nconst at = (x: number, y: number) => ({ x: (x / W) * 100, y: (y / H) * 100 });\n\nconst box = (x: number, y: number, w: number, h: number, label?: string) => ({\n  x: (x / W) * 100,\n  y: (y / H) * 100,\n  width: (w / W) * 100,\n  height: (h / H) * 100,\n  label,\n});\n\nfunction SiteBar() {\n  return (\n    <div className=\"flex h-12 items-center gap-3 border-kura-border-subtle border-b px-8 text-[14px]\">\n      <span className=\"size-5 rounded-[5px] bg-kura-fg\" />\n      <span className=\"text-kura-fg-muted\">acme</span>\n      <span className=\"text-kura-fg-subtle\">/</span>\n      <span className=\"font-semibold\">web</span>\n      <span className=\"ml-auto flex items-center gap-5 text-[13px] text-kura-fg-muted\">\n        <span>Code</span>\n        <span className=\"font-medium text-kura-fg\">Pull requests</span>\n        <span>Actions</span>\n        <span className=\"size-6 rounded-full bg-kura-gray-5\" />\n      </span>\n    </div>\n  );\n}\n\nfunction Field({ value, focused, tall }: { value: string; focused: boolean; tall?: boolean }) {\n  return (\n    <div\n      data-target={tall ? \"body\" : \"title\"}\n      className={\n        tall\n          ? \"h-24 rounded-[7px] bg-kura-surface px-3 py-2 text-[13px] leading-5\"\n          : \"flex h-9 items-center rounded-[7px] bg-kura-surface px-3 text-[13.5px]\"\n      }\n      style={{\n        boxShadow: focused\n          ? \"0 0 0 1px var(--kura-accent), 0 0 0 3px var(--kura-ring)\"\n          : \"0 0 0 1px var(--kura-border-strong)\",\n      }}\n    >\n      <span>{value}</span>\n      {focused && (\n        <span className=\"ml-px inline-block h-4 w-px translate-y-[3px] animate-kura-pulse bg-kura-fg\" />\n      )}\n      {!value && !focused && (\n        <span className=\"text-kura-fg-subtle\">{tall ? \"Add a description\" : \"Title\"}</span>\n      )}\n    </div>\n  );\n}\n\nexport function BrowserSessionFull() {\n  const [step, setStep] = React.useState(0);\n  const [typed, setTyped] = React.useState({ title: 0, body: 0 });\n  const [clicks, setClicks] = React.useState(0);\n  const [run, setRun] = React.useState(0);\n  const [startedAt, setStartedAt] = React.useState<number>();\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the session\n  React.useEffect(() => {\n    setStep(0);\n    setTyped({ title: 0, body: 0 });\n    setStartedAt(Date.now() - 6 * 60_000 - 12_000);\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const later = (ms: number, fn: () => void) => timers.push(setTimeout(fn, ms));\n    const type = (from: number, ms: number, key: \"title\" | \"body\", total: number) => {\n      const n = 24;\n      for (let i = 1; i <= n; i++)\n        later(from + (ms / n) * i, () =>\n          setTyped((t) => ({ ...t, [key]: Math.round((total * i) / n) })),\n        );\n    };\n    later(1300, () => setStep(1));\n    later(2300, () => setStep(2));\n    type(2400, 1500, \"title\", PR_TITLE.length);\n    later(4100, () => {\n      setStep(3);\n      setClicks((c) => c + 1);\n    });\n    type(4600, 1600, \"body\", PR_BODY.length);\n    later(6500, () => setStep(4));\n    later(7700, () => setStep(5));\n    later(9200, () => setStep(6));\n    return () => timers.forEach(clearTimeout);\n  }, [run]);\n\n  const created = step >= 5;\n  const current = STEPS[step];\n  const target = current ? TARGETS[current.id as keyof typeof TARGETS] : TARGETS.read;\n  const steps: BrowserStep[] = STEPS.map((s, i) => ({\n    ...s,\n    status: i < step ? \"done\" : i === step ? \"running\" : \"queued\",\n  }));\n\n  return (\n    <BrowserSession\n      className=\"w-full max-w-4xl\"\n      url={created ? PR_URL : COMPARE_URL}\n      title={created ? \"Migrate session API · acme/web\" : \"Open a pull request · acme/web\"}\n      viewport={{ width: W, height: H }}\n      status={step >= STEPS.length ? \"done\" : \"browsing\"}\n      startedAt={startedAt}\n      steps={steps}\n      cursor={target.cursor}\n      cursorLabel=\"Kura\"\n      clickKey={`${run}-${clicks}-${current?.kind === \"click\" ? current.id : \"\"}`}\n      highlight={step >= STEPS.length ? null : target.highlight}\n      onTakeOver={() => {}}\n      statusText={step >= STEPS.length ? \"acme/web#482 opened\" : undefined}\n      actions={\n        step >= STEPS.length && (\n          <IconButton\n            label=\"Replay\"\n            icon={<RotateCcw />}\n            variant=\"ghost\"\n            size=\"xs\"\n            className=\"animate-kura-fade-in\"\n            onClick={() => setRun((r) => r + 1)}\n          />\n        )\n      }\n    >\n      <div className={step === 0 ? \"opacity-40 transition-opacity\" : \"transition-opacity\"}>\n        {created ? (\n          <PullRequestPage />\n        ) : (\n          <ComparePage\n            title={PR_TITLE.slice(0, typed.title)}\n            body={PR_BODY.slice(0, typed.body)}\n            focus={step === 1 || step === 2 ? \"title\" : step === 3 ? \"body\" : null}\n          />\n        )}\n      </div>\n    </BrowserSession>\n  );\n}\n"
        },
        {
          "name": "BrowserSessionPaused",
          "title": "Paused for you",
          "description": "A 2FA prompt only the human can answer: amber edge, “Resume agent”.",
          "kind": "state",
          "code": "\"use client\";\n\nimport { Check } from \"lucide-react\";\nimport * as React from \"react\";\nimport { BrowserSession } from \"@kura-ui/react/browser-session\";\n\nconst W = 800;\n\nconst H = 500;\n\nconst at = (x: number, y: number) => ({ x: (x / W) * 100, y: (y / H) * 100 });\n\nconst box = (x: number, y: number, w: number, h: number, label?: string) => ({\n  x: (x / W) * 100,\n  y: (y / H) * 100,\n  width: (w / W) * 100,\n  height: (h / H) * 100,\n  label,\n});\n\nfunction TwoFactorPage() {\n  return (\n    <div className=\"flex h-[500px] flex-col items-center bg-kura-surface-sunken pt-16 font-kura-sans text-kura-fg\">\n      <div className=\"flex w-[360px] flex-col items-center gap-1 rounded-[14px] bg-kura-surface px-8 py-7 shadow-kura-sm\">\n        <span className=\"mb-3 size-8 rounded-[8px] bg-kura-fg\" />\n        <h1 className=\"font-semibold text-[17px] tracking-[-0.01em]\">Two-factor authentication</h1>\n        <p className=\"text-center text-[13px] text-kura-fg-muted leading-5\">\n          Enter the 6-digit code from your authenticator app to continue to Acme Cloud.\n        </p>\n        <div data-target=\"otp\" className=\"mt-5 flex gap-2\">\n          {[0, 1, 2, 3, 4, 5].map((i) => (\n            <span\n              key={i}\n              className=\"h-11 w-9 rounded-[8px] bg-kura-surface\"\n              style={{\n                boxShadow:\n                  i === 0\n                    ? \"0 0 0 1px var(--kura-accent), 0 0 0 3px var(--kura-ring)\"\n                    : \"0 0 0 1px var(--kura-border-strong)\",\n              }}\n            />\n          ))}\n        </div>\n        <span className=\"mt-5 flex h-9 w-full items-center justify-center rounded-[8px] bg-kura-ink font-medium text-[13px] text-kura-fg-inverted\">\n          Verify\n        </span>\n        <span className=\"mt-3 text-[12px] text-kura-fg-subtle\">Use a recovery code instead</span>\n      </div>\n    </div>\n  );\n}\n\nexport function BrowserSessionPaused() {\n  const [paused, setPaused] = React.useState(true);\n  const [startedAt] = React.useState(() => Date.now() - 9 * 60_000 - 41_000);\n  return (\n    <BrowserSession\n      className=\"w-full max-w-4xl\"\n      url=\"https://cloud.acme.dev/login/two-factor?next=%2Fweb%2Fdeployments\"\n      title=\"Sign in · Acme Cloud\"\n      viewport={{ width: W, height: H }}\n      status={paused ? \"paused\" : \"browsing\"}\n      statusText={paused ? \"Enter the 2FA code, then hand back\" : undefined}\n      startedAt={startedAt}\n      cursor={at(300, 262)}\n      cursorLabel=\"Kura\"\n      highlight={box(264, 226, 272, 58, \"input[autocomplete=one-time-code]\")}\n      onResume={() => setPaused(false)}\n      onTakeOver={() => setPaused(true)}\n      steps={[\n        {\n          id: \"1\",\n          kind: \"navigate\",\n          label: \"Open the preview\",\n          status: \"done\",\n          detail: \"cloud.acme.dev/web/deployments\",\n        },\n        {\n          id: \"2\",\n          kind: \"click\",\n          label: \"Continue with GitHub\",\n          status: \"done\",\n          detail: 'button[data-provider=\"github\"]',\n        },\n        {\n          id: \"3\",\n          kind: \"type\",\n          label: \"Enter the 2FA code\",\n          status: paused ? \"waiting\" : \"running\",\n          detail: \"Needs a code only you have\",\n        },\n        {\n          id: \"4\",\n          kind: \"extract\",\n          label: \"Check the login page\",\n          status: \"queued\",\n          detail: \"/login\",\n        },\n      ]}\n    >\n      <TwoFactorPage />\n    </BrowserSession>\n  );\n}\n"
        },
        {
          "name": "BrowserSessionComposition",
          "title": "Under the plan",
          "description": "A narrow stacked session under a TaskList, as in a run sidebar.",
          "kind": "composition",
          "code": "import { Check, GitPullRequest } from \"lucide-react\";\nimport { TaskList } from \"@kura-ui/react/task-list\";\nimport { BrowserSession } from \"@kura-ui/react/browser-session\";\n\nconst W = 800;\n\nconst H = 500;\n\nconst at = (x: number, y: number) => ({ x: (x / W) * 100, y: (y / H) * 100 });\n\nconst box = (x: number, y: number, w: number, h: number, label?: string) => ({\n  x: (x / W) * 100,\n  y: (y / H) * 100,\n  width: (w / W) * 100,\n  height: (h / H) * 100,\n  label,\n});\n\nfunction PullRequestPage() {\n  return (\n    <div className=\"h-[500px] bg-kura-surface font-kura-sans text-kura-fg\">\n      <SiteBar />\n      <div className=\"px-8 pt-6\">\n        <h1 data-target=\"pr-h1\" className=\"font-semibold text-[20px] leading-7 tracking-[-0.015em]\">\n          {PR_TITLE} <span className=\"font-normal text-kura-fg-subtle\">#482</span>\n        </h1>\n        <div className=\"mt-2 flex items-center gap-2 text-[13px] text-kura-fg-muted\">\n          <span className=\"flex items-center gap-1 rounded-full bg-kura-success/15 px-2 py-0.5 font-medium text-[12px] text-kura-success-text\">\n            <GitPullRequest className=\"size-3\" strokeWidth={2} /> Open\n          </span>\n          <span>\n            <span className=\"font-medium text-kura-fg\">kura</span> wants to merge 4 commits into{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">main</span> from{\" \"}\n            <span className=\"font-kura-mono text-[12px]\">kura/auth-client</span>\n          </span>\n        </div>\n        <div className=\"mt-4 flex gap-5 border-kura-border-subtle border-b text-[13px] text-kura-fg-muted\">\n          <span className=\"-mb-px border-kura-fg border-b-2 pb-2 font-medium text-kura-fg\">\n            Conversation\n          </span>\n          <span>Commits 4</span>\n          <span>Checks 3</span>\n          <span>Files changed 6</span>\n        </div>\n        <div className=\"mt-4 rounded-[10px] p-4 text-[13px] text-kura-fg-muted leading-5 shadow-kura-xs\">\n          {PR_BODY}\n        </div>\n        <div className=\"mt-3 flex flex-col rounded-[10px] text-[13px] shadow-kura-xs\">\n          <div className=\"flex h-10 items-center gap-2 border-kura-border-subtle border-b px-4 font-medium\">\n            <Check className=\"size-4 text-kura-success\" strokeWidth={2.25} /> All checks have passed\n          </div>\n          {[\n            [\"test\", \"142 passed · 41s\"],\n            [\"typecheck\", \"0 errors · 18s\"],\n            [\"lint\", \"clean · 6s\"],\n          ].map(([name, meta]) => (\n            <div key={name} className=\"flex h-9 items-center gap-2 px-4\">\n              <Check className=\"size-3.5 text-kura-success\" strokeWidth={2.25} />\n              <span className=\"font-kura-mono text-[12.5px]\">{name}</span>\n              <span className=\"ml-auto font-kura-mono text-[12px] text-kura-fg-subtle\">{meta}</span>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst PR_URL = \"https://github.com/acme/web/pull/482\";\n\nconst PR_TITLE = \"Migrate session API to @acme/auth client\";\n\nconst PR_BODY =\n  \"Replaces fetchUser() and the legacy /api/session REST calls with createAuthClient(). Refreshes expired sessions with the rotated token.\";\n\nfunction SiteBar() {\n  return (\n    <div className=\"flex h-12 items-center gap-3 border-kura-border-subtle border-b px-8 text-[14px]\">\n      <span className=\"size-5 rounded-[5px] bg-kura-fg\" />\n      <span className=\"text-kura-fg-muted\">acme</span>\n      <span className=\"text-kura-fg-subtle\">/</span>\n      <span className=\"font-semibold\">web</span>\n      <span className=\"ml-auto flex items-center gap-5 text-[13px] text-kura-fg-muted\">\n        <span>Code</span>\n        <span className=\"font-medium text-kura-fg\">Pull requests</span>\n        <span>Actions</span>\n        <span className=\"size-6 rounded-full bg-kura-gray-5\" />\n      </span>\n    </div>\n  );\n}\n\nexport function BrowserSessionComposition() {\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-3\">\n      <TaskList\n        title=\"Plan\"\n        collapseCompleted={false}\n        items={[\n          { id: \"t\", title: \"Run pnpm test\", status: \"done\", durationMs: 36_800 },\n          { id: \"p\", title: \"Open pull request\", status: \"running\", detail: \"via the browser\" },\n          { id: \"s\", title: \"Summarize the run\", status: \"queued\" },\n        ]}\n      />\n      <BrowserSession\n        layout=\"stacked\"\n        url={PR_URL}\n        title=\"Migrate session API · acme/web\"\n        viewport={{ width: W, height: H }}\n        cursor={at(468, 94)}\n        cursorLabel=\"Kura\"\n        highlight={box(24, 66, 478, 40, \"h1 · #482\")}\n        steps={[\n          { id: \"a\", kind: \"click\", label: \"Click “Create pull request”\", status: \"done\" },\n          {\n            id: \"b\",\n            kind: \"extract\",\n            label: \"Read the PR number\",\n            status: \"running\",\n            detail: \"h1\",\n          },\n        ]}\n      >\n        <PullRequestPage />\n      </BrowserSession>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Coordinates are percentages of the viewport. If your agent reports pixels on a W×H screen, pass `viewport={{ width: W, height: H }}` and use x = px / W * 100.",
        "For remote browsers pass the latest screenshot as `src` and keep `viewport` equal to the screenshot size; draw the *next* action with `cursor` + `highlight`.",
        "Change `clickKey` (e.g. an action counter) to replay the ripple; repeated clicks on the same step otherwise only ripple once.",
        "Set `status=\"paused\"` whenever the agent needs the human (login, 2FA, CAPTCHA, payment) and say why in `statusText`.",
        "Map the agent's actions to the five kinds; put the URL, selector or typed text in `detail` (mono)."
      ],
      "title": "Browser Session",
      "importPath": "@kura-ui/react/browser-session",
      "exports": [
        "BrowserHighlight",
        "BrowserPoint",
        "BrowserSession",
        "BrowserSessionProps",
        "BrowserStep",
        "BrowserStepKind"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/browser-session",
      "markdownUrl": "https://kuro.abstergo.fyi/components/browser-session.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/browser-session.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/browser-session.json",
        "cli": "npx @kura-ui/cli add browser-session",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "kura-format",
        "shimmer-text",
        "status-glyph",
        "use-elapsed",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "url": {
            "description": "Page URL in the address bar. The host is emphasised, the path muted; https shows a lock.",
            "x-ts-type": "string",
            "type": "string"
          },
          "title": {
            "description": "Page title next to the address bar (hidden in narrow containers).",
            "x-ts-type": "string",
            "type": "string"
          },
          "children": {
            "description": "Viewport content: a live page, or a mock rendered as markup.",
            "x-ts-type": "React.ReactNode"
          },
          "src": {
            "description": "A screenshot URL to show instead of children (e.g. the latest frame from a remote browser).",
            "x-ts-type": "string",
            "type": "string"
          },
          "alt": {
            "description": "Alt text for `src`. Default “Screenshot of {url}”.",
            "x-ts-type": "string",
            "type": "string"
          },
          "viewport": {
            "description": "Design size of the page in CSS px (e.g. the agent's 1280×800 screen). Children render at this size and scale to fit the frame, so % coordinates stay exact at any width. Omit to let children flow at the frame's width.",
            "x-ts-type": "{ width: number; height: number }"
          },
          "aspectRatio": {
            "description": "Viewport aspect ratio when `viewport` is not given.",
            "x-ts-type": "number",
            "type": "number",
            "default": "16 / 10"
          },
          "scrollTop": {
            "description": "Vertical page scroll in viewport px; eases on change (use for scroll steps).",
            "x-ts-type": "number",
            "type": "number",
            "default": 0
          },
          "cursor": {
            "description": "The agent's cursor { x, y } in % of the viewport. Glides to new positions.",
            "x-ts-type": "BrowserPoint | null"
          },
          "cursorLabel": {
            "description": "Name tag on the cursor, e.g. the agent's name.",
            "x-ts-type": "string",
            "type": "string"
          },
          "clickKey": {
            "description": "Change it to play a click ripple at the cursor. Default: the active step's id when that step is a click.",
            "x-ts-type": "string | number"
          },
          "highlight": {
            "description": "Box around the target element: { x, y, width, height } in % plus an optional `label` tag (e.g. `button \"Create pull request\"`).",
            "x-ts-type": "BrowserHighlight | null"
          },
          "steps": {
            "description": "The action log. BrowserStep = { id, kind: \"navigate\" | \"click\" | \"type\" | \"scroll\" | \"extract\", label, status: AgentStatus, detail? (mono: URL, selector, typed text) }. Omit to hide the log.",
            "x-ts-type": "readonly BrowserStep[]",
            "type": "array"
          },
          "activeStep": {
            "description": "Id of the step being executed. Default: the first running step.",
            "x-ts-type": "string",
            "type": "string"
          },
          "status": {
            "description": "browsing = “Agent is browsing” (blue, shimmer); paused = “Paused for you” (amber, the viewport gets an amber edge); done; failed.",
            "x-ts-type": "\"browsing\" | \"paused\" | \"done\" | \"failed\"",
            "type": "string",
            "enum": [
              "browsing",
              "done",
              "failed",
              "paused"
            ],
            "default": "browsing"
          },
          "statusText": {
            "description": "Secondary text after the status, e.g. “Enter the 2FA code, then hand back”.",
            "x-ts-type": "React.ReactNode"
          },
          "startedAt": {
            "description": "Session start (wall clock) for the elapsed clock; ticks while browsing.",
            "x-ts-type": "Date | number"
          },
          "loading": {
            "description": "Show the loading bar under the address bar. Default: true while the active step is a running navigate.",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "actions": {
            "description": "Extra controls in the status line (e.g. open in new tab, replay).",
            "x-ts-type": "React.ReactNode"
          },
          "onTakeOver": {
            "description": "Shows a “Take over” button while browsing.",
            "x-ts-type": "() => void"
          },
          "onResume": {
            "description": "Shows a “Resume agent” button while paused.",
            "x-ts-type": "() => void"
          },
          "layout": {
            "description": "Step log beside the browser (side), below it (stacked), or beside only when the container is ≥ 48rem (auto).",
            "x-ts-type": "\"auto\" | \"side\" | \"stacked\"",
            "type": "string",
            "enum": [
              "auto",
              "side",
              "stacked"
            ],
            "default": "auto"
          }
        },
        "required": [
          "url"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/browser-session/browser-session.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Hand, Lock, Play } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock } from \"@/lib/kura/format\";\nimport { Button } from \"@/components/kura/button\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\n\nexport type BrowserStepKind = \"navigate\" | \"click\" | \"type\" | \"scroll\" | \"extract\";\n\nexport interface BrowserStep {\n  /** Stable key. */\n  id: string;\n  kind: BrowserStepKind;\n  /** What the agent does, in words: \"Click “Create pull request”\". */\n  label: React.ReactNode;\n  status: AgentStatus;\n  /** Machine detail in mono: the URL, the typed text, the selector, the extracted value. */\n  detail?: React.ReactNode;\n}\n\nexport interface BrowserPoint {\n  /** Percent of the viewport width, 0–100. */\n  x: number;\n  /** Percent of the viewport height, 0–100. */\n  y: number;\n}\n\nexport interface BrowserHighlight extends BrowserPoint {\n  /** Percent of the viewport width. */\n  width: number;\n  /** Percent of the viewport height. */\n  height: number;\n  /** Short tag over the box, e.g. `button \"Create pull request\"`. */\n  label?: string;\n}\n\nexport interface BrowserSessionProps extends Omit<React.ComponentProps<\"div\">, \"title\"> {\n  /** The page URL shown in the address bar. */\n  url: string;\n  /** The page title shown next to the address bar. */\n  title?: string;\n  /** Viewport content: a live page, a mock rendered as markup, or anything else. */\n  children?: React.ReactNode;\n  /** A screenshot to show instead of `children`. */\n  src?: string;\n  /** Alt text for `src`. Default: \"Screenshot of {url}\". */\n  alt?: string;\n  /**\n   * Design size of the page in CSS px (e.g. the agent's 1280×800 screen). `children` render at this\n   * size and scale to fit, so coordinates stay stable at any width. Omit to let children flow.\n   */\n  viewport?: { width: number; height: number };\n  /** Viewport aspect ratio when `viewport` is not given. Default 16 / 10. */\n  aspectRatio?: number;\n  /** Vertical scroll of the page in viewport px (with `viewport`) or CSS px. Eases on change. */\n  scrollTop?: number;\n  /** The agent's cursor, in % of the viewport. Glides between positions. */\n  cursor?: BrowserPoint | null;\n  /** Name tag on the cursor (e.g. the agent's name). */\n  cursorLabel?: string;\n  /** Change this to play a click ripple at the cursor. Default: the active step's id when it is a click. */\n  clickKey?: string | number;\n  /** Box around the element the agent is acting on, in % of the viewport. */\n  highlight?: BrowserHighlight | null;\n  /** The step log. Omit to hide the log. */\n  steps?: readonly BrowserStep[];\n  /** Id of the step being executed. Default: the first running step. */\n  activeStep?: string;\n  /** Session state. browsing (blue), paused for the human (amber), done, failed. Default \"browsing\". */\n  status?: \"browsing\" | \"paused\" | \"done\" | \"failed\";\n  /** Override the status line, e.g. \"Paused — enter the 2FA code\". */\n  statusText?: React.ReactNode;\n  /** Wall-clock start of the session, for the elapsed clock. */\n  startedAt?: Date | number;\n  /** Show the page-loading bar under the address bar. Default: the active step is a running navigate. */\n  loading?: boolean;\n  /** Extra controls in the status line, before Take over / Resume. */\n  actions?: React.ReactNode;\n  /** Shows \"Take over\" while browsing. */\n  onTakeOver?: () => void;\n  /** Shows \"Resume agent\" while paused. */\n  onResume?: () => void;\n  /** Step log beside the browser, below it, or beside only when the container is wide. Default \"auto\". */\n  layout?: \"auto\" | \"side\" | \"stacked\";\n}\n\nconst STATUS_GLYPH: Record<NonNullable<BrowserSessionProps[\"status\"]>, AgentStatus> = {\n  browsing: \"running\",\n  paused: \"waiting\",\n  done: \"done\",\n  failed: \"failed\",\n};\n\nconst STATUS_TEXT: Record<NonNullable<BrowserSessionProps[\"status\"]>, string> = {\n  browsing: \"Agent is browsing\",\n  paused: \"Paused for you\",\n  done: \"Session ended\",\n  failed: \"Session failed\",\n};\n\nconst STEP_SPOKEN: Record<AgentStatus, string> = {\n  queued: \"pending\",\n  running: \"in progress\",\n  waiting: \"waiting for you\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n  paused: \"paused\",\n};\n\nfunction splitUrl(url: string): { secure: boolean; host: string; rest: string } {\n  const m = /^(https?:\\/\\/)?([^/?#]+)(.*)$/.exec(url);\n  return {\n    secure: !url.startsWith(\"http://\"),\n    host: m?.[2] ?? url,\n    rest: m?.[3] ?? \"\",\n  };\n}\n\nfunction useScale(ref: React.RefObject<HTMLDivElement | null>, width: number | undefined) {\n  const [scale, setScale] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || !width) return;\n    const update = () => setScale(el.clientWidth / width);\n    update();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref, width]);\n  return scale;\n}\n\nfunction Cursor({ point, label }: { point: BrowserPoint; label?: string }) {\n  return (\n    <span\n      data-slot=\"browser-session-cursor\"\n      className=\"absolute z-30 transition-[left,top] duration-(--kura-duration-slower) ease-kura-in-out motion-reduce:transition-none\"\n      style={{ left: `${point.x}%`, top: `${point.y}%` }}\n    >\n      <svg\n        aria-hidden\n        width=\"16\"\n        height=\"20\"\n        viewBox=\"0 0 16 20\"\n        className=\"-translate-x-[2px] -translate-y-[1px] drop-shadow-[0_1px_1.5px_oklch(0_0_0/0.25)]\"\n      >\n        <path\n          d=\"M1.5 1.5v14.2l3.7-3.5 2.5 5.8 2.5-1.1-2.4-5.6h5.2L1.5 1.5Z\"\n          className=\"fill-kura-fg stroke-kura-surface\"\n          strokeWidth=\"1.25\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n      {label && (\n        <span className=\"absolute top-[15px] left-[11px] whitespace-nowrap rounded-[5px] bg-kura-accent px-1.5 py-px font-medium text-[10.5px] text-kura-accent-fg leading-4 shadow-kura-xs\">\n          {label}\n        </span>\n      )}\n    </span>\n  );\n}\n\n/** One click ripple; replays when its key changes. Waits for the cursor to arrive. */\nfunction Ripple({ point }: { point: BrowserPoint }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof el.animate !== \"function\") return;\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const animation = el.animate(\n      [\n        { transform: \"translate(-50%, -50%) scale(0.2)\", opacity: 1 },\n        { transform: \"translate(-50%, -50%) scale(1)\", opacity: 0 },\n      ],\n      { duration: 700, delay: 420, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\", fill: \"forwards\" },\n    );\n    return () => animation.cancel();\n  }, []);\n  return (\n    <span\n      ref={ref}\n      data-slot=\"browser-session-ripple\"\n      className=\"absolute z-20 size-10 rounded-full border-2 border-kura-accent opacity-0\"\n      style={{ left: `${point.x}%`, top: `${point.y}%`, transform: \"translate(-50%, -50%)\" }}\n    />\n  );\n}\n\nfunction StepRow({ step, active }: { step: BrowserStep; active: boolean }) {\n  return (\n    <li\n      data-slot=\"browser-session-step\"\n      data-kind={step.kind}\n      data-status={step.status}\n      data-active={active || undefined}\n      aria-current={active ? \"step\" : undefined}\n      className={cn(\n        \"relative flex items-start gap-2.5 border-kura-border-subtle border-t px-3.5 py-2 first:border-t-0\",\n        \"transition-[opacity,translate,background-color] duration-(--kura-duration-base) ease-kura-out starting:translate-y-[2px] starting:opacity-0 motion-reduce:starting:translate-y-0\",\n        active && \"bg-kura-hover\",\n      )}\n    >\n      <span className=\"flex h-5 shrink-0 items-center\">\n        <StatusGlyph status={step.status} size=\"sm\" label={null} />\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span\n          className={cn(\n            \"text-[13px] leading-5\",\n            step.status === \"queued\" ? \"text-kura-fg-muted\" : \"text-kura-fg\",\n            step.status === \"running\" && \"font-medium\",\n            step.status === \"skipped\" && \"text-kura-fg-subtle line-through\",\n          )}\n        >\n          {step.status === \"running\" ? (\n            <ShimmerText duration={2.4}>{step.label}</ShimmerText>\n          ) : (\n            step.label\n          )}\n          <span className=\"sr-only\"> ({STEP_SPOKEN[step.status]})</span>\n        </span>\n        {step.detail != null && (\n          <span\n            className={cn(\n              \"truncate font-kura-mono text-[11.5px] leading-4\",\n              step.status === \"failed\" ? \"text-kura-danger-text\" : \"text-kura-fg-subtle\",\n            )}\n          >\n            {step.detail}\n          </span>\n        )}\n      </span>\n      <span className=\"flex h-5 shrink-0 items-center font-kura-mono text-[11px] text-kura-fg-subtle\">\n        {step.kind}\n      </span>\n    </li>\n  );\n}\n\n/**\n * A computer-use / browser-use session made legible: a tidy browser frame (title + address bar with\n * lock), the page itself (your markup or a screenshot, optionally at a fixed design size that scales\n * to fit), an action overlay (the agent's cursor gliding between targets, a click ripple, a box around\n * the target element) and a step log of navigate / click / type / scroll / extract actions, with a\n * status line and take-over / resume controls.\n */\nexport function BrowserSession({\n  url,\n  title,\n  children,\n  src,\n  alt,\n  viewport,\n  aspectRatio = 16 / 10,\n  scrollTop = 0,\n  cursor,\n  cursorLabel,\n  clickKey,\n  highlight,\n  steps,\n  activeStep,\n  status = \"browsing\",\n  statusText,\n  startedAt,\n  loading,\n  actions,\n  onTakeOver,\n  onResume,\n  layout = \"auto\",\n  className,\n  ...props\n}: BrowserSessionProps) {\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const logRef = React.useRef<HTMLOListElement>(null);\n  const scale = useScale(frameRef, viewport?.width);\n  const elapsed = useElapsed(startedAt, status === \"browsing\", 1000);\n\n  const active =\n    steps?.find((s) => s.id === activeStep) ?? steps?.find((s) => s.status === \"running\");\n  const ripple = clickKey ?? (active?.kind === \"click\" ? active.id : undefined);\n  const isLoading = loading ?? (active?.kind === \"navigate\" && active.status === \"running\");\n  const done = steps?.filter((s) => s.status === \"done\" || s.status === \"skipped\").length ?? 0;\n  const { secure, host, rest } = splitUrl(url);\n  const ratio = viewport ? viewport.width / viewport.height : aspectRatio;\n\n  // Keep the active step in view inside the log (without scrolling the page).\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the active step changes\n  React.useEffect(() => {\n    const list = logRef.current;\n    const scroller = list?.parentElement;\n    const row = list?.querySelector<HTMLElement>(\"[data-active]\");\n    if (!scroller || !row) return;\n    const top = row.offsetTop - list.offsetTop;\n    if (\n      top < scroller.scrollTop ||\n      top + row.offsetHeight > scroller.scrollTop + scroller.clientHeight\n    )\n      scroller.scrollTop = top - scroller.clientHeight / 2 + row.offsetHeight / 2;\n  }, [active?.id]);\n\n  const announcement =\n    status === \"paused\"\n      ? typeof statusText === \"string\"\n        ? statusText\n        : \"Paused for you\"\n      : status === \"done\"\n        ? \"Session ended\"\n        : status === \"failed\"\n          ? \"Session failed\"\n          : typeof active?.label === \"string\"\n            ? active.label\n            : \"\";\n\n  const side = layout === \"side\";\n  const auto = layout === \"auto\";\n\n  return (\n    <div\n      data-slot=\"browser-session\"\n      data-status={status}\n      data-layout={layout}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      {/* Status line */}\n      <div\n        data-slot=\"browser-session-header\"\n        className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b px-3.5\"\n      >\n        <StatusGlyph status={STATUS_GLYPH[status]} size=\"sm\" label={null} />\n        <span className=\"flex min-w-0 items-baseline gap-2 text-[13px]\">\n          <span\n            className={cn(\n              \"min-w-0 truncate font-medium @md:shrink-0\",\n              status === \"paused\" && \"text-kura-warning-text\",\n            )}\n          >\n            {status === \"browsing\" ? (\n              <ShimmerText duration={2.4}>{STATUS_TEXT.browsing}</ShimmerText>\n            ) : (\n              STATUS_TEXT[status]\n            )}\n          </span>\n          {statusText != null && (\n            <span className=\"hidden min-w-0 truncate text-kura-fg-subtle @md:inline\">\n              {statusText}\n            </span>\n          )}\n        </span>\n        <span className=\"flex-1\" />\n        {steps && steps.length > 0 && (\n          <span className=\"hidden whitespace-nowrap font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums @md:inline\">\n            {done}/{steps.length}\n          </span>\n        )}\n        {elapsed != null && (\n          <span\n            data-slot=\"browser-session-elapsed\"\n            className=\"min-w-[4.5ch] text-right font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            {formatClock(elapsed)}\n          </span>\n        )}\n        {actions}\n        {status === \"browsing\" && onTakeOver && (\n          <Button variant=\"outline\" size=\"xs\" onClick={onTakeOver} className=\"-mr-1\">\n            <Hand strokeWidth={1.75} /> Take over\n          </Button>\n        )}\n        {status === \"paused\" && onResume && (\n          <Button variant=\"primary\" size=\"xs\" onClick={onResume} className=\"-mr-1\">\n            <Play strokeWidth={1.75} /> Resume agent\n          </Button>\n        )}\n      </div>\n\n      <div\n        className={cn(\n          \"grid min-w-0\",\n          side && \"grid-cols-[minmax(0,1fr)_minmax(220px,34%)]\",\n          auto && steps && \"@3xl:grid-cols-[minmax(0,1fr)_minmax(240px,32%)]\",\n        )}\n      >\n        {/* Browser */}\n        <div data-slot=\"browser-session-browser\" className=\"flex min-w-0 flex-col\">\n          <div\n            data-slot=\"browser-session-chrome\"\n            className=\"relative flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b px-2.5\"\n          >\n            {title && (\n              <span className=\"hidden max-w-[34%] min-w-0 items-center gap-2 pl-1 @lg:flex\">\n                <span\n                  aria-hidden\n                  className=\"grid size-4 shrink-0 place-items-center rounded-[4px] bg-kura-fg font-semibold text-[9px] text-kura-fg-inverted\"\n                >\n                  {host\n                    .replace(/^www\\./, \"\")\n                    .charAt(0)\n                    .toUpperCase()}\n                </span>\n                <span className=\"truncate font-medium text-[12.5px]\">{title}</span>\n              </span>\n            )}\n            {/* biome-ignore lint/a11y/useSemanticElements: a fieldset would add form semantics to a read-only address */}\n            <div\n              data-slot=\"browser-session-address\"\n              role=\"group\"\n              aria-label=\"Address\"\n              className=\"flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-kura-md bg-kura-surface-sunken px-2.5 shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\"\n            >\n              {secure && (\n                <Lock\n                  aria-label=\"Secure connection\"\n                  strokeWidth={2}\n                  className=\"size-3 shrink-0 text-kura-fg-subtle\"\n                />\n              )}\n              <span className=\"min-w-0 truncate font-kura-mono text-[12px]\">\n                <span className=\"text-kura-fg\">{host}</span>\n                <span className=\"text-kura-fg-subtle\">{rest}</span>\n              </span>\n            </div>\n            {isLoading && (\n              <span\n                data-slot=\"browser-session-loading\"\n                aria-hidden\n                className=\"absolute inset-x-0 -bottom-px h-[2px] animate-kura-shimmer bg-[length:250%_100%] bg-[linear-gradient(90deg,transparent_30%,var(--kura-accent)_50%,transparent_70%)] motion-reduce:animate-none motion-reduce:bg-kura-accent/40\"\n              />\n            )}\n          </div>\n\n          {/* Viewport */}\n          <div\n            ref={frameRef}\n            data-slot=\"browser-session-viewport\"\n            className={cn(\n              \"relative w-full overflow-hidden bg-kura-canvas\",\n              status === \"paused\" &&\n                \"after:pointer-events-none after:absolute after:inset-0 after:z-40 after:shadow-[inset_0_0_0_2px_var(--kura-warning)]\",\n            )}\n            style={{ aspectRatio: ratio }}\n          >\n            <div\n              data-slot=\"browser-session-page\"\n              className={cn(\n                \"absolute top-0 left-0 transition-transform duration-(--kura-duration-slower) ease-kura-in-out motion-reduce:transition-none\",\n                !viewport && \"inset-x-0\",\n                viewport && scale == null && \"opacity-0\",\n              )}\n              style={\n                viewport\n                  ? {\n                      width: viewport.width,\n                      transformOrigin: \"0 0\",\n                      transform: `scale(${scale ?? 1}) translateY(${-scrollTop}px)`,\n                    }\n                  : { transform: `translateY(${-scrollTop}px)` }\n              }\n            >\n              {src ? (\n                <img\n                  src={src}\n                  alt={alt ?? `Screenshot of ${url}`}\n                  className=\"block h-auto w-full\"\n                  draggable={false}\n                />\n              ) : (\n                children\n              )}\n            </div>\n\n            <div\n              data-slot=\"browser-session-overlay\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 z-20\"\n            >\n              {highlight && (\n                <span\n                  data-slot=\"browser-session-highlight\"\n                  className=\"absolute rounded-[5px] bg-kura-accent/[0.06] shadow-[0_0_0_1.5px_var(--kura-accent)] transition-[left,top,width,height,opacity] duration-(--kura-duration-slow) ease-kura-out starting:opacity-0 motion-reduce:transition-opacity\"\n                  style={{\n                    left: `${highlight.x}%`,\n                    top: `${highlight.y}%`,\n                    width: `${highlight.width}%`,\n                    height: `${highlight.height}%`,\n                  }}\n                >\n                  {highlight.label && (\n                    <span className=\"absolute bottom-full left-[-1.5px] mb-1 max-w-[240px] truncate whitespace-nowrap rounded-[4px] bg-kura-accent px-1.5 font-kura-mono text-[10.5px] text-kura-accent-fg leading-4\">\n                      {highlight.label}\n                    </span>\n                  )}\n                </span>\n              )}\n              {cursor && ripple != null && <Ripple key={String(ripple)} point={cursor} />}\n              {cursor && <Cursor point={cursor} label={cursorLabel} />}\n            </div>\n          </div>\n        </div>\n\n        {/* Step log */}\n        {steps && (\n          <div\n            data-slot=\"browser-session-log\"\n            className={cn(\n              \"relative min-w-0 border-kura-border-subtle\",\n              side ? \"border-l\" : \"border-t\",\n              auto && \"@3xl:border-t-0 @3xl:border-l\",\n            )}\n          >\n            <div\n              className={cn(\n                \"overflow-y-auto overscroll-contain\",\n                side && \"absolute inset-0\",\n                !side && \"max-h-56\",\n                auto && \"@3xl:absolute @3xl:inset-0 @3xl:max-h-none\",\n              )}\n            >\n              <ol ref={logRef} aria-label=\"Browser actions\" className=\"flex flex-col\">\n                {steps.map((step) => (\n                  <StepRow key={step.id} step={step} active={step.id === active?.id} />\n                ))}\n              </ol>\n            </div>\n          </div>\n        )}\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/browser-session/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type BrowserHighlight,\n  type BrowserPoint,\n  BrowserSession,\n  type BrowserSessionProps,\n  type BrowserStep,\n  type BrowserStepKind,\n} from \"./browser-session\";\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 = solid ink call to action; secondary = soft grey fill; outline = surface with a hairline; ghost = no chrome until hover; 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.98 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 { GitBranch, Play, Plus, 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-5\">\n      <div className=\"flex flex-wrap items-center justify-center gap-2\">\n        <Button variant=\"primary\">Deploy</Button>\n        <Button variant=\"secondary\">Edit prompt</Button>\n        <Button variant=\"outline\">\n          <GitBranch /> Branch chat\n        </Button>\n        <Button variant=\"ghost\">Skip</Button>\n        <Button variant=\"accent\">\n          <Play /> 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          <Plus /> Tag\n        </Button>\n        <Button size=\"sm\" variant=\"outline\">\n          Small\n        </Button>\n        <Button size=\"md\" variant=\"outline\">\n          Medium\n        </Button>\n        <Button size=\"lg\" variant=\"outline\">\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    <div className=\"flex items-center gap-2\">\n      <Button asChild>\n        <a href=\"https://github.com/Absterrg0/Kura-UI#readme\">Get started</a>\n      </Button>\n      <Button asChild variant=\"outline\">\n        <a href=\"https://github.com/Absterrg0/Kura-UI\">\n          View source <ArrowUpRight />\n        </a>\n      </Button>\n    </div>\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 = solid ink call to action; secondary = soft grey fill; outline = surface with a hairline; ghost = no chrome until hover; 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-kura-sans font-medium tracking-[-0.006em] 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.98] motion-reduce:active:not-disabled:scale-100 \" +\n  \"disabled:cursor-not-allowed disabled:not-aria-busy: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 hover:not-disabled:bg-kura-ink-hover\",\n  secondary:\n    \"bg-kura-gray-4 text-kura-fg hover:not-disabled:bg-kura-gray-5 active:not-disabled:bg-kura-gray-6\",\n  outline:\n    \"bg-kura-surface text-kura-fg shadow-kura-xs hover:not-disabled:bg-kura-gray-3 active:not-disabled:bg-kura-gray-4\",\n  ghost:\n    \"bg-transparent text-kura-fg-muted hover:not-disabled:bg-kura-hover hover:not-disabled:text-kura-fg active:not-disabled:bg-kura-active\",\n  accent: \"bg-kura-accent text-kura-accent-fg hover:not-disabled:bg-kura-accent-hover\",\n  danger: \"bg-kura-danger text-white hover:not-disabled:opacity-90\",\n};\n\n// Heights 24 / 28 / 32 / 40. A leading or trailing icon trims the padding on that side so the\n// icon sits optically centred against the label.\nconst sizes: Record<ButtonSize, string> = {\n  xs: \"h-6 rounded-kura-sm px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n  sm: \"h-7 rounded-kura-md px-2.5 text-[13px] has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-3.5\",\n  md: \"h-8 rounded-kura-md px-3 text-sm has-[>svg]:px-2.5 [&_svg:not([class*='size-'])]:size-4\",\n  lg: \"h-10 rounded-kura-lg px-4 text-[15px] has-[>svg]:px-3.5 [&_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.98 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": "checkpoints",
      "name": "Checkpoints",
      "category": "agent",
      "status": "experimental",
      "description": "Restore points across an agent run: a compact rail of auto and saved checkpoints with relative times, a current marker and inline-confirmed Restore.",
      "whenToUse": [
        "A coding agent snapshots the workspace before edits (Claude Code rewind, Cursor checkpoints) and the human should be able to roll files back.",
        "Showing where the workspace is after a rewind: checkpoints newer than the current one read as undone and can be restored forward.",
        "Letting people save a manual checkpoint before a risky step (onCreate)."
      ],
      "whenNotToUse": [
        "The agent's plan or steps — use task-list or agent-timeline.",
        "Reviewing what changed in files — use diff-view or file-changes.",
        "Git history or commits — this is for harness snapshots, not a commit log."
      ],
      "props": [
        {
          "name": "checkpoints",
          "type": "Checkpoint[]",
          "required": true,
          "description": "{ id, label, at, filesChanged?, kind: 'auto' | 'manual', current? }, newest first. Mark exactly one current."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Checkpoints\"",
          "description": "Header title; the count follows it."
        },
        {
          "name": "onRestore",
          "type": "(id: string) => void | Promise<void>",
          "description": "Restore handler, called after the inline confirm. Return a promise to show a spinner until it settles. Without it no Restore buttons render."
        },
        {
          "name": "onCreate",
          "type": "() => void",
          "description": "Shows 'Save checkpoint' in the header. Add a kind:'manual' checkpoint marked current."
        },
        {
          "name": "confirm",
          "type": "boolean",
          "default": "true",
          "description": "Ask inline before restoring. false calls onRestore directly."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Replaces the default empty state ('No checkpoints yet')."
        }
      ],
      "parts": [
        {
          "name": "Checkpoints",
          "description": "Root panel (a size container).",
          "dataSlot": "checkpoints"
        },
        {
          "name": "Header",
          "description": "Title, count and the optional checkpoints-create button.",
          "dataSlot": "checkpoints-header"
        },
        {
          "name": "Item",
          "description": "<li> with `data-kind`, `data-current`, `data-ahead` (newer than current = undone) and `data-state='confirming'`. Node on a hairline rail: hollow ring = auto, diamond = saved, ink dot with halo = current.",
          "dataSlot": "checkpoints-item"
        },
        {
          "name": "Label",
          "description": "What the agent had just done. Truncates.",
          "dataSlot": "checkpoints-label"
        },
        {
          "name": "Current",
          "description": "'Current' pill.",
          "dataSlot": "checkpoints-current"
        },
        {
          "name": "Files",
          "description": "'3 files' in mono (hidden when narrow).",
          "dataSlot": "checkpoints-files"
        },
        {
          "name": "Restore",
          "description": "Ghost button that swaps in for the relative time on hover/focus (always visible on touch); aria-expanded when confirming.",
          "dataSlot": "checkpoints-restore"
        },
        {
          "name": "Confirm",
          "description": "Inline confirm under the row: what will be undone (or re-applied), Cancel and Restore.",
          "dataSlot": "checkpoints-confirm"
        },
        {
          "name": "Empty",
          "description": "Empty state.",
          "dataSlot": "checkpoints-empty"
        }
      ],
      "a11y": {
        "role": "list (ol)",
        "notes": [
          "An ordered list labelled by the title. Times are <time datetime> with the absolute time as a tooltip.",
          "Restore buttons are named 'Restore to <label>'. Opening the confirm moves focus to its Restore button; Escape or Cancel closes it and returns focus to the trigger.",
          "Undone checkpoints carry visually hidden '(undone)'. A polite status region announces 'Restored to …'.",
          "Relative times render after mount only, so server and client markup match."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Reach each Restore button (it becomes visible on focus)"
          },
          {
            "keys": "Enter / Space",
            "action": "Open the inline confirm, then confirm"
          },
          {
            "keys": "Escape",
            "action": "Cancel the confirm"
          }
        ],
        "reducedMotion": "Rows fade in without rising; the confirm opens with a fade instead of a height transition."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button"
        ]
      },
      "related": [
        "run-header",
        "diff-view",
        "file-changes",
        "task-list"
      ],
      "tags": [
        "agent",
        "checkpoint",
        "restore",
        "rewind",
        "undo",
        "history",
        "snapshot"
      ],
      "examples": [
        {
          "name": "CheckpointsMinimal",
          "title": "Three checkpoints",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { type Checkpoint, Checkpoints } from \"@kura-ui/react/checkpoints\";\n\nconst sec = (s: number) => Date.now() - s * 1000;\n\nexport function CheckpointsMinimal() {\n  const [checkpoints] = React.useState<Checkpoint[]>(() => [\n    {\n      id: \"c3\",\n      label: \"Patched src/middleware.ts\",\n      at: sec(70),\n      filesChanged: 1,\n      kind: \"auto\",\n      current: true,\n    },\n    {\n      id: \"c2\",\n      label: \"Replaced fetchUser() in 9 files\",\n      at: sec(260),\n      filesChanged: 9,\n      kind: \"auto\",\n    },\n    { id: \"c1\", label: \"Run started on kura/auth-client\", at: sec(690), kind: \"auto\" },\n  ]);\n  return <Checkpoints className=\"w-full max-w-md\" checkpoints={checkpoints} onRestore={() => {}} />;\n}\n"
        },
        {
          "name": "CheckpointsFull",
          "title": "Live, with save and restore",
          "description": "A new automatic checkpoint lands while you watch; Restore confirms inline and resolves after a simulated 900ms; Save checkpoint adds a manual one.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { type Checkpoint, Checkpoints } from \"@kura-ui/react/checkpoints\";\n\nfunction storyline(): Checkpoint[] {\n  return [\n    {\n      id: \"tests\",\n      label: \"Updated login and session tests\",\n      at: sec(150),\n      filesChanged: 3,\n      kind: \"auto\",\n      current: true,\n    },\n    { id: \"mw\", label: \"Patched src/middleware.ts\", at: sec(290), filesChanged: 1, kind: \"auto\" },\n    { id: \"manual\", label: \"Before middleware changes\", at: sec(370), kind: \"manual\" },\n    {\n      id: \"client\",\n      label: \"Replaced fetchUser() with auth.getSession()\",\n      at: sec(430),\n      filesChanged: 9,\n      kind: \"auto\",\n    },\n    { id: \"start\", label: \"Run started on kura/auth-client\", at: sec(720), kind: \"auto\" },\n  ];\n}\n\nconst sec = (s: number) => Date.now() - s * 1000;\n\nexport function CheckpointsFull() {\n  const [checkpoints, setCheckpoints] = React.useState<Checkpoint[]>(storyline);\n\n  // The agent keeps working: a new automatic checkpoint lands a moment after mount.\n  React.useEffect(() => {\n    const t = setTimeout(() => {\n      setCheckpoints((list) => [\n        {\n          id: \"fix\",\n          label: \"Fixed expired-session refresh in src/auth/client.ts\",\n          at: Date.now(),\n          filesChanged: 1,\n          kind: \"auto\",\n          current: true,\n        },\n        ...list.map((c) => ({ ...c, current: false })),\n      ]);\n    }, 1800);\n    return () => clearTimeout(t);\n  }, []);\n\n  const restore = (id: string) =>\n    new Promise<void>((resolve) =>\n      setTimeout(() => {\n        setCheckpoints((list) => list.map((c) => ({ ...c, current: c.id === id })));\n        resolve();\n      }, 900),\n    );\n\n  const save = () =>\n    setCheckpoints((list) => {\n      const current = list.find((c) => c.current);\n      return [\n        {\n          id: `manual-${list.length}`,\n          label: current ? `Saved after “${current.label}”` : \"Saved checkpoint\",\n          at: Date.now(),\n          kind: \"manual\" as const,\n          current: true,\n        },\n        ...list.map((c) => ({ ...c, current: false })),\n      ];\n    });\n\n  return (\n    <Checkpoints\n      className=\"w-full max-w-lg\"\n      checkpoints={checkpoints}\n      onRestore={restore}\n      onCreate={save}\n    />\n  );\n}\n"
        },
        {
          "name": "CheckpointsRestored",
          "title": "After a rewind",
          "description": "The current checkpoint is in the middle; newer ones read as undone and can be restored forward.",
          "kind": "state",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { type Checkpoint, Checkpoints } from \"@kura-ui/react/checkpoints\";\n\nfunction storyline(): Checkpoint[] {\n  return [\n    {\n      id: \"tests\",\n      label: \"Updated login and session tests\",\n      at: sec(150),\n      filesChanged: 3,\n      kind: \"auto\",\n      current: true,\n    },\n    { id: \"mw\", label: \"Patched src/middleware.ts\", at: sec(290), filesChanged: 1, kind: \"auto\" },\n    { id: \"manual\", label: \"Before middleware changes\", at: sec(370), kind: \"manual\" },\n    {\n      id: \"client\",\n      label: \"Replaced fetchUser() with auth.getSession()\",\n      at: sec(430),\n      filesChanged: 9,\n      kind: \"auto\",\n    },\n    { id: \"start\", label: \"Run started on kura/auth-client\", at: sec(720), kind: \"auto\" },\n  ];\n}\n\nconst sec = (s: number) => Date.now() - s * 1000;\n\nexport function CheckpointsRestored() {\n  const [checkpoints, setCheckpoints] = React.useState<Checkpoint[]>(() =>\n    storyline().map((c) => ({ ...c, current: c.id === \"manual\" })),\n  );\n  return (\n    <Checkpoints\n      className=\"w-full max-w-lg\"\n      checkpoints={checkpoints}\n      onRestore={(id) => setCheckpoints((l) => l.map((c) => ({ ...c, current: c.id === id })))}\n    />\n  );\n}\n"
        },
        {
          "name": "CheckpointsEmpty",
          "title": "Empty",
          "kind": "state",
          "code": "import { Checkpoints } from \"@kura-ui/react/checkpoints\";\n\nexport function CheckpointsEmpty() {\n  return <Checkpoints className=\"w-full max-w-md\" checkpoints={[]} />;\n}\n"
        },
        {
          "name": "CheckpointsComposition",
          "title": "With run-header",
          "description": "Restoring rewinds the run's step count and pauses it; Resume returns to the latest checkpoint.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\nimport { type Checkpoint, Checkpoints } from \"@kura-ui/react/checkpoints\";\n\nconst sec = (s: number) => Date.now() - s * 1000;\n\nfunction storyline(): Checkpoint[] {\n  return [\n    {\n      id: \"tests\",\n      label: \"Updated login and session tests\",\n      at: sec(150),\n      filesChanged: 3,\n      kind: \"auto\",\n      current: true,\n    },\n    { id: \"mw\", label: \"Patched src/middleware.ts\", at: sec(290), filesChanged: 1, kind: \"auto\" },\n    { id: \"manual\", label: \"Before middleware changes\", at: sec(370), kind: \"manual\" },\n    {\n      id: \"client\",\n      label: \"Replaced fetchUser() with auth.getSession()\",\n      at: sec(430),\n      filesChanged: 9,\n      kind: \"auto\",\n    },\n    { id: \"start\", label: \"Run started on kura/auth-client\", at: sec(720), kind: \"auto\" },\n  ];\n}\n\nconst STEP_OF: Record<string, number> = { tests: 5, mw: 4, manual: 3, client: 3, start: 0 };\n\nexport function CheckpointsComposition() {\n  const [startedAt] = React.useState(() => sec(744));\n  const [checkpoints, setCheckpoints] = React.useState<Checkpoint[]>(storyline);\n  const current = checkpoints.find((c) => c.current)?.id ?? \"tests\";\n  const rewound = current !== \"tests\";\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-2\">\n      <RunHeader\n        variant=\"bar\"\n        title=\"Migrate session API to @acme/auth\"\n        status={rewound ? \"paused\" : \"running\"}\n        startedAt={startedAt}\n        progress={{ done: STEP_OF[current] ?? 0, total: 7 }}\n        cost={1.46}\n        budget={5}\n        onPause={() => {}}\n        onResume={() => setCheckpoints((l) => l.map((c) => ({ ...c, current: c.id === \"tests\" })))}\n        onStop={() => {}}\n        shortcuts={false}\n      />\n      <Checkpoints\n        checkpoints={checkpoints}\n        onRestore={(id) => setCheckpoints((l) => l.map((c) => ({ ...c, current: c.id === id })))}\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass checkpoints newest first and mark exactly one current; the component uses `at` to work out which are undone.",
        "Restore only rewinds files in your harness; keep the conversation and say so in your UI copy if that differs.",
        "Make onRestore async when restoring takes time so the confirm shows a spinner and blocks double restores."
      ],
      "title": "Checkpoints",
      "importPath": "@kura-ui/react/checkpoints",
      "exports": [
        "Checkpoint",
        "Checkpoints",
        "CheckpointsProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/checkpoints",
      "markdownUrl": "https://kuro.abstergo.fyi/components/checkpoints.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/checkpoints.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/checkpoints.json",
        "cli": "npx @kura-ui/cli add checkpoints",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "checkpoints": {
            "description": "{ id, label, at, filesChanged?, kind: 'auto' | 'manual', current? }, newest first. Mark exactly one current.",
            "x-ts-type": "Checkpoint[]",
            "type": "array"
          },
          "title": {
            "description": "Header title; the count follows it.",
            "x-ts-type": "React.ReactNode",
            "default": "Checkpoints"
          },
          "onRestore": {
            "description": "Restore handler, called after the inline confirm. Return a promise to show a spinner until it settles. Without it no Restore buttons render.",
            "x-ts-type": "(id: string) => void | Promise<void>"
          },
          "onCreate": {
            "description": "Shows 'Save checkpoint' in the header. Add a kind:'manual' checkpoint marked current.",
            "x-ts-type": "() => void"
          },
          "confirm": {
            "description": "Ask inline before restoring. false calls onRestore directly.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "empty": {
            "description": "Replaces the default empty state ('No checkpoints yet').",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "checkpoints"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/checkpoints/checkpoints.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Plus, RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/kura/button\";\n\nexport interface Checkpoint {\n  /** Stable id passed to `onRestore`. */\n  id: string;\n  /** What the run had just done, e.g. \"Patched src/middleware.ts\". */\n  label: string;\n  /** When the checkpoint was taken. */\n  at: Date | number;\n  /** Files changed since the previous checkpoint. */\n  filesChanged?: number;\n  /** `auto` = taken by the harness before edits; `manual` = saved by a person. */\n  kind: \"auto\" | \"manual\";\n  /** The workspace is at this checkpoint. Checkpoints newer than it are shown as undone. */\n  current?: boolean;\n}\n\nexport interface CheckpointsProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> {\n  /** Restore points, newest first. */\n  checkpoints: Checkpoint[];\n  /** Panel title. Default \"Checkpoints\". */\n  title?: React.ReactNode;\n  /** Called after the inline confirm. Return a promise to show a pending state until it settles. */\n  onRestore?: (id: string) => void | Promise<void>;\n  /** Shows a \"Save\" button in the header that creates a manual checkpoint. */\n  onCreate?: () => void;\n  /** Ask \"Restore to …?\" inline before calling `onRestore`. Default true. */\n  confirm?: boolean;\n  /** Shown when `checkpoints` is empty. */\n  empty?: React.ReactNode;\n}\n\n/** Wall clock, ticking every 30s; null until mounted so SSR markup is stable. */\nfunction useNow(interval = 30_000) {\n  const [now, setNow] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    setNow(Date.now());\n    const id = setInterval(() => setNow(Date.now()), interval);\n    return () => clearInterval(id);\n  }, [interval]);\n  return now;\n}\n\nfunction formatAgo(ms: number): string {\n  const s = Math.max(0, Math.round(ms / 1000));\n  if (s < 45) return \"just now\";\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m}m ago`;\n  const h = Math.floor(m / 60);\n  if (h < 24) return `${h}h ago`;\n  return `${Math.floor(h / 24)}d ago`;\n}\n\n/**\n * Restore points across an agent run, newest first: what the agent had just done, how many\n * files changed, when, and whether it was automatic or saved by hand. \"Restore\" opens an inline\n * confirm (no modal) that says what will be undone. The current position is marked; checkpoints\n * after it read as undone and can be restored again.\n */\nexport function Checkpoints({\n  checkpoints,\n  title = \"Checkpoints\",\n  onRestore,\n  onCreate,\n  confirm = true,\n  empty,\n  className,\n  ...props\n}: CheckpointsProps) {\n  const id = React.useId();\n  const now = useNow();\n  const [confirmId, setConfirmId] = React.useState<string | null>(null);\n  const [pendingId, setPendingId] = React.useState<string | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n  const triggers = React.useRef(new Map<string, HTMLButtonElement>());\n\n  const current = checkpoints.find((c) => c.current);\n  const currentAt = current ? +new Date(current.at) : null;\n\n  const restore = async (c: Checkpoint) => {\n    setPendingId(c.id);\n    try {\n      await onRestore?.(c.id);\n      setMessage(`Restored to “${c.label}”`);\n    } finally {\n      setPendingId(null);\n      setConfirmId(null);\n      triggers.current.get(c.id)?.focus();\n    }\n  };\n\n  const cancel = (c: Checkpoint) => {\n    setConfirmId(null);\n    requestAnimationFrame(() => triggers.current.get(c.id)?.focus());\n  };\n\n  return (\n    <div\n      data-slot=\"checkpoints\"\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"checkpoints-header\"\n        className=\"flex h-10 shrink-0 items-center gap-2 border-kura-border-subtle border-b pr-2 pl-3.5\"\n      >\n        <h3 id={`${id}-title`} className=\"flex items-baseline gap-2 font-medium text-[13px]\">\n          {title}\n          <span className=\"font-kura-mono font-normal text-[12px] text-kura-fg-subtle tabular-nums\">\n            {checkpoints.length}\n          </span>\n        </h3>\n        {onCreate && (\n          <Button\n            data-slot=\"checkpoints-create\"\n            variant=\"ghost\"\n            size=\"xs\"\n            className=\"ml-auto\"\n            onClick={onCreate}\n          >\n            <Plus />\n            Save checkpoint\n          </Button>\n        )}\n      </div>\n\n      {checkpoints.length === 0 ? (\n        <div\n          data-slot=\"checkpoints-empty\"\n          className=\"flex min-h-24 flex-col items-center justify-center gap-1 px-6 py-6 text-center\"\n        >\n          {empty ?? (\n            <>\n              <p className=\"font-medium text-[13px] text-kura-fg-muted\">No checkpoints yet</p>\n              <p className=\"text-[12px] text-kura-fg-subtle\">\n                One is saved automatically before the agent edits files.\n              </p>\n            </>\n          )}\n        </div>\n      ) : (\n        <ol aria-labelledby={`${id}-title`} className=\"flex flex-col py-1\">\n          {checkpoints.map((c, i) => {\n            const at = +new Date(c.at);\n            const ahead = currentAt != null && at > currentAt;\n            const open = confirmId === c.id;\n            // Checkpoints between this one and where the workspace is now.\n            const between = checkpoints.filter((o) => {\n              const t = +new Date(o.at);\n              if (ahead) return currentAt != null && t > currentAt && t <= at;\n              return t > at && (currentAt == null || t <= currentAt);\n            }).length;\n            const first = i === 0;\n            const last = i === checkpoints.length - 1;\n            return (\n              <li\n                key={c.id}\n                data-slot=\"checkpoints-item\"\n                data-kind={c.kind}\n                data-current={c.current || undefined}\n                data-ahead={ahead || undefined}\n                data-state={open ? \"confirming\" : undefined}\n                className=\"group/cp relative animate-[kura-rise-in_var(--kura-duration-base)_var(--ease-kura-out)_both] [--kura-rise-blur:0px] [--kura-rise-y:2px] motion-reduce:[--kura-rise-y:0px]\"\n              >\n                {checkpoints.length > 1 && (\n                  <span\n                    aria-hidden\n                    className={cn(\n                      \"absolute left-[21.5px] w-px bg-kura-border\",\n                      first ? \"top-[18px] bottom-0\" : last ? \"top-0 h-[18px]\" : \"inset-y-0\",\n                      ahead && \"bg-kura-border-subtle\",\n                    )}\n                  />\n                )}\n                <div\n                  className={cn(\n                    \"relative flex h-9 items-center gap-2.5 pr-2 pl-3.5 transition-colors duration-(--kura-duration-instant) ease-kura-out\",\n                    !c.current && \"hover:bg-kura-hover\",\n                    open && \"bg-kura-hover\",\n                  )}\n                >\n                  <span aria-hidden className=\"grid w-4 shrink-0 place-items-center\">\n                    <Node checkpoint={c} ahead={ahead} />\n                  </span>\n                  <span\n                    data-slot=\"checkpoints-label\"\n                    className={cn(\n                      \"min-w-0 truncate text-[13px]\",\n                      c.current && \"font-medium\",\n                      ahead && \"text-kura-fg-subtle\",\n                    )}\n                  >\n                    {c.label}\n                  </span>\n                  {c.kind === \"manual\" && (\n                    <span className=\"shrink-0 text-[12px] text-kura-fg-subtle\">Saved</span>\n                  )}\n                  {c.current && (\n                    <span\n                      data-slot=\"checkpoints-current\"\n                      className=\"inline-flex h-[18px] shrink-0 items-center rounded-full bg-kura-hover px-1.5 font-medium text-[11px] text-kura-fg-muted\"\n                    >\n                      Current\n                    </span>\n                  )}\n                  {ahead && <span className=\"sr-only\">(undone)</span>}\n                  <span className=\"ml-auto flex shrink-0 items-center gap-3 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n                    {c.filesChanged != null && (\n                      <span data-slot=\"checkpoints-files\" className=\"hidden @sm:inline\">\n                        {c.filesChanged} {c.filesChanged === 1 ? \"file\" : \"files\"}\n                      </span>\n                    )}\n                    <span className=\"grid w-16 justify-items-end [grid-template-areas:'cell'] @sm:w-[5.5rem]\">\n                      <time\n                        dateTime={now != null ? new Date(at).toISOString() : undefined}\n                        title={now != null ? new Date(at).toLocaleString() : undefined}\n                        className={cn(\n                          \"min-w-[7ch] text-right [grid-area:cell]\",\n                          !c.current &&\n                            onRestore &&\n                            !open &&\n                            \"group-hover/cp:opacity-0 group-focus-within/cp:opacity-0 [@media(hover:none)]:opacity-0\",\n                        )}\n                      >\n                        {now != null ? formatAgo(now - at) : \"\"}\n                      </time>\n                      {!c.current && onRestore && (\n                        <Button\n                          ref={(el) => {\n                            if (el) triggers.current.set(c.id, el);\n                            else triggers.current.delete(c.id);\n                          }}\n                          data-slot=\"checkpoints-restore\"\n                          aria-label={`Restore to ${c.label}`}\n                          variant=\"ghost\"\n                          size=\"xs\"\n                          aria-expanded={confirm ? open : undefined}\n                          aria-controls={confirm ? `${id}-confirm-${c.id}` : undefined}\n                          disabled={pendingId != null}\n                          onClick={() => (confirm ? setConfirmId(open ? null : c.id) : restore(c))}\n                          className={cn(\n                            \"-my-1 font-kura-sans opacity-0 [grid-area:cell] focus-visible:opacity-100\",\n                            !open && \"group-hover/cp:opacity-100 [@media(hover:none)]:opacity-100\",\n                          )}\n                        >\n                          <RotateCcw />\n                          <span className=\"hidden @sm:inline\">Restore</span>\n                        </Button>\n                      )}\n                    </span>\n                  </span>\n                </div>\n                {confirm && !c.current && onRestore && (\n                  <div\n                    id={`${id}-confirm-${c.id}`}\n                    data-slot=\"checkpoints-confirm\"\n                    inert={!open || undefined}\n                    className={cn(\n                      \"grid transition-[grid-template-rows,opacity] duration-(--kura-duration-base) 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                      {open && (\n                        <ConfirmBody\n                          checkpoint={c}\n                          between={between}\n                          forward={ahead}\n                          pending={pendingId === c.id}\n                          onCancel={() => cancel(c)}\n                          onConfirm={() => restore(c)}\n                        />\n                      )}\n                    </div>\n                  </div>\n                )}\n              </li>\n            );\n          })}\n        </ol>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nfunction Node({ checkpoint, ahead }: { checkpoint: Checkpoint; ahead: boolean }) {\n  if (checkpoint.current) {\n    return (\n      <span className=\"size-2.5 rounded-full bg-kura-fg shadow-[0_0_0_3px_var(--kura-surface)]\" />\n    );\n  }\n  if (checkpoint.kind === \"manual\") {\n    return (\n      <span\n        className={cn(\n          \"size-2 rotate-45 rounded-[2px]\",\n          ahead ? \"bg-kura-border-strong\" : \"bg-kura-fg-muted\",\n        )}\n      />\n    );\n  }\n  return (\n    <span\n      className={cn(\n        \"size-2 rounded-full bg-kura-surface\",\n        ahead ? \"ring-1 ring-kura-border-strong\" : \"ring-[1.5px] ring-kura-fg-subtle\",\n      )}\n    />\n  );\n}\n\nfunction ConfirmBody({\n  checkpoint,\n  between,\n  forward,\n  pending,\n  onCancel,\n  onConfirm,\n}: {\n  checkpoint: Checkpoint;\n  between: number;\n  forward: boolean;\n  pending: boolean;\n  onCancel: () => void;\n  onConfirm: () => void;\n}) {\n  const confirmRef = React.useRef<HTMLButtonElement>(null);\n  React.useEffect(() => {\n    confirmRef.current?.focus({ preventScroll: true });\n  }, []);\n  return (\n    <fieldset\n      aria-label={`Restore to ${checkpoint.label}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") {\n          e.stopPropagation();\n          onCancel();\n        }\n      }}\n      className=\"m-0 flex min-w-0 flex-col gap-2.5 border-0 pt-1 pr-3.5 pb-3 pl-10\"\n    >\n      <p className=\"text-[12.5px] text-kura-fg-muted leading-[18px]\">\n        {forward ? (\n          <>\n            Restore files forward to this point? Re-applies edits from {between}{\" \"}\n            {between === 1 ? \"checkpoint\" : \"checkpoints\"}.\n          </>\n        ) : (\n          <>\n            Restore files to this point? Edits from{\" \"}\n            {between > 0\n              ? `${between} later ${between === 1 ? \"checkpoint\" : \"checkpoints\"}`\n              : \"later steps\"}{\" \"}\n            are undone. The conversation is kept, and you can restore forward again.\n          </>\n        )}\n      </p>\n      <div className=\"flex items-center justify-end gap-1.5\">\n        <Button variant=\"ghost\" size=\"xs\" onClick={onCancel} disabled={pending}>\n          Cancel\n        </Button>\n        <Button ref={confirmRef} variant=\"primary\" size=\"xs\" loading={pending} onClick={onConfirm}>\n          <RotateCcw />\n          Restore\n        </Button>\n      </div>\n    </fieldset>\n  );\n}\n"
        },
        {
          "target": "components/kura/checkpoints/index.ts",
          "type": "registry:ui",
          "content": "export { type Checkpoint, Checkpoints, type CheckpointsProps } from \"./checkpoints\";\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: \"AbortController – Web APIs\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/API/AbortController\",\n    siteName: \"MDN\",\n    snippet:\n      \"The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.\",\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: \"AbortController – Web APIs\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/API/AbortController\",\n    siteName: \"MDN\",\n    snippet:\n      \"The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.\",\n  },\n];\n\nexport function CitationFull() {\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-4\">\n      <p className=\"text-[15px] text-kura-fg leading-relaxed\">\n        Render the new count immediately with{\" \"}\n        <code className=\"rounded-kura-xs bg-kura-active px-[0.3em] py-[0.1em] font-kura-mono text-[0.86em]\">\n          useOptimistic\n        </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 abort the stale request if\n        the reader taps again\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: \"AbortController – Web APIs\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/API/AbortController\",\n    siteName: \"MDN\",\n    snippet:\n      \"The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.\",\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: \"AbortController – Web APIs\",\n    url: \"https://developer.mozilla.org/en-US/docs/Web/API/AbortController\",\n    siteName: \"MDN\",\n    snippet:\n      \"The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.\",\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-44 flex-col gap-1.5 rounded-kura-lg bg-kura-surface px-3 py-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=\"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=\"tabular-nums\">{i + 1}</span>\n                  </span>\n                  <span className=\"line-clamp-2 min-h-[2lh] font-medium text-[13px] text-kura-fg leading-snug\">\n                    {source.title}\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.",
          "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  return (\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      header\n      className=\"w-full max-w-xl\"\n    />\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).",
        "The default palette is deliberately quiet: grey value carries structure (keywords/attributes fg-muted, punctuation/comments fg-subtle) and only strings, numbers and types get a low-chroma tint. Theme it with CSS variables on any ancestor: --kura-syntax-keyword, -string, -constant, -function, -type, -tag, -attr, -property, -comment, -punctuation.",
        "`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-code-comment)\",\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-code-punctuation)\",\n  operator: \"text-(--kura-code-punctuation)\",\n};\n\n/*\n * A quiet, mostly-neutral palette. Structure is carried by grey value (keywords and attributes\n * recede to fg-muted, punctuation and comments to fg-subtle, names stay fg); only literals get\n * a low-chroma tint, derived from --kura-fg-muted so they keep its ≥ 4.5:1 contrast and flip with\n * the theme. Override any of them with --kura-syntax-* on a parent.\n */\nconst tint = (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\": \"var(--kura-syntax-keyword, var(--kura-fg-muted))\",\n  \"--kura-code-string\": tint(\"string\", 0.075, 150),\n  \"--kura-code-constant\": tint(\"constant\", 0.085, 55),\n  \"--kura-code-function\": \"var(--kura-syntax-function, var(--kura-fg))\",\n  \"--kura-code-type\": tint(\"type\", 0.045, 240),\n  \"--kura-code-tag\": \"var(--kura-syntax-tag, var(--kura-fg))\",\n  \"--kura-code-attr\": \"var(--kura-syntax-attr, var(--kura-fg-muted))\",\n  \"--kura-code-property\": \"var(--kura-syntax-property, var(--kura-fg))\",\n  \"--kura-code-comment\": \"var(--kura-syntax-comment, var(--kura-fg-subtle))\",\n  \"--kura-code-punctuation\": \"var(--kura-syntax-punctuation, var(--kura-fg-subtle))\",\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.5 font-kura-mono text-[11px] text-kura-fg-subtle\"\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 (opaque raised surface over a dimming scrim, 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          // A scrim that dims in both themes: ink at 14% on light, canvas at 70% on dark (via the\n          // color-scheme the tokens set), so dark mode darkens instead of washing out to grey.\n          className=\"fixed inset-0 z-50 bg-[light-dark(color-mix(in_oklch,var(--kura-gray-12)_14%,transparent),color-mix(in_oklch,var(--kura-canvas)_72%,transparent))] 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-surface-raised font-kura-sans text-kura-fg shadow-kura-lg outline-none\",\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 type { AttachmentItem } from \"@kura-ui/react/attachment\";\nimport { KbdShortcut } from \"@kura-ui/react/kbd-shortcut\";\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 aria-pressed:shadow-[inset_0_0_0_1px_var(--kura-border-subtle)] [&_svg]:size-3.5\"\n    >\n      {icon}\n      {children}\n    </button>\n  );\n}\n\nconst boardFiles: AttachmentItem[] = [\n  { id: \"deck\", name: \"q3-board-deck.pdf\", type: \"application/pdf\", size: 4_820_000 },\n  { id: \"cohorts\", name: \"churn-by-cohort.csv\", type: \"text/csv\", size: 312_000 },\n];\n\nexport function ComposerFull() {\n  const [value, setValue] = React.useState(\n    \"Check the churn figures on slide 7 against the cohort CSV and flag anything that doesn’t reconcile.\",\n  );\n  const [status, setStatus] = React.useState<ComposerStatus>(\"idle\");\n  const [model, setModel] = React.useState(\"sonnet\");\n  const [search, setSearch] = React.useState(false);\n  const [think, setThink] = React.useState(true);\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-2.5\">\n      <Composer\n        value={value}\n        onValueChange={setValue}\n        defaultAttachments={boardFiles}\n        status={status}\n        maxLength={4000}\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\n          placeholder=\"Ask about these files, or drop more…\"\n          minRows={2}\n          maxRows={10}\n        />\n        <ComposerToolbar>\n          <ComposerTools>\n            <ComposerAttachButton />\n            <ModelPicker models={models} value={model} onValueChange={setModel} side=\"top\" />\n            <span aria-hidden className=\"mx-0.5 h-4 w-px bg-kura-border\" />\n            <ToolToggle pressed={think} onPressedChange={setThink} icon={<Lightbulb />}>\n              Think\n            </ToolToggle>\n            <ToolToggle pressed={search} onPressedChange={setSearch} icon={<Globe />}>\n              Search\n            </ToolToggle>\n          </ComposerTools>\n          <ComposerActions>\n            <ComposerHint showAt={0.8} />\n            <ComposerSubmit />\n          </ComposerActions>\n        </ComposerToolbar>\n      </Composer>\n      <div className=\"flex min-h-5 items-center justify-between gap-3 px-1.5\">\n        <p className=\"truncate text-kura-fg-subtle text-xs\" aria-live=\"polite\">\n          {status === \"submitted\"\n            ? \"Sending…\"\n            : status === \"streaming\"\n              ? \"Responding · Esc to stop\"\n              : last\n                ? `Sent with ${last.attachments.length} ${last.attachments.length === 1 ? \"file\" : \"files\"}`\n                : \"Kura reads attachments in full; tables over 50k rows are sampled.\"}\n        </p>\n        <div className=\"hidden shrink-0 items-center gap-3 sm:flex\">\n          <KbdShortcut keys={[\"enter\"]} label=\"Send\" variant=\"ghost\" />\n          <KbdShortcut keys={[\"shift\", \"enter\"]} label=\"New line\" variant=\"ghost\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ComposerStates",
          "title": "Submitted, streaming, over limit and disabled",
          "kind": "state",
          "code": "import * as React from \"react\";\nimport { Composer, ComposerActions, ComposerAttachButton, ComposerHint, ComposerInput, ComposerSubmit, ComposerToolbar, ComposerTools } from \"@kura-ui/react/composer\";\n\nfunction StateLabel({ children }: { children: React.ReactNode }) {\n  return <p className=\"px-1.5 pb-1.5 font-medium text-kura-fg-subtle text-xs\">{children}</p>;\n}\n\nexport function ComposerStates() {\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-5\">\n      <div>\n        <StateLabel>Submitted — waiting for the first token</StateLabel>\n        <Composer\n          defaultValue=\"Summarise the attached incident report for the #eng-oncall channel.\"\n          status=\"submitted\"\n        >\n          <ComposerInput />\n          <ComposerToolbar>\n            <ComposerTools>\n              <ComposerAttachButton />\n            </ComposerTools>\n            <ComposerActions>\n              <ComposerSubmit />\n            </ComposerActions>\n          </ComposerToolbar>\n        </Composer>\n      </div>\n      <div>\n        <StateLabel>Streaming, close to the character limit</StateLabel>\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      </div>\n      <div>\n        <StateLabel>Disabled</StateLabel>\n        <Composer\n          disabled\n          placeholder=\"You’ve reached this week’s Opus limit. Resets Monday at 09:00.\"\n        />\n      </div>\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-[-0.02em]\">\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-raised font-kura-sans text-kura-fg shadow-kura-md\",\n          \"transition-[box-shadow,opacity] duration-(--kura-duration-base) ease-kura-out\",\n          // Rest: hairline + soft lift. Hover firms the hairline. Focus: firm hairline plus a quiet\n          // neutral halo — blue stays reserved for the model's own state.\n          \"hover:shadow-[0_0_0_1px_var(--kura-border-strong),var(--kura-shadow-md)]\",\n          \"data-[focused=true]:shadow-[0_0_0_1px_var(--kura-border-strong),0_0_0_4px_color-mix(in_oklch,var(--kura-gray-12)_6%,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 text-kura-fg-muted\", 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 turns inside the button while waiting for\n * the 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-full 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 hover:bg-kura-ink-hover\"\n          : \"bg-kura-gray-4 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-[2px] 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 32 32\"\n          fill=\"none\"\n          className=\"pointer-events-none absolute inset-0 size-full animate-kura-spin motion-reduce:animate-kura-pulse\"\n        >\n          {/* a quarter arc turning just inside the button's edge while waiting for the first token */}\n          <circle\n            cx=\"16\"\n            cy=\"16\"\n            r=\"13\"\n            stroke=\"currentColor\"\n            strokeOpacity=\"0.45\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            strokeDasharray=\"20 62\"\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-surface-raised/95\",\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=\"13.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": "Thread header with a breakdown",
          "description": "A ring in the thread header and a bar with a footer note, both with the segment breakdown on hover. Attach logs to watch the numbers roll into warning and danger.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { Paperclip } 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 [logs, setLogs] = React.useState(0);\n  const segments = BASE.map((s) =>\n    s.id === \"files\" ? { ...s, tokens: s.tokens + logs * 24_000 } : s,\n  );\n  return (\n    <div className=\"flex w-full max-w-md flex-col rounded-kura-xl bg-kura-surface shadow-kura-sm\">\n      <div className=\"flex items-center justify-between gap-3 px-4 py-3\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <span className=\"truncate font-medium text-kura-fg text-sm tracking-[-0.01em]\">\n            Auth middleware refactor\n          </span>\n          <span className=\"text-kura-fg-subtle text-xs\">32 turns · 200k context window</span>\n        </div>\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      </div>\n      <div className=\"flex items-center justify-between gap-3 px-4 py-3 shadow-[inset_0_1px_0_var(--kura-border-subtle)]\">\n        <ContextMeter\n          max={200_000}\n          segments={segments}\n          variant=\"bar\"\n          valueDisplay=\"tokens\"\n          className=\"-ml-1.5\"\n          footer=\"Files count once per turn; tool definitions are cached.\"\n        />\n        <div className=\"flex items-center gap-1\">\n          {logs > 0 && (\n            <Button variant=\"ghost\" size=\"xs\" onClick={() => setLogs(0)}>\n              Clear\n            </Button>\n          )}\n          <Button variant=\"secondary\" size=\"xs\" onClick={() => setLogs((n) => n + 1)}>\n            <Paperclip /> Attach server.log\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "ContextMeterThresholds",
          "title": "Thresholds",
          "description": "ok, warning and danger bars in a list of threads, each labelled by its thread.",
          "kind": "state",
          "code": "import { ContextMeter } from \"@kura-ui/react/context-meter\";\n\nconst THREADS = [\n  { title: \"Onboarding emails\", used: 52_000 },\n  { title: \"Q3 data migration\", used: 98_500 },\n  { title: \"Incident review: checkout\", used: 121_900 },\n];\n\nexport function ContextMeterThresholds() {\n  return (\n    <ul className=\"flex w-full max-w-md flex-col\">\n      {THREADS.map((t) => (\n        <li\n          key={t.title}\n          className=\"flex h-10 items-center justify-between gap-4 text-[13px] text-kura-fg shadow-[inset_0_-1px_0_var(--kura-border-subtle)] last:shadow-none\"\n        >\n          <span className=\"min-w-0 truncate\">{t.title}</span>\n          <ContextMeter max={128_000} used={t.used} variant=\"bar\" label={t.title} />\n        </li>\n      ))}\n    </ul>\n  );\n}\n"
        },
        {
          "name": "ContextMeterInComposer",
          "title": "In a composer footer",
          "description": "Next to the attach and send IconButtons of a composer.",
          "kind": "composition",
          "code": "import { ArrowUp, Paperclip } 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        <div className=\"flex items-center gap-1\">\n          <IconButton label=\"Attach files\" size=\"sm\" icon={<Paperclip />} />\n          <ContextMeter max={200_000} segments={BASE} />\n        </div>\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": "diff-view",
      "name": "DiffView",
      "category": "workspace",
      "status": "experimental",
      "description": "Code review for agent edits: a parsed unified diff with syntax and word highlights, line numbers, and per-hunk Accept / Reject with j/k/a/r keys.",
      "whenToUse": [
        "Let a user review the edits a coding agent made (Claude Code, Codex, Cursor-style) before applying or committing them.",
        "Show `git diff` / `git show` output or a model-written patch as a readable, highlighted diff.",
        "Per-change approval: accept or reject individual hunks, a whole file, or everything, and act on the resulting decisions.",
        "A read-only diff of what changed in a run (reviewable={false})."
      ],
      "whenNotToUse": [
        "Listing which files changed without showing contents — use file-changes (it composes well beside DiffView).",
        "Showing a single code snippet or a whole file — use code-block.",
        "Command or test output — use terminal.",
        "Approving a tool call or command before it runs — use approval-prompt."
      ],
      "props": [
        {
          "name": "diff",
          "type": "string",
          "description": "A unified diff (`git diff`, `git show`, `diff -u`, a model's patch). Parsed with parseUnifiedDiff: renames, new/deleted/binary files, quoted paths, CRLF, missing counts, `\\ No newline at end of file`, and wrong hunk counts are handled. Ignored when `files` is given."
        },
        {
          "name": "files",
          "type": "readonly DiffFileInput[]",
          "description": "Structured files `{ path, oldPath?, status?, language?, binary?, hunks: [{ id?, oldStart?, newStart?, section?, lines }] }`. `lines` may be DiffLine objects or raw strings (\"+added\", \"-removed\", \" context\"); line numbers are computed."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Changes\"",
          "description": "Header title."
        },
        {
          "name": "reviewable",
          "type": "boolean",
          "default": "true",
          "description": "Show per-hunk Accept / Reject, per-file Accept file, Accept all / Reject all and the “2 of 5 reviewed” tally."
        },
        {
          "name": "review",
          "type": "DiffReview",
          "description": "Decisions (controlled): `{ [hunkId]: \"accepted\" | \"rejected\" }`; absent = pending. Hunk ids are `hunk.id` or `<path>#<index>` (see hunkId())."
        },
        {
          "name": "defaultReview",
          "type": "DiffReview",
          "default": "{}",
          "description": "Initial decisions when uncontrolled."
        },
        {
          "name": "onReviewChange",
          "type": "(review: DiffReview, change: DiffReviewChange) => void",
          "description": "Called with the full next review state and `{ path, hunkIds, decision }` where decision is \"accepted\", \"rejected\" or \"pending\" (undo)."
        },
        {
          "name": "collapsed",
          "type": "readonly string[]",
          "description": "Collapsed file paths (controlled)."
        },
        {
          "name": "defaultCollapsed",
          "type": "readonly string[]",
          "default": "[]",
          "description": "Initially collapsed file paths when uncontrolled."
        },
        {
          "name": "onCollapsedChange",
          "type": "(collapsed: string[]) => void",
          "description": "Called when a file is collapsed or expanded (by click or by collapseReviewed)."
        },
        {
          "name": "collapseReviewed",
          "type": "boolean",
          "default": "true",
          "description": "Collapse a file once every hunk in it has a decision (like GitHub's “Viewed”)."
        },
        {
          "name": "autoAdvance",
          "type": "boolean",
          "default": "true",
          "description": "After a decision, move focus to the next pending hunk (Cursor-style review flow)."
        },
        {
          "name": "wrap",
          "type": "boolean",
          "default": "false",
          "description": "Soft-wrap long lines instead of scrolling each hunk sideways."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "description": "Scroll the files inside this height (px). File headers stay pinned while scrolling (also against page scroll without it)."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra controls at the end of the header, e.g. a Commit button."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Rendered when there are no files. Default an icon + “No changes”."
        }
      ],
      "parts": [
        {
          "name": "DiffView",
          "description": "`data-state=\"empty\" | \"pending\" | \"reviewed\"`, `data-reviewable`. A container query hides secondary header items below ~26–34rem.",
          "dataSlot": "diff-view"
        },
        {
          "name": "Header",
          "description": "Title, mono summary (`diff-view-summary`: files, +N −M), review progress (`diff-view-progress`) and Accept all / Reject all.",
          "dataSlot": "diff-view-header"
        },
        {
          "name": "File",
          "description": "`<section aria-label={path}>` with `data-status=\"added\" | \"modified\" | \"deleted\" | \"renamed\"` and `data-state=\"open\" | \"closed\"`.",
          "dataSlot": "diff-view-file"
        },
        {
          "name": "FileHeader",
          "description": "Sticky row: collapse toggle (button, aria-expanded), path (`diff-view-path`: dim directory + bold name, old → new for renames), status chip, +N −M, 5-block ratio bar (`diff-view-ratio`), Accept file / review state (`diff-view-file-review`).",
          "dataSlot": "diff-view-file-header"
        },
        {
          "name": "Hunk",
          "description": "`role=\"group\"`, roving tabindex, `data-hunk-id`, `data-review=\"pending\" | \"accepted\" | \"rejected\"`. Accent bar on the left when focused.",
          "dataSlot": "diff-view-hunk"
        },
        {
          "name": "HunkHeader",
          "description": "`@@ −a,b +c,d @@` + section heading, and Reject / Accept (`diff-view-hunk-actions`) or the decision (`diff-view-hunk-state`) with Undo.",
          "dataSlot": "diff-view-hunk-header"
        },
        {
          "name": "Line",
          "description": "`data-kind=\"context\" | \"add\" | \"remove\"`: old and new line numbers, sign, highlighted code with word-level change marks.",
          "dataSlot": "diff-view-line"
        }
      ],
      "a11y": {
        "notes": [
          "Each file is a labelled section; its header toggle is a button with aria-expanded/aria-controls.",
          "Hunks are focusable groups named “Change 2 of 9, src/auth/client.ts lines 10–19, pending review”; only the current hunk (and its buttons) is in the Tab order.",
          "Added/removed lines carry sr-only “added:” / “removed:” prefixes; colour is never the only signal (+/− signs, status chips, labels).",
          "Decisions are announced in a polite status region (“Accepted hunk in src/middleware.ts. 3 of 9 reviewed.”) — once per action, never per render.",
          "Accept / Reject tooltips show their single-key shortcuts."
        ],
        "keyboard": [
          {
            "keys": "J / K",
            "action": "Next / previous hunk (skips collapsed files)"
          },
          {
            "keys": "A",
            "action": "Accept the focused hunk, then move to the next pending one"
          },
          {
            "keys": "R",
            "action": "Reject the focused hunk, then move to the next pending one"
          },
          {
            "keys": "U / Backspace",
            "action": "Undo the focused hunk's decision"
          },
          {
            "keys": "Enter / Space",
            "action": "On a file header: collapse or expand the file"
          },
          {
            "keys": "Tab",
            "action": "Through file headers and the focused hunk's buttons"
          }
        ],
        "reducedMotion": "Chevron rotation, the review-dimming fade and the progress colour change apply instantly; no motion is essential."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button",
          "code-block",
          "icon-button",
          "tooltip"
        ]
      },
      "related": [
        "file-changes",
        "code-block",
        "terminal",
        "approval-prompt",
        "tool-call"
      ],
      "tags": [
        "diff",
        "code-review",
        "patch",
        "git",
        "accept-reject",
        "agent",
        "syntax-highlighting",
        "workspace"
      ],
      "examples": [
        {
          "name": "DiffViewMinimal",
          "title": "Basic",
          "description": "One file from a unified diff string, with per-hunk review.",
          "kind": "minimal",
          "code": "import { DiffView } from \"@kura-ui/react/diff-view\";\n\nconst MIDDLEWARE_DIFF = `diff --git a/src/middleware.ts b/src/middleware.ts\n--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -1,4 +1,4 @@\n import { NextResponse, type NextRequest } from \"next/server\";\n-import { readSessionCookie } from \"@/lib/legacy-session\";\n+import { auth } from \"@/auth/client\";\n \n const PUBLIC_PATHS = [\"/login\", \"/signup\", \"/api/health\"];\n@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {\n   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();\n \n-  const session = readSessionCookie(request.headers.get(\"cookie\"));\n+  const session = await auth.getSession({ headers: request.headers });\n   if (!session) {\n     const url = new URL(\"/login\", request.url);\n`;\n\nexport function DiffViewMinimal() {\n  return <DiffView diff={MIDDLEWARE_DIFF} className=\"w-full max-w-2xl\" />;\n}\n"
        },
        {
          "name": "DiffViewFull",
          "title": "Review session",
          "description": "The agent's whole migration (4 files, 9 hunks) with controlled review state, a scrolling body, pinned file headers and a keyboard legend. Try j, k, a, r, u.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Kbd } from \"@kura-ui/react/kbd\";\nimport { type DiffReview, DiffView } from \"@kura-ui/react/diff-view\";\n\nconst AUTH_MIGRATION_DIFF = `diff --git a/src/auth/client.ts b/src/auth/client.ts\nindex 887cccf..1c08d5a 100644\n--- a/src/auth/client.ts\n+++ b/src/auth/client.ts\n@@ -1,7 +1,8 @@\n import { cache } from \"react\";\n-import { fetchJson } from \"@/lib/http\";\n-import type { Session, User } from \"./types\";\n+import { createAuthClient, type Session, type User } from \"@acme/auth\";\n \n-const SESSION_URL = \"/api/v1/session\";\n-const REFRESH_URL = \"/api/v1/session/refresh\";\n+export const auth = createAuthClient({\n+  baseUrl: process.env.NEXT_PUBLIC_AUTH_URL,\n+  refresh: \"auto\",\n+});\n \n@@ -9,14 +10,10 @@ const REFRESH_URL = \"/api/v1/session/refresh\";\n export const fetchUser = cache(async (): Promise<User | null> => {\n-  const res = await fetchJson<Session>(SESSION_URL, { credentials: \"include\" });\n-  if (!res.ok) return null;\n-  return res.data.user;\n+  const session = await auth.getSession();\n+  return session?.user ?? null;\n });\n \n-export async function refreshSession(token: string): Promise<Session> {\n-  const res = await fetchJson<Session>(REFRESH_URL, {\n-    method: \"POST\",\n-    body: JSON.stringify({ token }),\n-  });\n-  if (!res.ok) throw new Error(\\`refresh failed: \\${res.status}\\`);\n-  return res.data;\n+export async function refreshSession(session: Session): Promise<Session> {\n+  const next = await auth.refresh(session.refreshToken);\n+  if (!next) throw new Error(\"refresh failed: session revoked\");\n+  return next;\n }\n@@ -24,3 +21,3 @@ export async function refreshSession(token: string): Promise<Session> {\n export function isExpired(session: Session, now = Date.now()): boolean {\n-  return session.expiresAt < now;\n+  return session.expiresAt <= now;\n }\n@@ -28,3 +25,3 @@ export function isExpired(session: Session, now = Date.now()): boolean {\n export async function signOut(): Promise<void> {\n-  await fetchJson(SESSION_URL, { method: \"DELETE\", credentials: \"include\" });\n+  await auth.signOut();\n }\ndiff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts\nindex 9fe0aaf..711a4bd 100644\n--- a/src/auth/__tests__/login.test.ts\n+++ b/src/auth/__tests__/login.test.ts\n@@ -1,4 +1,4 @@\n import { describe, expect, it, vi } from \"vitest\";\n-import { refreshSession, isExpired } from \"../client\";\n+import { auth, isExpired, refreshSession } from \"../client\";\n \n describe(\"login\", () => {\n@@ -8,8 +8,10 @@ describe(\"login\", () => {\n \n   it(\"refreshes an expired session\", async () => {\n-    vi.spyOn(globalThis, \"fetch\").mockResolvedValue(\n-      new Response(JSON.stringify({ expiresAt: 10, user: { id: \"u_1\" } })),\n-    );\n-    const next = await refreshSession(\"tok_expired\");\n+    vi.spyOn(auth, \"refresh\").mockResolvedValue({\n+      expiresAt: 10,\n+      refreshToken: \"tok_next\",\n+      user: { id: \"u_1\" },\n+    } as never);\n+    const next = await refreshSession({ refreshToken: \"tok_expired\" } as never);\n     expect(next.user.id).toBe(\"u_1\");\n   });\ndiff --git a/src/lib/legacy-session.ts b/src/lib/legacy-session.ts\ndeleted file mode 100644\nindex f0ae86d..0000000\n--- a/src/lib/legacy-session.ts\n+++ /dev/null\n@@ -1,10 +0,0 @@\n-/** @deprecated Use \\`auth.getSession()\\` from @acme/auth. */\n-export function readSessionCookie(cookie: string | null) {\n-  const raw = cookie?.match(/acme_session=([^;]+)/)?.[1];\n-  if (!raw) return null;\n-  try {\n-    return JSON.parse(atob(raw)) as { userId: string; expiresAt: number };\n-  } catch {\n-    return null;\n-  }\n-}\ndiff --git a/src/middleware.ts b/src/middleware.ts\nindex 0e93409..7eda2e2 100644\n--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -1,4 +1,4 @@\n import { NextResponse, type NextRequest } from \"next/server\";\n-import { readSessionCookie } from \"@/lib/legacy-session\";\n+import { auth } from \"@/auth/client\";\n \n const PUBLIC_PATHS = [\"/login\", \"/signup\", \"/api/health\"];\n@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {\n   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();\n \n-  const session = readSessionCookie(request.headers.get(\"cookie\"));\n+  const session = await auth.getSession({ headers: request.headers });\n   if (!session) {\n     const url = new URL(\"/login\", request.url);\n`;\n\nexport function DiffViewFull() {\n  const [review, setReview] = React.useState<DiffReview>({\n    \"src/auth/client.ts#0\": \"accepted\",\n  });\n  const decisions = Object.values(review);\n  const accepted = decisions.filter((d) => d === \"accepted\").length;\n  const rejected = decisions.length - accepted;\n  return (\n    <div className=\"flex w-full max-w-3xl flex-col gap-3\">\n      <DiffView\n        title=\"Review changes\"\n        diff={AUTH_MIGRATION_DIFF}\n        review={review}\n        onReviewChange={setReview}\n        maxHeight={620}\n      />\n      <div className=\"flex items-center gap-3 px-1 text-[12px] text-kura-fg-subtle\">\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd size=\"sm\">J</Kbd>\n          <Kbd size=\"sm\">K</Kbd>\n          <span>move</span>\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd size=\"sm\">A</Kbd>\n          <span>accept</span>\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd size=\"sm\">R</Kbd>\n          <span>reject</span>\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd size=\"sm\">U</Kbd>\n          <span>undo</span>\n        </span>\n        <span className=\"ml-auto font-kura-mono tabular-nums\">\n          {accepted} accepted · {rejected} rejected\n        </span>\n      </div>\n    </div>\n  );\n}\n"
        },
        {
          "name": "DiffViewStructured",
          "title": "Structured files",
          "description": "Rename, new file and a binary file from structured input; read-only.",
          "kind": "variant",
          "code": "import { type DiffFileInput, DiffView } from \"@kura-ui/react/diff-view\";\n\nconst STRUCTURED_FILES: DiffFileInput[] = [\n  {\n    path: \"src/auth/session-store.ts\",\n    oldPath: \"src/auth/session.ts\",\n    status: \"renamed\",\n    hunks: [\n      {\n        oldStart: 3,\n        newStart: 3,\n        section: \"export class SessionStore\",\n        lines: [\n          \"   #ttl = 15 * 60_000;\",\n          \"-  read(key: string) {\",\n          \"+  read(key: string): Session | undefined {\",\n          \"     return this.#map.get(key);\",\n        ],\n      },\n    ],\n  },\n  {\n    path: \"src/auth/types.ts\",\n    status: \"added\",\n    hunks: [\n      {\n        newStart: 1,\n        lines: [\n          '+export type { Session, User } from \"@acme/auth\";',\n          \"+\",\n          '+export type AuthProvider = \"password\" | \"github\" | \"google\";',\n        ],\n      },\n    ],\n  },\n  { path: \"public/og-login.png\", status: \"modified\", binary: true, hunks: [] },\n];\n\nexport function DiffViewStructured() {\n  return (\n    <DiffView\n      title=\"Patch from test-writer\"\n      files={STRUCTURED_FILES}\n      reviewable={false}\n      className=\"w-full max-w-2xl\"\n    />\n  );\n}\n"
        },
        {
          "name": "DiffViewComposition",
          "title": "Apply accepted changes",
          "description": "A footer bar with a Button acts on the accepted hunks.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { type DiffReview, DiffView } from \"@kura-ui/react/diff-view\";\n\nconst MIDDLEWARE_DIFF = `diff --git a/src/middleware.ts b/src/middleware.ts\n--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -1,4 +1,4 @@\n import { NextResponse, type NextRequest } from \"next/server\";\n-import { readSessionCookie } from \"@/lib/legacy-session\";\n+import { auth } from \"@/auth/client\";\n \n const PUBLIC_PATHS = [\"/login\", \"/signup\", \"/api/health\"];\n@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {\n   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();\n \n-  const session = readSessionCookie(request.headers.get(\"cookie\"));\n+  const session = await auth.getSession({ headers: request.headers });\n   if (!session) {\n     const url = new URL(\"/login\", request.url);\n`;\n\nexport function DiffViewComposition() {\n  const [review, setReview] = React.useState<DiffReview>({});\n  const [applied, setApplied] = React.useState<number | null>(null);\n  const accepted = Object.values(review).filter((d) => d === \"accepted\").length;\n  return (\n    <div className=\"flex w-full max-w-3xl flex-col gap-3\">\n      <DiffView\n        title=\"kura/auth-client\"\n        diff={MIDDLEWARE_DIFF}\n        review={review}\n        onReviewChange={(next) => {\n          setReview(next);\n          setApplied(null);\n        }}\n      />\n      <div className=\"flex items-center justify-between gap-3 rounded-kura-xl bg-kura-surface py-2 pr-2 pl-3.5 shadow-kura-sm\">\n        <span className=\"text-[13px] text-kura-fg-muted\" role=\"status\">\n          {applied != null\n            ? `Applied ${applied} ${applied === 1 ? \"change\" : \"changes\"} to kura/auth-client`\n            : accepted > 0\n              ? `${accepted} accepted ${accepted === 1 ? \"change\" : \"changes\"} ready`\n              : \"Accept changes to apply them\"}\n        </span>\n        <Button size=\"sm\" disabled={accepted === 0} onClick={() => setApplied(accepted)}>\n          Apply changes\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass the raw `git diff` string to `diff`; don't pre-split it. Use `files` when you already have structured hunks (raw '+'/'-'/' ' strings are fine as lines).",
        "Review state is keyed by hunk id: `hunk.id` if you set one (do this when hunks can shift, e.g. while an agent keeps editing), else `<path>#<index>`. Export `hunkId(file, hunk, index)` to compute it.",
        "To apply decisions, walk parseUnifiedDiff(diff) and keep hunks whose id is \"accepted\"; rejected and pending hunks are not applied.",
        "Helpers exported alongside: parseUnifiedDiff, normalizeFiles, wordDiff, countChanges, languageFromPath.",
        "Syntax highlighting uses code-block's synchronous highlighter; the language is inferred from the file extension (override with files[].language).",
        "Very large diffs render every line; collapse big files with defaultCollapsed (e.g. lockfiles) to keep it fast."
      ],
      "title": "Diff View",
      "importPath": "@kura-ui/react/diff-view",
      "exports": [
        "DiffFile",
        "DiffFileInput",
        "DiffFileStatus",
        "DiffHunk",
        "DiffHunkInput",
        "DiffLine",
        "DiffLineKind",
        "DiffReview",
        "DiffReviewChange",
        "DiffView",
        "DiffViewProps",
        "HunkDecision",
        "countChanges",
        "hunkId",
        "languageFromPath",
        "normalizeFiles",
        "parseUnifiedDiff",
        "wordDiff"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/diff-view",
      "markdownUrl": "https://kuro.abstergo.fyi/components/diff-view.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/diff-view.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/diff-view.json",
        "cli": "npx @kura-ui/cli add diff-view",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "code-block",
        "icon-button",
        "tooltip",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "diff": {
            "description": "A unified diff (`git diff`, `git show`, `diff -u`, a model's patch). Parsed with parseUnifiedDiff: renames, new/deleted/binary files, quoted paths, CRLF, missing counts, `\\ No newline at end of file`, and wrong hunk counts are handled. Ignored when `files` is given.",
            "x-ts-type": "string",
            "type": "string"
          },
          "files": {
            "description": "Structured files `{ path, oldPath?, status?, language?, binary?, hunks: [{ id?, oldStart?, newStart?, section?, lines }] }`. `lines` may be DiffLine objects or raw strings (\"+added\", \"-removed\", \" context\"); line numbers are computed.",
            "x-ts-type": "readonly DiffFileInput[]",
            "type": "array"
          },
          "title": {
            "description": "Header title.",
            "x-ts-type": "React.ReactNode",
            "default": "Changes"
          },
          "reviewable": {
            "description": "Show per-hunk Accept / Reject, per-file Accept file, Accept all / Reject all and the “2 of 5 reviewed” tally.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "review": {
            "description": "Decisions (controlled): `{ [hunkId]: \"accepted\" | \"rejected\" }`; absent = pending. Hunk ids are `hunk.id` or `<path>#<index>` (see hunkId()).",
            "x-ts-type": "DiffReview"
          },
          "defaultReview": {
            "description": "Initial decisions when uncontrolled.",
            "x-ts-type": "DiffReview",
            "default": {}
          },
          "onReviewChange": {
            "description": "Called with the full next review state and `{ path, hunkIds, decision }` where decision is \"accepted\", \"rejected\" or \"pending\" (undo).",
            "x-ts-type": "(review: DiffReview, change: DiffReviewChange) => void"
          },
          "collapsed": {
            "description": "Collapsed file paths (controlled).",
            "x-ts-type": "readonly string[]",
            "type": "array"
          },
          "defaultCollapsed": {
            "description": "Initially collapsed file paths when uncontrolled.",
            "x-ts-type": "readonly string[]",
            "type": "array",
            "default": []
          },
          "onCollapsedChange": {
            "description": "Called when a file is collapsed or expanded (by click or by collapseReviewed).",
            "x-ts-type": "(collapsed: string[]) => void"
          },
          "collapseReviewed": {
            "description": "Collapse a file once every hunk in it has a decision (like GitHub's “Viewed”).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "autoAdvance": {
            "description": "After a decision, move focus to the next pending hunk (Cursor-style review flow).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "wrap": {
            "description": "Soft-wrap long lines instead of scrolling each hunk sideways.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "maxHeight": {
            "description": "Scroll the files inside this height (px). File headers stay pinned while scrolling (also against page scroll without it).",
            "x-ts-type": "number",
            "type": "number"
          },
          "actions": {
            "description": "Extra controls at the end of the header, e.g. a Commit button.",
            "x-ts-type": "React.ReactNode"
          },
          "empty": {
            "description": "Rendered when there are no files. Default an icon + “No changes”.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/diff-view/diff-view.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Check, ChevronRight, CircleSlash2, FileDiff, RotateCcw, X } 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 { type CodeToken, type CodeTokenType, highlightCode } from \"@/components/kura/code-block\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\nimport {\n  countChanges,\n  type DiffFile,\n  type DiffFileInput,\n  type DiffFileStatus,\n  type DiffHunk,\n  type DiffLine,\n  hunkId,\n  intraLineChanges,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  type Ranges,\n} from \"./parse-diff\";\n\nexport type HunkDecision = \"accepted\" | \"rejected\";\n/** Review state: hunk id → decision. Hunks that are absent are pending. */\nexport type DiffReview = Readonly<Record<string, HunkDecision>>;\n\nexport interface DiffReviewChange {\n  /** File the change happened in (the last file for \"Accept all\"). */\n  path: string;\n  /** Hunks whose decision changed. */\n  hunkIds: string[];\n  /** New decision; \"pending\" means the decision was undone. */\n  decision: HunkDecision | \"pending\";\n}\n\nexport interface DiffViewProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> {\n  /** A unified diff (`git diff`, `git show`, `diff -u`, or a model's patch). Ignored when `files` is given. */\n  diff?: string;\n  /** Structured files instead of a diff string. Hunk lines may be DiffLine objects or raw `\"+added\"` / `\"-removed\"` / `\" context\"` strings. */\n  files?: readonly DiffFileInput[];\n  /** Header title. Default \"Changes\". */\n  title?: React.ReactNode;\n  /** Show per-hunk Accept / Reject, per-file \"Accept file\", and the review tally. Default true. */\n  reviewable?: boolean;\n  /** Review decisions keyed by hunk id (controlled). Hunk ids default to `${path}#${index}`. */\n  review?: DiffReview;\n  /** Initial decisions when uncontrolled. Default {}. */\n  defaultReview?: DiffReview;\n  /** Called with the full next review state and what changed. */\n  onReviewChange?: (review: DiffReview, change: DiffReviewChange) => void;\n  /** Collapsed file paths (controlled). */\n  collapsed?: readonly string[];\n  /** Initially collapsed file paths when uncontrolled. Default []. */\n  defaultCollapsed?: readonly string[];\n  onCollapsedChange?: (collapsed: string[]) => void;\n  /** Collapse a file once every hunk in it has a decision. Default true. */\n  collapseReviewed?: boolean;\n  /** After a decision, move focus to the next pending hunk. Default true. */\n  autoAdvance?: boolean;\n  /** Soft-wrap long lines instead of scrolling sideways. Default false. */\n  wrap?: boolean;\n  /** Scroll the files inside this height (px); file headers stay pinned while scrolling. */\n  maxHeight?: number;\n  /** Extra controls at the end of the header (e.g. a \"Commit\" button). */\n  actions?: React.ReactNode;\n  /** Rendered when there are no files. Default \"No changes\". */\n  empty?: React.ReactNode;\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-code-comment)\",\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-code-punctuation)\",\n  operator: \"text-(--kura-code-punctuation)\",\n};\n\n// Same quiet palette as CodeBlock (override with --kura-syntax-* on a parent).\nconst tint = (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\": \"var(--kura-syntax-keyword, var(--kura-fg-muted))\",\n  \"--kura-code-string\": tint(\"string\", 0.075, 150),\n  \"--kura-code-constant\": tint(\"constant\", 0.085, 55),\n  \"--kura-code-function\": \"var(--kura-syntax-function, var(--kura-fg))\",\n  \"--kura-code-type\": tint(\"type\", 0.045, 240),\n  \"--kura-code-tag\": \"var(--kura-syntax-tag, var(--kura-fg))\",\n  \"--kura-code-attr\": \"var(--kura-syntax-attr, var(--kura-fg-muted))\",\n  \"--kura-code-property\": \"var(--kura-syntax-property, var(--kura-fg))\",\n  \"--kura-code-comment\": \"var(--kura-syntax-comment, var(--kura-fg-subtle))\",\n  \"--kura-code-punctuation\": \"var(--kura-syntax-punctuation, var(--kura-fg-subtle))\",\n} as React.CSSProperties;\n\ninterface PreparedHunk {\n  id: string;\n  index: number;\n  hunk: DiffHunk;\n  tokens: CodeToken[][];\n  words: Map<number, Ranges>;\n}\n\ninterface PreparedFile {\n  file: DiffFile;\n  additions: number;\n  deletions: number;\n  digits: number;\n  hunks: PreparedHunk[];\n}\n\n/** Highlight a hunk as two coherent texts (old side, new side) so multi-line strings and comments colour correctly. */\nfunction highlightHunk(lines: readonly DiffLine[], language: string): CodeToken[][] {\n  const oldIdx: number[] = [];\n  const newIdx: number[] = [];\n  lines.forEach((l, i) => {\n    if (l.kind !== \"add\") oldIdx.push(i);\n    if (l.kind !== \"remove\") newIdx.push(i);\n  });\n  const out: CodeToken[][] = lines.map(() => []);\n  const oldTokens = highlightCode(oldIdx.map((i) => lines[i]!.content).join(\"\\n\"), language);\n  const newTokens = highlightCode(newIdx.map((i) => lines[i]!.content).join(\"\\n\"), language);\n  newIdx.forEach((i, k) => {\n    out[i] = newTokens[k] ?? [];\n  });\n  oldIdx.forEach((i, k) => {\n    if (lines[i]!.kind === \"remove\") out[i] = oldTokens[k] ?? [];\n  });\n  return out;\n}\n\n/** Split syntax tokens at word-change boundaries. */\ninterface Segment extends CodeToken {\n  changed: boolean;\n  /** Segment starts / ends a changed range (for rounded ends on a continuous mark). */\n  first?: boolean;\n  last?: boolean;\n}\n\nfunction segment(tokens: CodeToken[], ranges: Ranges | undefined): Segment[] {\n  if (!ranges?.length) return tokens.map((t) => ({ ...t, changed: false }));\n  const out: Segment[] = [];\n  let pos = 0;\n  for (const t of tokens) {\n    let start = 0;\n    while (start < t.text.length) {\n      const abs = pos + start;\n      const inside = ranges.find(([a, b]) => abs >= a && abs < b);\n      const nextEdge = inside\n        ? inside[1]\n        : Math.min(...ranges.map(([a]) => a).filter((a) => a > abs), pos + t.text.length);\n      const end = Math.min(t.text.length, nextEdge - pos);\n      out.push({\n        type: t.type,\n        text: t.text.slice(start, end),\n        changed: !!inside,\n        first: !!inside && abs === inside[0],\n        last: !!inside && pos + end === inside[1],\n      });\n      start = end;\n    }\n    pos += t.text.length;\n  }\n  return out;\n}\n\nfunction splitPath(path: string): [string, string] {\n  const slash = path.lastIndexOf(\"/\");\n  return slash >= 0 ? [path.slice(0, slash + 1), path.slice(slash + 1)] : [\"\", path];\n}\n\nconst STATUS_LABEL: Record<DiffFileStatus, string> = {\n  added: \"Added\",\n  modified: \"Modified\",\n  deleted: \"Deleted\",\n  renamed: \"Renamed\",\n};\n\n/** GitHub-style 5-block bar: green for additions, red for deletions, grey for the rest. */\nfunction RatioBar({ additions, deletions }: { additions: number; deletions: number }) {\n  const total = additions + deletions;\n  let add = total ? Math.round((additions / total) * 5) : 0;\n  let del = total ? Math.round((deletions / total) * 5) : 0;\n  if (additions > 0 && add === 0) add = 1;\n  if (deletions > 0 && del === 0) del = 1;\n  while (add + del > 5) add > del ? add-- : del--;\n  return (\n    <span aria-hidden data-slot=\"diff-view-ratio\" className=\"flex shrink-0 gap-[2px]\">\n      {Array.from({ length: 5 }, (_, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"size-[7px] rounded-[2px]\",\n            i < add ? \"bg-kura-success\" : i < add + del ? \"bg-kura-danger\" : \"bg-kura-gray-5\",\n          )}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Counts({ additions, deletions }: { additions: number; deletions: number }) {\n  return (\n    <span className=\"flex shrink-0 items-center gap-1.5 font-kura-mono text-[12px] tabular-nums\">\n      <span className=\"text-kura-success-text\">+{additions}</span>\n      <span className=\"text-kura-danger-text\">−{deletions}</span>\n      <span className=\"sr-only\">\n        {additions} additions, {deletions} deletions\n      </span>\n    </span>\n  );\n}\n\n/**\n * Review an agent's edits: parses unified diffs (or takes structured files) and renders them as\n * collapsible files with old/new line numbers, syntax highlighting and word-level change marks.\n * Each hunk can be accepted or rejected (j/k to move, a/r to decide, u to undo), each file\n * accepted at once, and the header keeps the running tally.\n */\nexport function DiffView({\n  diff,\n  files: filesProp,\n  title = \"Changes\",\n  reviewable = true,\n  review: reviewProp,\n  defaultReview,\n  onReviewChange,\n  collapsed: collapsedProp,\n  defaultCollapsed,\n  onCollapsedChange,\n  collapseReviewed = true,\n  autoAdvance = true,\n  wrap = false,\n  maxHeight,\n  actions,\n  empty,\n  className,\n  style,\n  onKeyDown,\n  ...props\n}: DiffViewProps) {\n  const files = React.useMemo(\n    () => (filesProp ? normalizeFiles(filesProp) : diff ? parseUnifiedDiff(diff) : []),\n    [filesProp, diff],\n  );\n\n  const prepared = React.useMemo<PreparedFile[]>(\n    () =>\n      files.map((file) => {\n        const language = file.language ?? languageFromPath(file.path);\n        const { additions, deletions } = countChanges([file]);\n        let maxLine = 0;\n        const hunks = file.hunks.map((hunk, index) => {\n          for (const l of hunk.lines)\n            maxLine = Math.max(maxLine, l.oldNumber ?? 0, l.newNumber ?? 0);\n          return {\n            id: hunkId(file, hunk, index),\n            index,\n            hunk,\n            tokens: highlightHunk(hunk.lines, language),\n            words: intraLineChanges(hunk.lines),\n          };\n        });\n        return { file, additions, deletions, digits: Math.max(2, String(maxLine).length), hunks };\n      }),\n    [files],\n  );\n\n  const allHunks = React.useMemo(\n    () => prepared.flatMap((p) => p.hunks.map((h) => ({ ...h, path: p.file.path }))),\n    [prepared],\n  );\n  const totals = React.useMemo(() => countChanges(files), [files]);\n\n  const [review, setReview] = useControllableState<DiffReview>({\n    value: reviewProp,\n    defaultValue: defaultReview ?? {},\n  });\n  const [collapsedList, setCollapsedList] = useControllableState<readonly string[]>({\n    value: collapsedProp,\n    defaultValue: defaultCollapsed ?? [],\n    onChange: onCollapsedChange as ((v: readonly string[]) => void) | undefined,\n  });\n  const collapsed = React.useMemo(() => new Set(collapsedList), [collapsedList]);\n\n  const reviewed = allHunks.filter((h) => review[h.id]).length;\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  // Roving focus between hunks.\n  const [activeId, setActiveId] = React.useState<string | null>(null);\n  const visibleHunks = allHunks.filter((h) => !collapsed.has(h.path));\n  const tabStop =\n    activeId && visibleHunks.some((h) => h.id === activeId) ? activeId : visibleHunks[0]?.id;\n  const hunkEls = React.useRef(new Map<string, HTMLElement>());\n  const fileEls = React.useRef(new Map<string, HTMLElement>());\n  const focusRequest = React.useRef<{ kind: \"hunk\" | \"file\"; id: string } | null>(null);\n  React.useEffect(() => {\n    const req = focusRequest.current;\n    if (!req) return;\n    focusRequest.current = null;\n    const el = (req.kind === \"hunk\" ? hunkEls : fileEls).current.get(req.id);\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView?.({ block: \"nearest\" });\n  });\n\n  const focusHunk = (id: string) => {\n    setActiveId(id);\n    focusRequest.current = { kind: \"hunk\", id };\n    // Focus now as well when nothing needs to re-render first.\n    const el = hunkEls.current.get(id);\n    if (el) {\n      el.focus({ preventScroll: true });\n      el.scrollIntoView?.({ block: \"nearest\" });\n    }\n  };\n\n  const setFileCollapsed = (path: string, value: boolean) =>\n    setCollapsedList((prev) => {\n      const has = prev.includes(path);\n      if (value === has) return prev;\n      return value ? [...prev, path] : prev.filter((p) => p !== path);\n    });\n\n  const decide = (ids: string[], decision: HunkDecision | \"pending\", path: string) => {\n    const next: Record<string, HunkDecision> = { ...review };\n    let changed = false;\n    for (const id of ids) {\n      if (decision === \"pending\") {\n        if (id in next) {\n          delete next[id];\n          changed = true;\n        }\n      } else if (next[id] !== decision) {\n        next[id] = decision;\n        changed = true;\n      }\n    }\n    if (!changed) return null;\n    setReview(next);\n    onReviewChange?.(next, { path, hunkIds: ids, decision });\n    const done = allHunks.filter((h) => next[h.id]).length;\n    const verb =\n      decision === \"pending\" ? \"Undid\" : decision === \"accepted\" ? \"Accepted\" : \"Rejected\";\n    setAnnouncement(\n      `${verb} ${ids.length === 1 ? \"hunk\" : `${ids.length} hunks`} in ${path}. ${done} of ${allHunks.length} reviewed.`,\n    );\n    return next;\n  };\n\n  /** After a decision on `fromId`: collapse finished files and move on to the next pending hunk. */\n  const afterDecision = (next: DiffReview, fromId: string, path: string) => {\n    const fileHunks = allHunks.filter((h) => h.path === path);\n    const fileDone = fileHunks.every((h) => next[h.id]);\n    const willCollapse = collapseReviewed && fileDone;\n    if (willCollapse) setFileCollapsed(path, true);\n    if (!autoAdvance) {\n      if (willCollapse) focusRequest.current = { kind: \"file\", id: path };\n      return;\n    }\n    const start = allHunks.findIndex((h) => h.id === fromId);\n    const order = [...allHunks.slice(start + 1), ...allHunks.slice(0, start)];\n    const target = order.find(\n      (h) => !next[h.id] && (!collapsed.has(h.path) || h.path === path) && h.id !== fromId,\n    );\n    if (target) {\n      if (collapsed.has(target.path)) setFileCollapsed(target.path, false);\n      setActiveId(target.id);\n      focusRequest.current = { kind: \"hunk\", id: target.id };\n    } else if (willCollapse) {\n      focusRequest.current = { kind: \"file\", id: path };\n    }\n  };\n\n  const decideHunk = (id: string, path: string, decision: HunkDecision | \"pending\") => {\n    const next = decide([id], decision, path);\n    if (next && decision !== \"pending\") afterDecision(next, id, path);\n  };\n\n  const decideFile = (path: string, decision: HunkDecision) => {\n    const ids = allHunks.filter((h) => h.path === path).map((h) => h.id);\n    const next = decide(ids, decision, path);\n    if (next && collapseReviewed) {\n      setFileCollapsed(path, true);\n      focusRequest.current = { kind: \"file\", id: path };\n    }\n  };\n\n  const decideAll = (decision: HunkDecision) => {\n    const pending = allHunks.filter((h) => !review[h.id]);\n    if (!pending.length) return;\n    decide(\n      pending.map((h) => h.id),\n      decision,\n      pending[pending.length - 1]!.path,\n    );\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;\n    const target = event.target as HTMLElement;\n    if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n    const key = event.key.toLowerCase();\n    const current = visibleHunks.find((h) => h.id === tabStop);\n    const index = current ? visibleHunks.indexOf(current) : -1;\n    if (key === \"j\" || key === \"k\") {\n      const next = visibleHunks[key === \"j\" ? index + 1 : index - 1];\n      event.preventDefault();\n      if (next) focusHunk(next.id);\n      else if (index === -1 && visibleHunks[0]) focusHunk(visibleHunks[0].id);\n      return;\n    }\n    if (!reviewable || !current) return;\n    // Decisions apply to the hunk that holds focus (not, say, a file header).\n    const inHunk = target.closest(\"[data-slot=diff-view-hunk]\");\n    if (!inHunk || inHunk.getAttribute(\"data-hunk-id\") !== current.id) return;\n    if (key === \"a\" || key === \"r\") {\n      event.preventDefault();\n      decideHunk(current.id, current.path, key === \"a\" ? \"accepted\" : \"rejected\");\n    } else if (key === \"u\" || key === \"backspace\") {\n      event.preventDefault();\n      decideHunk(current.id, current.path, \"pending\");\n    }\n  };\n\n  const pendingCount = allHunks.length - reviewed;\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: delegates j/k/a/r/u from the focused hunk (the hunks are the focus targets)\n    <div\n      data-slot=\"diff-view\"\n      data-reviewable={reviewable || undefined}\n      data-state={allHunks.length === 0 ? \"empty\" : pendingCount === 0 ? \"reviewed\" : \"pending\"}\n      className={cn(\n        \"@container/diff flex min-w-0 flex-col overflow-clip rounded-kura-xl bg-kura-surface font-kura-sans text-[13px] text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      style={{ ...syntaxVars, ...style }}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      <div\n        data-slot=\"diff-view-header\"\n        className=\"flex h-10 shrink-0 items-center gap-3 border-kura-border-subtle border-b pr-2 pl-3.5\"\n      >\n        <span className=\"flex min-w-0 items-baseline gap-2.5\">\n          <span className=\"min-w-[3rem] truncate font-medium tracking-[-0.006em]\">{title}</span>\n          <span\n            data-slot=\"diff-view-summary\"\n            className=\"flex shrink-0 items-baseline gap-2.5 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            <span className=\"hidden @[24rem]/diff:inline\">\n              {files.length} {files.length === 1 ? \"file\" : \"files\"}\n            </span>\n            {files.length > 0 && (\n              <Counts additions={totals.additions} deletions={totals.deletions} />\n            )}\n          </span>\n        </span>\n        <span className=\"flex-1\" />\n        {reviewable && allHunks.length > 0 && (\n          <span\n            data-slot=\"diff-view-progress\"\n            className=\"hidden shrink-0 items-center gap-2.5 @[34rem]/diff:flex\"\n          >\n            <span className=\"font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n              {reviewed} of {allHunks.length} reviewed\n            </span>\n            <span aria-hidden className=\"flex w-20 gap-[2px]\">\n              {allHunks.map((h) => (\n                <span\n                  key={h.id}\n                  className={cn(\n                    \"h-1 min-w-0 flex-1 rounded-full transition-colors duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                    review[h.id] === \"accepted\"\n                      ? \"bg-kura-success\"\n                      : review[h.id] === \"rejected\"\n                        ? \"bg-kura-danger\"\n                        : \"bg-kura-gray-5\",\n                  )}\n                />\n              ))}\n            </span>\n          </span>\n        )}\n        {(actions || (reviewable && pendingCount > 0)) && (\n          <span className=\"flex shrink-0 items-center gap-1\">\n            {reviewable && pendingCount > 0 && (\n              <>\n                <Button\n                  size=\"xs\"\n                  variant=\"ghost\"\n                  data-slot=\"diff-view-reject-all\"\n                  onClick={() => decideAll(\"rejected\")}\n                  className=\"hidden @[26rem]/diff:inline-flex\"\n                >\n                  Reject all\n                </Button>\n                <Button\n                  size=\"xs\"\n                  variant=\"primary\"\n                  data-slot=\"diff-view-accept-all\"\n                  onClick={() => decideAll(\"accepted\")}\n                >\n                  Accept all\n                </Button>\n              </>\n            )}\n            {actions}\n          </span>\n        )}\n      </div>\n\n      <div\n        data-slot=\"diff-view-body\"\n        className={cn(\n          \"min-h-0 flex-1\",\n          maxHeight != null && \"overflow-y-auto overscroll-contain [scrollbar-width:thin]\",\n        )}\n        style={maxHeight != null ? { maxHeight } : undefined}\n      >\n        {prepared.length === 0 && (\n          <div\n            data-slot=\"diff-view-empty\"\n            className=\"flex h-24 flex-col items-center justify-center gap-1.5 text-kura-fg-subtle\"\n          >\n            {empty ?? (\n              <>\n                <FileDiff aria-hidden className=\"size-4\" strokeWidth={1.75} />\n                <span>No changes</span>\n              </>\n            )}\n          </div>\n        )}\n        {prepared.map((p, fileIndex) => {\n          const { file } = p;\n          const open = !collapsed.has(file.path);\n          const fileReviewed = p.hunks.filter((h) => review[h.id]).length;\n          const filePending = p.hunks.length - fileReviewed;\n          const allAccepted =\n            p.hunks.length > 0 && p.hunks.every((h) => review[h.id] === \"accepted\");\n          const allRejected =\n            p.hunks.length > 0 && p.hunks.every((h) => review[h.id] === \"rejected\");\n          const [dir, base] = splitPath(file.path);\n          const bodyId = `diff-${fileIndex}-${base.replace(/[^\\w-]/g, \"\")}`;\n          return (\n            <section\n              key={`${file.oldPath ?? \"\"}→${file.path}`}\n              data-slot=\"diff-view-file\"\n              data-status={file.status}\n              data-state={open ? \"open\" : \"closed\"}\n              aria-label={file.path}\n              className={cn(fileIndex > 0 && \"border-kura-border-subtle border-t\")}\n            >\n              <div\n                data-slot=\"diff-view-file-header\"\n                className={cn(\n                  \"sticky top-0 z-[2] flex h-10 items-center gap-2 bg-kura-surface pr-2\",\n                  open && \"shadow-[inset_0_-1px_0_var(--kura-border-subtle)]\",\n                )}\n              >\n                <button\n                  type=\"button\"\n                  ref={(el) => {\n                    if (el) fileEls.current.set(file.path, el);\n                    else fileEls.current.delete(file.path);\n                  }}\n                  aria-expanded={open}\n                  aria-controls={bodyId}\n                  onClick={() => setFileCollapsed(file.path, open)}\n                  className=\"flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 pl-3 text-left outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)] motion-reduce:transition-none\"\n                >\n                  <ChevronRight\n                    aria-hidden\n                    strokeWidth={1.75}\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                  <span\n                    data-slot=\"diff-view-path\"\n                    className=\"flex min-w-0 items-baseline font-kura-mono text-[12.5px]\"\n                  >\n                    {file.status === \"renamed\" && file.oldPath && (\n                      <>\n                        <span className=\"min-w-0 truncate text-kura-fg-subtle\">{file.oldPath}</span>\n                        <span aria-hidden className=\"shrink-0 px-1.5 text-kura-fg-subtle\">\n                          →\n                        </span>\n                        <span className=\"sr-only\"> renamed to </span>\n                      </>\n                    )}\n                    {/* The directory truncates first (from the left); the file name keeps priority. */}\n                    {!(file.status === \"renamed\" && file.oldPath) && dir && (\n                      <span className=\"hidden min-w-0 shrink-[999] truncate text-kura-fg-subtle [direction:rtl] @[24rem]/diff:inline\">\n                        <span className=\"[direction:ltr] [unicode-bidi:isolate]\">{dir}</span>\n                      </span>\n                    )}\n                    <span\n                      className={cn(\n                        \"min-w-0 truncate font-medium text-kura-fg\",\n                        file.status === \"deleted\" &&\n                          \"text-kura-fg-muted line-through decoration-kura-fg-subtle/60\",\n                      )}\n                    >\n                      {file.status === \"renamed\" && file.oldPath ? file.path : base}\n                    </span>\n                  </span>\n                  {file.status !== \"modified\" && (\n                    <span\n                      data-slot=\"diff-view-file-status\"\n                      className={cn(\n                        \"inline-flex h-[18px] shrink-0 items-center rounded-kura-xs px-1.5 font-medium text-[11px]\",\n                        file.status === \"added\" && \"bg-kura-success/12 text-kura-success-text\",\n                        file.status === \"deleted\" && \"bg-kura-danger/10 text-kura-danger-text\",\n                        file.status === \"renamed\" && \"bg-kura-hover text-kura-fg-muted\",\n                      )}\n                    >\n                      {STATUS_LABEL[file.status]}\n                    </span>\n                  )}\n                  <span className=\"ml-auto flex shrink-0 items-center gap-2.5 pl-2\">\n                    {file.binary ? (\n                      <span className=\"font-kura-mono text-[12px] text-kura-fg-subtle\">binary</span>\n                    ) : (\n                      <>\n                        <Counts additions={p.additions} deletions={p.deletions} />\n                        <span className=\"hidden @[30rem]/diff:flex\">\n                          <RatioBar additions={p.additions} deletions={p.deletions} />\n                        </span>\n                      </>\n                    )}\n                  </span>\n                </button>\n                {reviewable && p.hunks.length > 0 && (\n                  <span\n                    data-slot=\"diff-view-file-review\"\n                    className=\"flex h-6 shrink-0 items-center justify-end @[30rem]/diff:w-[6.75rem]\"\n                  >\n                    {filePending > 0 ? (\n                      <Button\n                        size=\"xs\"\n                        variant=\"outline\"\n                        onClick={() => decideFile(file.path, \"accepted\")}\n                        aria-label={`Accept all changes in ${file.path}`}\n                      >\n                        <Check aria-hidden strokeWidth={2} />\n                        <span className=\"hidden @[30rem]/diff:inline\">Accept file</span>\n                      </Button>\n                    ) : (\n                      <span\n                        className={cn(\n                          \"inline-flex items-center gap-1 pr-1.5 font-medium text-[12px] animate-kura-fade-in\",\n                          allAccepted\n                            ? \"text-kura-success-text\"\n                            : allRejected\n                              ? \"text-kura-danger-text\"\n                              : \"text-kura-fg-muted\",\n                        )}\n                      >\n                        {allRejected ? (\n                          <X aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        ) : (\n                          <Check aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        )}\n                        <span className=\"hidden @[30rem]/diff:inline\">\n                          {allAccepted ? \"Accepted\" : allRejected ? \"Rejected\" : \"Reviewed\"}\n                        </span>\n                      </span>\n                    )}\n                  </span>\n                )}\n              </div>\n\n              <div id={bodyId} data-slot=\"diff-view-file-body\" hidden={!open}>\n                {open && (\n                  <FileBody\n                    prepared={p}\n                    review={review}\n                    reviewable={reviewable}\n                    wrap={wrap}\n                    tabStop={tabStop}\n                    total={allHunks.length}\n                    ordinal={(id) => allHunks.findIndex((h) => h.id === id) + 1}\n                    registerHunk={(id, el) => {\n                      if (el) hunkEls.current.set(id, el);\n                      else hunkEls.current.delete(id);\n                    }}\n                    onFocusHunk={setActiveId}\n                    onDecide={(id, d) => decideHunk(id, file.path, d)}\n                  />\n                )}\n              </div>\n            </section>\n          );\n        })}\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\ninterface FileBodyProps {\n  prepared: PreparedFile;\n  review: DiffReview;\n  reviewable: boolean;\n  wrap: boolean;\n  tabStop: string | undefined;\n  total: number;\n  ordinal: (id: string) => number;\n  registerHunk: (id: string, el: HTMLElement | null) => void;\n  onFocusHunk: (id: string) => void;\n  onDecide: (id: string, decision: HunkDecision | \"pending\") => void;\n}\n\nfunction FileBody({\n  prepared,\n  review,\n  reviewable,\n  wrap,\n  tabStop,\n  total,\n  ordinal,\n  registerHunk,\n  onFocusHunk,\n  onDecide,\n}: FileBodyProps) {\n  const { file, hunks, digits } = prepared;\n  if (file.binary || hunks.length === 0) {\n    const note = file.binary\n      ? \"Binary file not shown.\"\n      : file.status === \"renamed\"\n        ? \"File renamed without content changes.\"\n        : file.status === \"deleted\"\n          ? \"Empty file deleted.\"\n          : file.status === \"added\"\n            ? \"Empty file added.\"\n            : \"No content changes.\";\n    return (\n      <div className=\"flex h-10 items-center bg-kura-surface-sunken px-3.5 text-kura-fg-subtle\">\n        {note}\n      </div>\n    );\n  }\n  const gutter = `calc(${digits}ch + 1.25rem)`;\n  return (\n    <div className=\"flex flex-col font-kura-mono text-[12.5px] leading-5\">\n      {hunks.map((h, k) => {\n        const decision = review[h.id];\n        const active = tabStop === h.id;\n        const range =\n          h.hunk.newLines > 0\n            ? `lines ${h.hunk.newStart}–${h.hunk.newStart + h.hunk.newLines - 1}`\n            : `at line ${h.hunk.oldStart}`;\n        return (\n          // biome-ignore lint/a11y/useSemanticElements: a hunk is a focusable review region (roving tabindex), not a form fieldset\n          <div\n            key={h.id}\n            ref={(el) => registerHunk(h.id, el)}\n            data-slot=\"diff-view-hunk\"\n            data-hunk-id={h.id}\n            data-review={decision ?? \"pending\"}\n            data-active={active || undefined}\n            role=\"group\"\n            aria-label={`Change ${ordinal(h.id)} of ${total}, ${file.path} ${range}, ${decision ?? \"pending review\"}`}\n            tabIndex={active ? 0 : -1}\n            onFocus={() => onFocusHunk(h.id)}\n            className=\"group/hunk relative scroll-mt-10 outline-none\"\n          >\n            <span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-y-0 left-0 z-[1] w-0.5 bg-kura-accent opacity-0 transition-opacity duration-(--kura-duration-fast) ease-kura-out group-focus-within/hunk:opacity-100 motion-reduce:transition-none\"\n            />\n            <div\n              data-slot=\"diff-view-hunk-header\"\n              className={cn(\n                \"flex h-8 items-center gap-3 bg-kura-surface-sunken pr-1.5 font-kura-mono text-[12px] text-kura-fg-subtle transition-colors duration-(--kura-duration-fast) ease-kura-out group-focus-within/hunk:bg-kura-accent-subtle/60 motion-reduce:transition-none\",\n                k > 0 && \"border-kura-border-subtle border-t\",\n              )}\n            >\n              <span className=\"flex min-w-0 flex-1 items-baseline gap-2.5 pl-3.5\">\n                <span className=\"min-w-0 truncate tabular-nums @[30rem]/diff:shrink-0 @[30rem]/diff:overflow-visible\">\n                  @@ −{h.hunk.oldStart},{h.hunk.oldLines} +{h.hunk.newStart},{h.hunk.newLines} @@\n                </span>\n                {h.hunk.section && (\n                  <span className=\"hidden min-w-0 truncate text-kura-fg-muted @[30rem]/diff:inline\">\n                    {h.hunk.section}\n                  </span>\n                )}\n              </span>\n              {reviewable && (\n                <span\n                  data-slot=\"diff-view-hunk-actions\"\n                  className=\"flex shrink-0 items-center gap-1 font-kura-sans\"\n                >\n                  {decision ? (\n                    <>\n                      <span\n                        data-slot=\"diff-view-hunk-state\"\n                        className={cn(\n                          \"inline-flex items-center gap-1 px-1 font-medium text-[12px] animate-kura-fade-in\",\n                          decision === \"accepted\"\n                            ? \"text-kura-success-text\"\n                            : \"text-kura-danger-text\",\n                        )}\n                      >\n                        {decision === \"accepted\" ? (\n                          <Check aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        ) : (\n                          <X aria-hidden className=\"size-3.5\" strokeWidth={2} />\n                        )}\n                        {decision === \"accepted\" ? \"Accepted\" : \"Rejected\"}\n                      </span>\n                      <IconButton\n                        size=\"xs\"\n                        label=\"Undo decision\"\n                        shortcut={[\"u\"]}\n                        icon={<RotateCcw strokeWidth={1.75} />}\n                        tabIndex={active ? 0 : -1}\n                        onClick={() => onDecide(h.id, \"pending\")}\n                      />\n                    </>\n                  ) : (\n                    <>\n                      <Tooltip content=\"Reject change\" shortcut={[\"r\"]}>\n                        <Button\n                          size=\"xs\"\n                          variant=\"ghost\"\n                          aria-label=\"Reject change\"\n                          tabIndex={active ? 0 : -1}\n                          onClick={() => onDecide(h.id, \"rejected\")}\n                        >\n                          <X aria-hidden strokeWidth={2} />\n                          <span className=\"hidden @[30rem]/diff:inline\">Reject</span>\n                        </Button>\n                      </Tooltip>\n                      <Tooltip content=\"Accept change\" shortcut={[\"a\"]}>\n                        <Button\n                          size=\"xs\"\n                          variant=\"secondary\"\n                          aria-label=\"Accept change\"\n                          tabIndex={active ? 0 : -1}\n                          onClick={() => onDecide(h.id, \"accepted\")}\n                        >\n                          <Check aria-hidden strokeWidth={2} />\n                          <span className=\"hidden @[30rem]/diff:inline\">Accept</span>\n                        </Button>\n                      </Tooltip>\n                    </>\n                  )}\n                </span>\n              )}\n            </div>\n            <div\n              data-slot=\"diff-view-lines\"\n              className={cn(\n                \"overflow-x-auto transition-opacity duration-(--kura-duration-base) ease-kura-out [scrollbar-width:thin] motion-reduce:transition-none\",\n                decision === \"accepted\" && \"opacity-55\",\n                decision === \"rejected\" && \"opacity-45\",\n              )}\n            >\n              <div className={wrap ? \"w-full\" : \"w-max min-w-full\"}>\n                {h.hunk.lines.map((line, i) => (\n                  <Line\n                    key={i}\n                    line={line}\n                    tokens={h.tokens[i] ?? []}\n                    ranges={h.words.get(i)}\n                    gutter={gutter}\n                    wrap={wrap}\n                    struck={decision === \"rejected\" && line.kind === \"add\"}\n                  />\n                ))}\n              </div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nconst Line = React.memo(function Line({\n  line,\n  tokens,\n  ranges,\n  gutter,\n  wrap,\n  struck,\n}: {\n  line: DiffLine;\n  tokens: CodeToken[];\n  ranges: Ranges | undefined;\n  gutter: string;\n  wrap: boolean;\n  struck: boolean;\n}) {\n  const add = line.kind === \"add\";\n  const remove = line.kind === \"remove\";\n  const parts = segment(tokens, ranges);\n  return (\n    <div\n      data-slot=\"diff-view-line\"\n      data-kind={line.kind}\n      className={cn(\"flex\", add && \"bg-kura-success/[0.08]\", remove && \"bg-kura-danger/[0.07]\")}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"shrink-0 select-none pr-2 text-right text-kura-fg-subtle/70 tabular-nums\",\n          add && \"bg-kura-success/[0.1] text-kura-success-text/70\",\n          remove && \"bg-kura-danger/[0.09] text-kura-danger-text/70\",\n        )}\n        style={{ width: gutter }}\n      >\n        {line.oldNumber}\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"shrink-0 select-none pr-2 text-right text-kura-fg-subtle/70 tabular-nums\",\n          add && \"bg-kura-success/[0.1] text-kura-success-text/70\",\n          remove && \"bg-kura-danger/[0.09] text-kura-danger-text/70\",\n        )}\n        style={{ width: gutter }}\n      >\n        {line.newNumber}\n      </span>\n      <span\n        aria-hidden\n        className={cn(\n          \"w-6 shrink-0 select-none text-center\",\n          add && \"text-kura-success-text\",\n          remove && \"text-kura-danger-text\",\n        )}\n      >\n        {add ? \"+\" : remove ? \"−\" : \"\"}\n      </span>\n      {(add || remove) && <span className=\"sr-only\">{add ? \"added: \" : \"removed: \"}</span>}\n      <span\n        className={cn(\n          \"min-w-0 flex-1 pr-4\",\n          wrap ? \"whitespace-pre-wrap [overflow-wrap:anywhere]\" : \"whitespace-pre\",\n          struck && \"line-through decoration-kura-fg-subtle/70\",\n        )}\n      >\n        {parts.length === 0\n          ? \"​\"\n          : parts.map((t, i) => (\n              <span\n                key={i}\n                className={cn(\n                  tokenClass[t.type],\n                  t.first && \"rounded-l-[3px]\",\n                  t.last && \"rounded-r-[3px]\",\n                  t.changed && add && \"bg-kura-success/25\",\n                  t.changed && remove && \"bg-kura-danger/20\",\n                )}\n              >\n                {t.text}\n              </span>\n            ))}\n        {line.noNewline && (\n          <span\n            className=\"ml-2 select-none font-kura-sans text-[11px] text-kura-fg-subtle\"\n            title=\"No newline at end of file\"\n          >\n            <CircleSlash2\n              aria-hidden\n              className=\"inline size-3 -translate-y-px\"\n              strokeWidth={1.75}\n            />\n            <span className=\"sr-only\">No newline at end of file</span>\n          </span>\n        )}\n      </span>\n    </div>\n  );\n});\n\nexport type {\n  DiffFile,\n  DiffFileInput,\n  DiffFileStatus,\n  DiffHunk,\n  DiffHunkInput,\n  DiffLine,\n  DiffLineKind,\n} from \"./parse-diff\";\nexport {\n  countChanges,\n  hunkId,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  wordDiff,\n} from \"./parse-diff\";\n"
        },
        {
          "target": "components/kura/diff-view/index.ts",
          "type": "registry:ui",
          "content": "export {\n  countChanges,\n  type DiffFile,\n  type DiffFileInput,\n  type DiffFileStatus,\n  type DiffHunk,\n  type DiffHunkInput,\n  type DiffLine,\n  type DiffLineKind,\n  type DiffReview,\n  type DiffReviewChange,\n  DiffView,\n  type DiffViewProps,\n  type HunkDecision,\n  hunkId,\n  languageFromPath,\n  normalizeFiles,\n  parseUnifiedDiff,\n  wordDiff,\n} from \"./diff-view\";\n"
        },
        {
          "target": "components/kura/diff-view/parse-diff.ts",
          "type": "registry:ui",
          "content": "/**\n * Unified-diff parsing and line pairing for DiffView. Pure functions, no React, safe anywhere.\n *\n * The parser is count-driven (it trusts the `@@ -a,b +c,d @@` header to know where a hunk\n * ends, so a removed line such as `-- comment` or `--- a/x` inside a hunk is never mistaken for a\n * file header) but forgiving: agents often emit hunks whose counts are wrong, so a hunk also ends\n * at anything that cannot be a hunk line, and a short hunk simply stops early.\n */\n\nexport type DiffFileStatus = \"added\" | \"modified\" | \"deleted\" | \"renamed\";\nexport type DiffLineKind = \"context\" | \"add\" | \"remove\";\n\nexport interface DiffLine {\n  kind: DiffLineKind;\n  /** The line's text without its `+` / `-` / space prefix. */\n  content: string;\n  /** Line number in the old file (absent for additions). */\n  oldNumber?: number;\n  /** Line number in the new file (absent for removals). */\n  newNumber?: number;\n  /** The diff said `\\ No newline at end of file` after this line. */\n  noNewline?: boolean;\n}\n\nexport interface DiffHunk {\n  /** Stable id for review state. Default `${path}#${index}`. */\n  id?: string;\n  oldStart: number;\n  oldLines: number;\n  newStart: number;\n  newLines: number;\n  /** Section heading git prints after the second `@@` (usually the enclosing function). */\n  section?: string;\n  lines: DiffLine[];\n}\n\nexport interface DiffFile {\n  /** Path after the change (the old path for deletions). */\n  path: string;\n  /** Path before a rename or copy. */\n  oldPath?: string;\n  status: DiffFileStatus;\n  /** Highlighting language. Default: from the file extension. */\n  language?: string;\n  /** Binary change: no hunks are shown. */\n  binary?: boolean;\n  hunks: DiffHunk[];\n}\n\n/** Structured input: like DiffHunk, but positions are optional and lines may be raw diff strings (`\"+added\"`, `\"-removed\"`, `\" context\"`). */\nexport interface DiffHunkInput {\n  id?: string;\n  oldStart?: number;\n  newStart?: number;\n  section?: string;\n  lines: ReadonlyArray<string | DiffLine>;\n}\n\nexport interface DiffFileInput {\n  path: string;\n  oldPath?: string;\n  status?: DiffFileStatus;\n  language?: string;\n  binary?: boolean;\n  hunks: readonly DiffHunkInput[];\n}\n\nconst HUNK_RE = /^@@+ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@+ ?(.*)$/;\n\n/** Undo git's C-style quoting: `\"a/caf\\303\\251 menu.ts\"` → `a/café menu.ts`. */\nfunction unquote(path: string): string {\n  if (!(path.length >= 2 && path.startsWith('\"') && path.endsWith('\"'))) return path;\n  const body = path.slice(1, -1);\n  const bytes: number[] = [];\n  const escapes: Record<string, number> = { n: 10, t: 9, r: 13, '\"': 34, \"\\\\\": 92, a: 7, b: 8 };\n  for (let i = 0; i < body.length; i++) {\n    const ch = body[i]!;\n    if (ch !== \"\\\\\") {\n      for (const b of new TextEncoder().encode(ch)) bytes.push(b);\n      continue;\n    }\n    const next = body[i + 1] ?? \"\";\n    const octal = /^[0-7]{3}/.exec(body.slice(i + 1));\n    if (octal) {\n      bytes.push(Number.parseInt(octal[0], 8));\n      i += 3;\n    } else if (next in escapes) {\n      bytes.push(escapes[next]!);\n      i += 1;\n    } else {\n      bytes.push(92);\n    }\n  }\n  return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\n/** Path from a `---` / `+++` line: drops the trailing tab + timestamp and quoting. */\nfunction headerPath(raw: string): string {\n  let p = raw.trim();\n  if (p.startsWith('\"')) {\n    let j = 1;\n    while (j < p.length && !(p[j] === '\"' && p[j - 1] !== \"\\\\\")) j++;\n    p = unquote(p.slice(0, j + 1));\n  } else {\n    const tab = p.indexOf(\"\\t\");\n    if (tab >= 0) p = p.slice(0, tab);\n    // GNU diff without a tab: \"file 2024-01-01 10:00:00.000000000 +0000\"\n    p = p.replace(/\\s+\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)? [+-]\\d{4}$/, \"\");\n  }\n  return p;\n}\n\nconst stripPrefix = (p: string, prefix: \"a/\" | \"b/\") => (p.startsWith(prefix) ? p.slice(2) : p);\n\n/** Paths from `diff --git a/x b/y`, including quoted names and names that contain spaces. */\nfunction gitHeaderPaths(rest: string): [string, string] | null {\n  if (rest.startsWith('\"')) {\n    let j = 1;\n    while (j < rest.length && !(rest[j] === '\"' && rest[j - 1] !== \"\\\\\")) j++;\n    const a = unquote(rest.slice(0, j + 1));\n    const b = headerPath(rest.slice(j + 2));\n    return [stripPrefix(a, \"a/\"), stripPrefix(unquote(b), \"b/\")];\n  }\n  // Same name on both sides (the overwhelmingly common case): split exactly in half.\n  const half = (rest.length - 1) / 2;\n  if (Number.isInteger(half) && rest[half] === \" \") {\n    const a = rest.slice(0, half);\n    const b = rest.slice(half + 1);\n    if (stripPrefix(a, \"a/\") === stripPrefix(b, \"b/\"))\n      return [stripPrefix(a, \"a/\"), stripPrefix(b, \"b/\")];\n  }\n  const split = rest.lastIndexOf(\" b/\");\n  if (split > 0) return [stripPrefix(rest.slice(0, split), \"a/\"), rest.slice(split + 3)];\n  const space = rest.indexOf(\" \");\n  return space > 0 ? [rest.slice(0, space), rest.slice(space + 1)] : null;\n}\n\ninterface Draft {\n  oldPath: string | null;\n  newPath: string | null;\n  added: boolean;\n  deleted: boolean;\n  renamed: boolean;\n  binary: boolean;\n  hunks: DiffHunk[];\n}\n\nfunction finish(d: Draft): DiffFile | null {\n  const oldP = d.oldPath === \"/dev/null\" ? null : d.oldPath;\n  const newP = d.newPath === \"/dev/null\" ? null : d.newPath;\n  const added = d.added || (d.oldPath === \"/dev/null\" && newP != null);\n  const deleted = d.deleted || (d.newPath === \"/dev/null\" && oldP != null);\n  const path = (deleted ? oldP : newP) ?? oldP ?? newP;\n  if (!path) return null;\n  const renamed = !added && !deleted && oldP != null && newP != null && oldP !== newP;\n  const file: DiffFile = {\n    path,\n    status: added ? \"added\" : deleted ? \"deleted\" : renamed || d.renamed ? \"renamed\" : \"modified\",\n    hunks: d.hunks,\n  };\n  if (renamed && oldP) file.oldPath = oldP;\n  if (d.binary) file.binary = true;\n  return file;\n}\n\n/**\n * Parse a unified diff (`git diff`, `git show`, `diff -u`, or an agent's patch) into files and\n * hunks with old/new line numbers. Ignores commit headers and other noise; never throws.\n */\nexport function parseUnifiedDiff(input: string): DiffFile[] {\n  const lines = input.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  const files: DiffFile[] = [];\n  let cur: Draft | null = null;\n  const fresh = (): Draft => ({\n    oldPath: null,\n    newPath: null,\n    added: false,\n    deleted: false,\n    renamed: false,\n    binary: false,\n    hunks: [],\n  });\n  const flush = () => {\n    if (cur) {\n      const f = finish(cur);\n      if (f) files.push(f);\n    }\n    cur = null;\n  };\n\n  let i = 0;\n  while (i < lines.length) {\n    const line = lines[i]!;\n\n    if (line.startsWith(\"diff --git \") || line.startsWith(\"diff --cc \")) {\n      flush();\n      cur = fresh();\n      const paths = gitHeaderPaths(line.slice(line.indexOf(\" \", 5) + 1));\n      if (paths) [cur.oldPath, cur.newPath] = paths;\n      i++;\n      continue;\n    }\n\n    // A `---` immediately followed by `+++` is a file header. Outside `diff --git` blocks (plain\n    // `diff -u` output, or several patches pasted together) it starts a new file.\n    if (line.startsWith(\"--- \") && lines[i + 1]?.startsWith(\"+++ \")) {\n      // Inside a `diff --git` block that has no hunks yet, this header belongs to it.\n      if (!cur || (cur as Draft).hunks.length > 0) {\n        flush();\n        cur = fresh();\n      }\n      const d = cur as Draft;\n      const oldP = headerPath(line.slice(4));\n      const newP = headerPath(lines[i + 1]!.slice(4));\n      d.oldPath = oldP === \"/dev/null\" ? oldP : stripPrefix(oldP, \"a/\");\n      d.newPath = newP === \"/dev/null\" ? newP : stripPrefix(newP, \"b/\");\n      i += 2;\n      continue;\n    }\n\n    const hunk = HUNK_RE.exec(line);\n    if (hunk) {\n      if (!cur) cur = fresh();\n      const h: DiffHunk = {\n        oldStart: Number(hunk[1]),\n        oldLines: hunk[2] === undefined ? 1 : Number(hunk[2]),\n        newStart: Number(hunk[3]),\n        newLines: hunk[4] === undefined ? 1 : Number(hunk[4]),\n        lines: [],\n      };\n      const section = hunk[5]?.trim();\n      if (section) h.section = section;\n      let oldLeft = h.oldLines;\n      let newLeft = h.newLines;\n      let oldNo = h.oldStart;\n      let newNo = h.newStart;\n      i++;\n      while (i < lines.length) {\n        const l = lines[i]!;\n        const c = l[0];\n        if (c === \"\\\\\") {\n          const last = h.lines[h.lines.length - 1];\n          if (last) last.noNewline = true;\n          i++;\n          continue;\n        }\n        if (oldLeft <= 0 && newLeft <= 0) break;\n        // Counts may be wrong in hand-written or model-written patches: never swallow the next\n        // file's header or hunk.\n        if (l.startsWith(\"diff --git \") || HUNK_RE.test(l)) break;\n        if (\n          l.startsWith(\"--- \") &&\n          lines[i + 1]?.startsWith(\"+++ \") &&\n          HUNK_RE.test(lines[i + 2] ?? \"\")\n        )\n          break;\n        if (c === \" \" || l === \"\") {\n          // A blank line is a context line whose trailing space was stripped by an editor —\n          // unless it is the very end of the input.\n          if (l === \"\" && i === lines.length - 1) break;\n          h.lines.push({\n            kind: \"context\",\n            content: l.slice(1),\n            oldNumber: oldNo++,\n            newNumber: newNo++,\n          });\n          oldLeft--;\n          newLeft--;\n        } else if (c === \"-\") {\n          h.lines.push({ kind: \"remove\", content: l.slice(1), oldNumber: oldNo++ });\n          oldLeft--;\n        } else if (c === \"+\") {\n          h.lines.push({ kind: \"add\", content: l.slice(1), newNumber: newNo++ });\n          newLeft--;\n        } else {\n          break;\n        }\n        i++;\n      }\n      // Trust what was actually read when the header lied.\n      h.oldLines = h.lines.filter((x) => x.kind !== \"add\").length;\n      h.newLines = h.lines.filter((x) => x.kind !== \"remove\").length;\n      cur.hunks.push(h);\n      continue;\n    }\n\n    if (cur && cur.hunks.length === 0) {\n      if (line.startsWith(\"new file mode\")) cur.added = true;\n      else if (line.startsWith(\"deleted file mode\")) cur.deleted = true;\n      else if (line.startsWith(\"rename from \") || line.startsWith(\"copy from \")) {\n        cur.oldPath = unquote(line.slice(line.indexOf(\" from \") + 6));\n        cur.renamed = true;\n      } else if (line.startsWith(\"rename to \") || line.startsWith(\"copy to \")) {\n        cur.newPath = unquote(line.slice(line.indexOf(\" to \") + 4));\n        cur.renamed = true;\n      } else if (line.startsWith(\"Binary files \") || line.startsWith(\"GIT binary patch\")) {\n        cur.binary = true;\n        const m = /^Binary files (.+) and (.+) differ$/.exec(line);\n        if (m && cur.oldPath === null) {\n          cur.oldPath = m[1] === \"/dev/null\" ? m[1] : stripPrefix(m[1]!, \"a/\");\n          cur.newPath = m[2] === \"/dev/null\" ? m[2] : stripPrefix(m[2]!, \"b/\");\n        }\n        if (m?.[1] === \"/dev/null\") cur.added = true;\n        if (m?.[2] === \"/dev/null\") cur.deleted = true;\n      }\n    }\n    i++;\n  }\n  flush();\n  return files;\n}\n\n/** Turn structured input (possibly with raw `\"+line\"` strings and no positions) into DiffFiles. */\nexport function normalizeFiles(input: readonly DiffFileInput[]): DiffFile[] {\n  return input.map((f) => {\n    const hunks = f.hunks.map((h) => {\n      const oldStart = h.oldStart ?? h.newStart ?? 1;\n      const newStart = h.newStart ?? h.oldStart ?? 1;\n      let oldNo = oldStart;\n      let newNo = newStart;\n      const lines: DiffLine[] = [];\n      for (const raw of h.lines) {\n        if (typeof raw !== \"string\") {\n          const l: DiffLine = { ...raw };\n          if (l.kind !== \"add\") {\n            l.oldNumber ??= oldNo;\n            oldNo = l.oldNumber + 1;\n          }\n          if (l.kind !== \"remove\") {\n            l.newNumber ??= newNo;\n            newNo = l.newNumber + 1;\n          }\n          lines.push(l);\n          continue;\n        }\n        if (raw.startsWith(\"\\\\\")) {\n          const last = lines[lines.length - 1];\n          if (last) last.noNewline = true;\n        } else if (raw.startsWith(\"+\"))\n          lines.push({ kind: \"add\", content: raw.slice(1), newNumber: newNo++ });\n        else if (raw.startsWith(\"-\"))\n          lines.push({ kind: \"remove\", content: raw.slice(1), oldNumber: oldNo++ });\n        else\n          lines.push({\n            kind: \"context\",\n            content: raw.startsWith(\" \") ? raw.slice(1) : raw,\n            oldNumber: oldNo++,\n            newNumber: newNo++,\n          });\n      }\n      const hunk: DiffHunk = {\n        oldStart,\n        newStart,\n        oldLines: lines.filter((l) => l.kind !== \"add\").length,\n        newLines: lines.filter((l) => l.kind !== \"remove\").length,\n        lines,\n      };\n      if (h.id) hunk.id = h.id;\n      if (h.section) hunk.section = h.section;\n      return hunk;\n    });\n    const status: DiffFileStatus =\n      f.status ?? (f.oldPath && f.oldPath !== f.path ? \"renamed\" : \"modified\");\n    const file: DiffFile = { path: f.path, status, hunks };\n    if (f.oldPath) file.oldPath = f.oldPath;\n    if (f.language) file.language = f.language;\n    if (f.binary) file.binary = true;\n    return file;\n  });\n}\n\n/** The id DiffView uses for a hunk's review state. */\nexport function hunkId(file: Pick<DiffFile, \"path\">, hunk: Pick<DiffHunk, \"id\">, index: number) {\n  return hunk.id ?? `${file.path}#${index}`;\n}\n\nexport function countChanges(files: readonly DiffFile[]): { additions: number; deletions: number } {\n  let additions = 0;\n  let deletions = 0;\n  for (const f of files)\n    for (const h of f.hunks)\n      for (const l of h.lines) {\n        if (l.kind === \"add\") additions++;\n        else if (l.kind === \"remove\") deletions++;\n      }\n  return { additions, deletions };\n}\n\nconst EXT_LANGUAGE: Record<string, string> = {\n  ts: \"ts\",\n  tsx: \"tsx\",\n  mts: \"ts\",\n  cts: \"ts\",\n  js: \"js\",\n  jsx: \"jsx\",\n  mjs: \"js\",\n  cjs: \"js\",\n  json: \"json\",\n  jsonc: \"jsonc\",\n  json5: \"json5\",\n  sh: \"bash\",\n  bash: \"bash\",\n  zsh: \"bash\",\n  py: \"python\",\n  css: \"css\",\n  scss: \"scss\",\n  html: \"html\",\n  xml: \"xml\",\n  svg: \"svg\",\n  vue: \"vue\",\n  yml: \"yaml\",\n  yaml: \"yaml\",\n  toml: \"toml\",\n  sql: \"sql\",\n  go: \"go\",\n  rs: \"rust\",\n  java: \"java\",\n  kt: \"kotlin\",\n  swift: \"swift\",\n  c: \"c\",\n  h: \"c\",\n  cc: \"cpp\",\n  cpp: \"cpp\",\n  hpp: \"cpp\",\n  cs: \"csharp\",\n  php: \"php\",\n  rb: \"ruby\",\n};\n\n/** Highlighting language for a path, from its extension (\"text\" when unknown). */\nexport function languageFromPath(path: string): string {\n  const name = path.slice(path.lastIndexOf(\"/\") + 1).toLowerCase();\n  if (name === \"dockerfile\" || name.endsWith(\".env\") || name.startsWith(\".env\")) return \"bash\";\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 ? (EXT_LANGUAGE[name.slice(dot + 1)] ?? \"text\") : \"text\";\n}\n\n// --- word-level (intra-line) changes -----------------------------------------------------------\n\n/** Half-open character ranges `[start, end)` that changed within a line. */\nexport type Ranges = Array<[number, number]>;\n\nconst WORD_RE = /[A-Za-z0-9_$]+|\\s+|[=<>!&|+\\-*/%^~?:]+|[^A-Za-z0-9_$\\s]/g;\n\n/**\n * Character ranges that differ between an old and a new line, at word granularity (LCS over\n * word / space / punctuation tokens). Returns null when the lines are too different for word\n * highlights to help (then the whole line is the change).\n */\nexport function wordDiff(a: string, b: string): { old: Ranges; new: Ranges } | null {\n  if (a === b) return { old: [], new: [] };\n  const ta = a.match(WORD_RE) ?? [];\n  const tb = b.match(WORD_RE) ?? [];\n  if (ta.length * tb.length > 40_000) return null;\n  const n = ta.length;\n  const m = tb.length;\n  // LCS table, (n+1) × (m+1), filled from the end.\n  const dp = new Uint16Array((n + 1) * (m + 1));\n  const at = (i: number, j: number) => i * (m + 1) + j;\n  for (let i = n - 1; i >= 0; i--)\n    for (let j = m - 1; j >= 0; j--)\n      dp[at(i, j)] =\n        ta[i] === tb[j]\n          ? dp[at(i + 1, j + 1)]! + 1\n          : Math.max(dp[at(i + 1, j)]!, dp[at(i, j + 1)]!);\n  const keepA = new Array<boolean>(n).fill(false);\n  const keepB = new Array<boolean>(m).fill(false);\n  let i = 0;\n  let j = 0;\n  let common = 0;\n  while (i < n && j < m) {\n    if (ta[i] === tb[j]) {\n      keepA[i] = true;\n      keepB[j] = true;\n      if (/\\S/.test(ta[i]!)) common += ta[i]!.length;\n      i++;\n      j++;\n    } else if (dp[at(i + 1, j)]! >= dp[at(i, j + 1)]!) i++;\n    else j++;\n  }\n  // Too little in common: highlighting words would just paint the whole line.\n  const solid = (s: string) => s.replace(/\\s+/g, \"\").length;\n  if (common < 0.35 * Math.max(solid(a), solid(b))) return null;\n  const toRanges = (tokens: string[], keep: boolean[]): Ranges => {\n    const out: Ranges = [];\n    let pos = 0;\n    tokens.forEach((t, k) => {\n      if (!keep[k]) {\n        const last = out[out.length - 1];\n        // Merge across a single kept whitespace token so \"foo bar\" reads as one change.\n        if (last && last[1] === pos) last[1] = pos + t.length;\n        else out.push([pos, pos + t.length]);\n      }\n      pos += t.length;\n    });\n    // Join ranges separated only by whitespace.\n    const joined: Ranges = [];\n    const text = tokens.join(\"\");\n    for (const r of out) {\n      const prev = joined[joined.length - 1];\n      if (prev && /^\\s*$/.test(text.slice(prev[1], r[0]))) prev[1] = r[1];\n      else joined.push([r[0], r[1]]);\n    }\n    // Hug the changed tokens: no marks on leading/trailing whitespace.\n    return joined\n      .map(([start, end]): [number, number] => {\n        while (start < end && /\\s/.test(text[start]!)) start++;\n        while (end > start && /\\s/.test(text[end - 1]!)) end--;\n        return [start, end];\n      })\n      .filter(([start, end]) => end > start);\n  };\n  return { old: toRanges(ta, keepA), new: toRanges(tb, keepB) };\n}\n\n/**\n * Pair removed and added lines inside each change block of a hunk (a run of removals followed\n * by a run of additions) and compute word ranges for each pair. Keyed by line index.\n */\nexport function intraLineChanges(lines: readonly DiffLine[]): Map<number, Ranges> {\n  const out = new Map<number, Ranges>();\n  let k = 0;\n  while (k < lines.length) {\n    if (lines[k]!.kind !== \"remove\") {\n      k++;\n      continue;\n    }\n    const removes: number[] = [];\n    while (k < lines.length && lines[k]!.kind === \"remove\") removes.push(k++);\n    const adds: number[] = [];\n    while (k < lines.length && lines[k]!.kind === \"add\") adds.push(k++);\n    const pairs = Math.min(removes.length, adds.length);\n    for (let p = 0; p < pairs; p++) {\n      const r = removes[p]!;\n      const a = adds[p]!;\n      const d = wordDiff(lines[r]!.content, lines[a]!.content);\n      if (!d) continue;\n      if (d.old.length) out.set(r, d.old);\n      if (d.new.length) out.set(a, d.new);\n    }\n  }\n  return out;\n}\n"
        }
      ]
    },
    {
      "slug": "file-changes",
      "name": "FileChanges",
      "category": "workspace",
      "status": "experimental",
      "description": "The files an agent touched: a compact folder tree or flat list with A/M/D/R status, +/− counts, live “editing” markers and selection.",
      "whenToUse": [
        "Show which files a coding agent has changed during or after a run (a sidebar next to the conversation or a diff).",
        "Indicate live activity: which file the agent is reading or editing right now.",
        "A file picker for a review screen: select a file to show its diff in diff-view."
      ],
      "whenNotToUse": [
        "Showing the actual line changes — use diff-view (pair them: FileChanges as navigation, DiffView as content).",
        "A step-by-step log of the agent's actions — use agent-timeline or task-list.",
        "A general file browser or editor tree with create/rename/drag — this is a read-only change list."
      ],
      "props": [
        {
          "name": "files",
          "type": "readonly FileChange[]",
          "required": true,
          "description": "`{ path, status: \"A\" | \"M\" | \"D\" | \"R\", oldPath?, additions?, deletions?, state?: \"reading\" | \"editing\" }`. Paths are repo-relative with \"/\" separators."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Changes\"",
          "description": "Header title."
        },
        {
          "name": "view",
          "type": "\"tree\" | \"list\"",
          "description": "tree = folders (single-child chains compacted like VS Code, folders first); list = flat, file name then dimmed directory. Controlled."
        },
        {
          "name": "defaultView",
          "type": "\"tree\" | \"list\"",
          "default": "\"tree\"",
          "description": "Initial view when uncontrolled."
        },
        {
          "name": "onViewChange",
          "type": "(view: \"tree\" | \"list\") => void",
          "description": "Called when the header toggle switches views."
        },
        {
          "name": "viewToggle",
          "type": "boolean",
          "default": "true",
          "description": "Show the tree/list toggle in the header."
        },
        {
          "name": "selectedPath",
          "type": "string | null",
          "description": "The selected file path (controlled). Pass null for no selection."
        },
        {
          "name": "defaultSelectedPath",
          "type": "string | null",
          "default": "null",
          "description": "Initially selected file when uncontrolled."
        },
        {
          "name": "onSelect",
          "type": "(path: string) => void",
          "description": "Called when a file is chosen by click, Enter or Space."
        },
        {
          "name": "defaultCollapsed",
          "type": "readonly string[]",
          "description": "Folder paths collapsed initially, e.g. [\"src/auth/__tests__\"]."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "description": "Scroll the rows inside this height (px)."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra header controls."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Rendered when `files` is empty. Default an icon + “No changes yet”."
        }
      ],
      "parts": [
        {
          "name": "FileChanges",
          "description": "`data-view=\"tree\" | \"list\"`.",
          "dataSlot": "file-changes"
        },
        {
          "name": "Header",
          "description": "Title, summary (`file-changes-summary`: “7 files +33 −38”), actions, view toggle.",
          "dataSlot": "file-changes-header"
        },
        {
          "name": "Rows",
          "description": "role=\"tree\" (tree view) or role=\"listbox\" (list view).",
          "dataSlot": "file-changes-rows"
        },
        {
          "name": "Folder",
          "description": "treeitem with aria-expanded, `data-state=\"open\" | \"closed\"`; collapsed folders show their file count and a pulsing dot if a file inside is being edited.",
          "dataSlot": "file-changes-folder"
        },
        {
          "name": "File",
          "description": "treeitem/option with aria-selected, `data-status=\"A\" | \"M\" | \"D\" | \"R\"`, `data-state=\"reading\" | \"editing\"`, `data-selected`. Selected rows get bg-kura-active and a 2px accent bar.",
          "dataSlot": "file-changes-file"
        },
        {
          "name": "Name",
          "description": "Mono file name; shimmers while editing, struck through when deleted.",
          "dataSlot": "file-changes-name"
        },
        {
          "name": "Counts",
          "description": "+N −M in mono, or “editing” (accent, pulsing dot) / “reading”.",
          "dataSlot": "file-changes-counts"
        },
        {
          "name": "Status",
          "description": "Status letter: A green, M amber, D red, R muted; sr-only word (“, modified”).",
          "dataSlot": "file-changes-status"
        },
        {
          "name": "Guide",
          "description": "Hairline indent guides in the tree.",
          "dataSlot": "file-changes-guide"
        }
      ],
      "a11y": {
        "role": "tree | listbox",
        "notes": [
          "Tree view is a WAI-ARIA tree (treeitems with aria-level/posinset/setsize, aria-expanded on folders, aria-selected on files); list view is a listbox of options.",
          "One roving tab stop: Tab enters the list at the selected (or first) row.",
          "Each file's accessible name includes its status word and live state (“client.ts … , modified, being edited”).",
          "The view toggle buttons use aria-pressed and have tooltips."
        ],
        "keyboard": [
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Next / previous row"
          },
          {
            "keys": "ArrowRight",
            "action": "Expand a folder, or move into it"
          },
          {
            "keys": "ArrowLeft",
            "action": "Collapse a folder, or move to the parent folder"
          },
          {
            "keys": "Home / End",
            "action": "First / last row"
          },
          {
            "keys": "Enter / Space",
            "action": "Select a file, or toggle a folder"
          }
        ],
        "reducedMotion": "The editing pulse and name shimmer stop; row fade-in and chevron rotation are instant."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "icon-button",
          "shimmer-text"
        ]
      },
      "related": [
        "diff-view",
        "terminal",
        "agent-timeline",
        "task-list",
        "run-header"
      ],
      "tags": [
        "files",
        "changes",
        "git",
        "tree",
        "agent",
        "status",
        "sidebar",
        "workspace"
      ],
      "examples": [
        {
          "name": "FileChangesMinimal",
          "title": "Changed files",
          "description": "Seven files from the auth migration as a folder tree.",
          "kind": "minimal",
          "code": "import { type FileChange, FileChanges } from \"@kura-ui/react/file-changes\";\n\nconst CHANGES: FileChange[] = [\n  { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n  { path: \"src/auth/types.ts\", status: \"A\", additions: 6 },\n  { path: \"src/auth/__tests__/login.test.ts\", status: \"M\", additions: 7, deletions: 5 },\n  { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n  { path: \"src/lib/legacy-session.ts\", status: \"D\", deletions: 10 },\n  { path: \"src/routes/account/page.tsx\", status: \"M\", additions: 4, deletions: 4 },\n  { path: \"package.json\", status: \"M\", additions: 1, deletions: 1 },\n];\n\nexport function FileChangesMinimal() {\n  return <FileChanges files={CHANGES} className=\"w-full max-w-sm\" />;\n}\n"
        },
        {
          "name": "FileChangesFull",
          "title": "Live run",
          "description": "Files appear as the agent reads and edits them — the active one shimmers with an “editing” marker — then the run settles. Controlled selection.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { type FileChange, FileChanges } from \"@kura-ui/react/file-changes\";\n\nconst STEPS: FileChange[][] = [\n  [{ path: \"src/auth/client.ts\", status: \"M\", state: \"reading\" }],\n  [{ path: \"src/auth/client.ts\", status: \"M\", additions: 4, deletions: 6, state: \"editing\" }],\n  [\n    { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n    { path: \"src/middleware.ts\", status: \"M\", additions: 1, deletions: 1, state: \"editing\" },\n  ],\n  [\n    { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n    { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n    { path: \"src/auth/types.ts\", status: \"A\", additions: 3, state: \"editing\" },\n  ],\n  [\n    { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n    { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n    { path: \"src/auth/types.ts\", status: \"A\", additions: 6 },\n    {\n      path: \"src/auth/__tests__/login.test.ts\",\n      status: \"M\",\n      additions: 2,\n      deletions: 3,\n      state: \"editing\",\n    },\n  ],\n  [\n    { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n    { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n    { path: \"src/auth/types.ts\", status: \"A\", additions: 6 },\n    { path: \"src/auth/__tests__/login.test.ts\", status: \"M\", additions: 7, deletions: 5 },\n    { path: \"src/lib/legacy-session.ts\", status: \"D\", deletions: 10 },\n    {\n      path: \"src/routes/account/page.tsx\",\n      status: \"M\",\n      additions: 1,\n      deletions: 1,\n      state: \"editing\",\n    },\n  ],\n  CHANGES,\n];\n\nconst CHANGES: FileChange[] = [\n  { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n  { path: \"src/auth/types.ts\", status: \"A\", additions: 6 },\n  { path: \"src/auth/__tests__/login.test.ts\", status: \"M\", additions: 7, deletions: 5 },\n  { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n  { path: \"src/lib/legacy-session.ts\", status: \"D\", deletions: 10 },\n  { path: \"src/routes/account/page.tsx\", status: \"M\", additions: 4, deletions: 4 },\n  { path: \"package.json\", status: \"M\", additions: 1, deletions: 1 },\n];\n\nexport function FileChangesFull() {\n  const [step, setStep] = React.useState(0);\n  const [selected, setSelected] = React.useState<string | null>(\"src/auth/client.ts\");\n  React.useEffect(() => {\n    if (step >= STEPS.length - 1) return;\n    const id = setTimeout(() => setStep((s) => s + 1), step === 0 ? 1400 : 1800);\n    return () => clearTimeout(id);\n  }, [step]);\n  return (\n    <FileChanges\n      title=\"Changes\"\n      files={STEPS[step]!}\n      selectedPath={selected}\n      onSelect={setSelected}\n      className=\"w-full max-w-sm\"\n    />\n  );\n}\n"
        },
        {
          "name": "FileChangesList",
          "title": "Flat list",
          "description": "List view: file name first, directory dimmed.",
          "kind": "variant",
          "code": "import { type FileChange, FileChanges } from \"@kura-ui/react/file-changes\";\n\nconst CHANGES: FileChange[] = [\n  { path: \"src/auth/client.ts\", status: \"M\", additions: 13, deletions: 16 },\n  { path: \"src/auth/types.ts\", status: \"A\", additions: 6 },\n  { path: \"src/auth/__tests__/login.test.ts\", status: \"M\", additions: 7, deletions: 5 },\n  { path: \"src/middleware.ts\", status: \"M\", additions: 2, deletions: 2 },\n  { path: \"src/lib/legacy-session.ts\", status: \"D\", deletions: 10 },\n  { path: \"src/routes/account/page.tsx\", status: \"M\", additions: 4, deletions: 4 },\n  { path: \"package.json\", status: \"M\", additions: 1, deletions: 1 },\n];\n\nexport function FileChangesList() {\n  return (\n    <FileChanges\n      files={CHANGES}\n      defaultView=\"list\"\n      defaultSelectedPath=\"src/middleware.ts\"\n      className=\"w-full max-w-md\"\n    />\n  );\n}\n"
        },
        {
          "name": "FileChangesComposition",
          "title": "With DiffView",
          "description": "FileChanges as navigation; DiffView shows the selected file parsed from the same unified diff.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { DiffView, parseUnifiedDiff } from \"@kura-ui/react/diff-view\";\nimport { type FileChange, FileChanges } from \"@kura-ui/react/file-changes\";\n\nconst REVIEW_FILES = parseUnifiedDiff(AUTH_MIGRATION_DIFF);\n\nconst AUTH_MIGRATION_DIFF = `diff --git a/src/auth/client.ts b/src/auth/client.ts\nindex 887cccf..1c08d5a 100644\n--- a/src/auth/client.ts\n+++ b/src/auth/client.ts\n@@ -1,7 +1,8 @@\n import { cache } from \"react\";\n-import { fetchJson } from \"@/lib/http\";\n-import type { Session, User } from \"./types\";\n+import { createAuthClient, type Session, type User } from \"@acme/auth\";\n \n-const SESSION_URL = \"/api/v1/session\";\n-const REFRESH_URL = \"/api/v1/session/refresh\";\n+export const auth = createAuthClient({\n+  baseUrl: process.env.NEXT_PUBLIC_AUTH_URL,\n+  refresh: \"auto\",\n+});\n \n@@ -9,14 +10,10 @@ const REFRESH_URL = \"/api/v1/session/refresh\";\n export const fetchUser = cache(async (): Promise<User | null> => {\n-  const res = await fetchJson<Session>(SESSION_URL, { credentials: \"include\" });\n-  if (!res.ok) return null;\n-  return res.data.user;\n+  const session = await auth.getSession();\n+  return session?.user ?? null;\n });\n \n-export async function refreshSession(token: string): Promise<Session> {\n-  const res = await fetchJson<Session>(REFRESH_URL, {\n-    method: \"POST\",\n-    body: JSON.stringify({ token }),\n-  });\n-  if (!res.ok) throw new Error(\\`refresh failed: \\${res.status}\\`);\n-  return res.data;\n+export async function refreshSession(session: Session): Promise<Session> {\n+  const next = await auth.refresh(session.refreshToken);\n+  if (!next) throw new Error(\"refresh failed: session revoked\");\n+  return next;\n }\n@@ -24,3 +21,3 @@ export async function refreshSession(token: string): Promise<Session> {\n export function isExpired(session: Session, now = Date.now()): boolean {\n-  return session.expiresAt < now;\n+  return session.expiresAt <= now;\n }\n@@ -28,3 +25,3 @@ export function isExpired(session: Session, now = Date.now()): boolean {\n export async function signOut(): Promise<void> {\n-  await fetchJson(SESSION_URL, { method: \"DELETE\", credentials: \"include\" });\n+  await auth.signOut();\n }\ndiff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts\nindex 9fe0aaf..711a4bd 100644\n--- a/src/auth/__tests__/login.test.ts\n+++ b/src/auth/__tests__/login.test.ts\n@@ -1,4 +1,4 @@\n import { describe, expect, it, vi } from \"vitest\";\n-import { refreshSession, isExpired } from \"../client\";\n+import { auth, isExpired, refreshSession } from \"../client\";\n \n describe(\"login\", () => {\n@@ -8,8 +8,10 @@ describe(\"login\", () => {\n \n   it(\"refreshes an expired session\", async () => {\n-    vi.spyOn(globalThis, \"fetch\").mockResolvedValue(\n-      new Response(JSON.stringify({ expiresAt: 10, user: { id: \"u_1\" } })),\n-    );\n-    const next = await refreshSession(\"tok_expired\");\n+    vi.spyOn(auth, \"refresh\").mockResolvedValue({\n+      expiresAt: 10,\n+      refreshToken: \"tok_next\",\n+      user: { id: \"u_1\" },\n+    } as never);\n+    const next = await refreshSession({ refreshToken: \"tok_expired\" } as never);\n     expect(next.user.id).toBe(\"u_1\");\n   });\ndiff --git a/src/lib/legacy-session.ts b/src/lib/legacy-session.ts\ndeleted file mode 100644\nindex f0ae86d..0000000\n--- a/src/lib/legacy-session.ts\n+++ /dev/null\n@@ -1,10 +0,0 @@\n-/** @deprecated Use \\`auth.getSession()\\` from @acme/auth. */\n-export function readSessionCookie(cookie: string | null) {\n-  const raw = cookie?.match(/acme_session=([^;]+)/)?.[1];\n-  if (!raw) return null;\n-  try {\n-    return JSON.parse(atob(raw)) as { userId: string; expiresAt: number };\n-  } catch {\n-    return null;\n-  }\n-}\ndiff --git a/src/middleware.ts b/src/middleware.ts\nindex 0e93409..7eda2e2 100644\n--- a/src/middleware.ts\n+++ b/src/middleware.ts\n@@ -1,4 +1,4 @@\n import { NextResponse, type NextRequest } from \"next/server\";\n-import { readSessionCookie } from \"@/lib/legacy-session\";\n+import { auth } from \"@/auth/client\";\n \n const PUBLIC_PATHS = [\"/login\", \"/signup\", \"/api/health\"];\n@@ -8,5 +8,5 @@ export async function middleware(request: NextRequest) {\n   if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();\n \n-  const session = readSessionCookie(request.headers.get(\"cookie\"));\n+  const session = await auth.getSession({ headers: request.headers });\n   if (!session) {\n     const url = new URL(\"/login\", request.url);\n`;\n\nexport function FileChangesComposition() {\n  const [selected, setSelected] = React.useState(\"src/auth/client.ts\");\n  const files: FileChange[] = REVIEW_FILES.map((f) => {\n    const lines = f.hunks.flatMap((h) => h.lines);\n    return {\n      path: f.path,\n      status:\n        f.status === \"added\"\n          ? \"A\"\n          : f.status === \"deleted\"\n            ? \"D\"\n            : f.status === \"renamed\"\n              ? \"R\"\n              : \"M\",\n      additions: lines.filter((l) => l.kind === \"add\").length,\n      deletions: lines.filter((l) => l.kind === \"remove\").length,\n    };\n  });\n  const current = REVIEW_FILES.filter((f) => f.path === selected);\n  return (\n    <div className=\"grid w-full max-w-4xl items-start gap-3 md:grid-cols-[17rem_1fr]\">\n      <FileChanges\n        files={files}\n        selectedPath={selected}\n        onSelect={setSelected}\n        viewToggle={false}\n      />\n      <DiffView title={selected.split(\"/\").pop()} files={current} reviewable={false} wrap />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Map git porcelain codes directly: A/M/D/R (treat C copies as A, T type changes as M).",
        "Set `state: \"editing\"` on the file the agent is writing and clear it when done — the row shimmers and collapsed ancestor folders show a live dot.",
        "Selection is single and files-only; folders toggle. Use onSelect to drive a DiffView or editor beside it.",
        "Counts are optional; omit additions/deletions when unknown rather than passing 0."
      ],
      "title": "File Changes",
      "importPath": "@kura-ui/react/file-changes",
      "exports": [
        "FileChange",
        "FileChangeStatus",
        "FileChanges",
        "FileChangesProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/file-changes",
      "markdownUrl": "https://kuro.abstergo.fyi/components/file-changes.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/file-changes.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/file-changes.json",
        "cli": "npx @kura-ui/cli add file-changes",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "icon-button",
        "shimmer-text",
        "use-controllable-state",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "files": {
            "description": "`{ path, status: \"A\" | \"M\" | \"D\" | \"R\", oldPath?, additions?, deletions?, state?: \"reading\" | \"editing\" }`. Paths are repo-relative with \"/\" separators.",
            "x-ts-type": "readonly FileChange[]",
            "type": "array"
          },
          "title": {
            "description": "Header title.",
            "x-ts-type": "React.ReactNode",
            "default": "Changes"
          },
          "view": {
            "description": "tree = folders (single-child chains compacted like VS Code, folders first); list = flat, file name then dimmed directory. Controlled.",
            "x-ts-type": "\"tree\" | \"list\"",
            "type": "string",
            "enum": [
              "list",
              "tree"
            ]
          },
          "defaultView": {
            "description": "Initial view when uncontrolled.",
            "x-ts-type": "\"tree\" | \"list\"",
            "type": "string",
            "enum": [
              "list",
              "tree"
            ],
            "default": "tree"
          },
          "onViewChange": {
            "description": "Called when the header toggle switches views.",
            "x-ts-type": "(view: \"tree\" | \"list\") => void"
          },
          "viewToggle": {
            "description": "Show the tree/list toggle in the header.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "selectedPath": {
            "description": "The selected file path (controlled). Pass null for no selection.",
            "x-ts-type": "string | null"
          },
          "defaultSelectedPath": {
            "description": "Initially selected file when uncontrolled.",
            "x-ts-type": "string | null",
            "default": null
          },
          "onSelect": {
            "description": "Called when a file is chosen by click, Enter or Space.",
            "x-ts-type": "(path: string) => void"
          },
          "defaultCollapsed": {
            "description": "Folder paths collapsed initially, e.g. [\"src/auth/__tests__\"].",
            "x-ts-type": "readonly string[]",
            "type": "array"
          },
          "maxHeight": {
            "description": "Scroll the rows inside this height (px).",
            "x-ts-type": "number",
            "type": "number"
          },
          "actions": {
            "description": "Extra header controls.",
            "x-ts-type": "React.ReactNode"
          },
          "empty": {
            "description": "Rendered when `files` is empty. Default an icon + “No changes yet”.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [
          "files"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/file-changes/file-changes.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ChevronRight, FileDiff, List, ListTree } 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 { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type FileChangeStatus = \"A\" | \"M\" | \"D\" | \"R\";\n\nexport interface FileChange {\n  /** Path relative to the repo root, e.g. \"src/auth/client.ts\". */\n  path: string;\n  /** A = added, M = modified, D = deleted, R = renamed. */\n  status: FileChangeStatus;\n  /** Previous path for renames. */\n  oldPath?: string;\n  additions?: number;\n  deletions?: number;\n  /** The agent is touching this file right now. */\n  state?: \"reading\" | \"editing\";\n}\n\nexport interface FileChangesProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onSelect\"> {\n  /** The files the agent touched. */\n  files: readonly FileChange[];\n  /** Header title. Default \"Changes\". */\n  title?: React.ReactNode;\n  /** \"tree\" groups by folder (single-child folders compacted, like VS Code); \"list\" is flat. Controlled. */\n  view?: \"tree\" | \"list\";\n  /** Initial view when uncontrolled. Default \"tree\". */\n  defaultView?: \"tree\" | \"list\";\n  onViewChange?: (view: \"tree\" | \"list\") => void;\n  /** Show the tree/list toggle in the header. Default true. */\n  viewToggle?: boolean;\n  /** The selected file (controlled). */\n  selectedPath?: string | null;\n  /** Initially selected file when uncontrolled. */\n  defaultSelectedPath?: string | null;\n  /** Called when a file is selected (click, Enter or Space). */\n  onSelect?: (path: string) => void;\n  /** Folder paths collapsed initially, e.g. [\"src/auth/__tests__\"]. */\n  defaultCollapsed?: readonly string[];\n  /** Scroll the rows inside this height (px). */\n  maxHeight?: number;\n  /** Extra header controls. */\n  actions?: React.ReactNode;\n  /** Rendered when `files` is empty. Default \"No changes yet\". */\n  empty?: React.ReactNode;\n}\n\nconst STATUS_CLASS: Record<FileChangeStatus, string> = {\n  A: \"text-kura-success-text\",\n  M: \"text-kura-warning-text\",\n  D: \"text-kura-danger-text\",\n  R: \"text-kura-fg-muted\",\n};\nconst STATUS_WORD: Record<FileChangeStatus, string> = {\n  A: \"added\",\n  M: \"modified\",\n  D: \"deleted\",\n  R: \"renamed\",\n};\n\ninterface FolderNode {\n  kind: \"folder\";\n  /** Full path, e.g. \"src/auth\". */\n  path: string;\n  /** Display name; compacted chains read \"src/auth\". */\n  name: string;\n  children: TreeNode[];\n}\ninterface FileNode {\n  kind: \"file\";\n  path: string;\n  name: string;\n  file: FileChange;\n}\ntype TreeNode = FolderNode | FileNode;\n\nfunction buildTree(files: readonly FileChange[]): TreeNode[] {\n  const root: FolderNode = { kind: \"folder\", path: \"\", name: \"\", children: [] };\n  for (const file of files) {\n    const parts = file.path.split(\"/\").filter(Boolean);\n    let node = root;\n    parts.slice(0, -1).forEach((part) => {\n      const path = node.path ? `${node.path}/${part}` : part;\n      let next = node.children.find((c): c is FolderNode => c.kind === \"folder\" && c.path === path);\n      if (!next) {\n        next = { kind: \"folder\", path, name: part, children: [] };\n        node.children.push(next);\n      }\n      node = next;\n    });\n    node.children.push({\n      kind: \"file\",\n      path: file.path,\n      name: parts[parts.length - 1] ?? file.path,\n      file,\n    });\n  }\n  const compact = (n: TreeNode): TreeNode => {\n    if (n.kind === \"file\") return n;\n    let folder = n;\n    while (folder.children.length === 1 && folder.children[0]!.kind === \"folder\") {\n      const only = folder.children[0] as FolderNode;\n      folder = { ...only, name: `${folder.name}/${only.name}` };\n    }\n    const children = folder.children\n      .map(compact)\n      .sort((a, b) =>\n        a.kind !== b.kind ? (a.kind === \"folder\" ? -1 : 1) : a.name.localeCompare(b.name),\n      );\n    return { ...folder, children };\n  };\n  return root.children\n    .map(compact)\n    .sort((a, b) =>\n      a.kind !== b.kind ? (a.kind === \"folder\" ? -1 : 1) : a.name.localeCompare(b.name),\n    );\n}\n\ninterface Row {\n  node: TreeNode;\n  depth: number;\n  posinset: number;\n  setsize: number;\n}\n\nfunction flatten(nodes: TreeNode[], collapsed: Set<string>, depth = 0, out: Row[] = []): Row[] {\n  nodes.forEach((node, i) => {\n    out.push({ node, depth, posinset: i + 1, setsize: nodes.length });\n    if (node.kind === \"folder\" && !collapsed.has(node.path))\n      flatten(node.children, collapsed, depth + 1, out);\n  });\n  return out;\n}\n\nfunction folderStats(node: FolderNode): { files: number; live: boolean } {\n  let files = 0;\n  let live = false;\n  const walk = (n: TreeNode) => {\n    if (n.kind === \"file\") {\n      files++;\n      if (n.file.state === \"editing\") live = true;\n    } else n.children.forEach(walk);\n  };\n  walk(node);\n  return { files, live };\n}\n\nfunction Counts({ file }: { file: FileChange }) {\n  if (file.state === \"editing\")\n    return (\n      <span className=\"inline-flex items-center gap-1.5 text-kura-accent-text\">\n        <span\n          aria-hidden\n          className=\"size-1.5 rounded-full bg-kura-accent animate-kura-pulse motion-reduce:animate-none\"\n        />\n        editing\n      </span>\n    );\n  if (file.state === \"reading\") return <span className=\"text-kura-fg-subtle\">reading</span>;\n  if (file.additions == null && file.deletions == null) return null;\n  return (\n    <>\n      {file.additions != null && file.additions > 0 && (\n        <span className=\"text-kura-success-text\">+{file.additions}</span>\n      )}\n      {file.deletions != null && file.deletions > 0 && (\n        <span className=\"text-kura-danger-text\">−{file.deletions}</span>\n      )}\n    </>\n  );\n}\n\n/**\n * What the agent touched: a compact tree (or flat list) of changed files with status letters\n * (A/M/D/R in semantic colour), +/− counts, a live \"editing\" marker on files being written, a\n * summary header, single selection and full tree keyboard navigation.\n */\nexport function FileChanges({\n  files,\n  title = \"Changes\",\n  view: viewProp,\n  defaultView = \"tree\",\n  onViewChange,\n  viewToggle = true,\n  selectedPath: selectedProp,\n  defaultSelectedPath = null,\n  onSelect,\n  defaultCollapsed,\n  maxHeight,\n  actions,\n  empty,\n  className,\n  ...props\n}: FileChangesProps) {\n  const [view, setView] = useControllableState({\n    value: viewProp,\n    defaultValue: defaultView,\n    onChange: onViewChange,\n  });\n  const [selected, setSelected] = useControllableState<string | null>({\n    value: selectedProp,\n    defaultValue: defaultSelectedPath,\n  });\n  const [collapsed, setCollapsed] = React.useState(() => new Set(defaultCollapsed ?? []));\n\n  const tree = React.useMemo(() => buildTree(files), [files]);\n  const rows: Row[] = React.useMemo(\n    () =>\n      view === \"tree\"\n        ? flatten(tree, collapsed)\n        : [...files]\n            .sort((a, b) => a.path.localeCompare(b.path))\n            .map((file, i, all) => ({\n              node: {\n                kind: \"file\" as const,\n                path: file.path,\n                name: file.path.slice(file.path.lastIndexOf(\"/\") + 1),\n                file,\n              },\n              depth: 0,\n              posinset: i + 1,\n              setsize: all.length,\n            })),\n    [view, tree, collapsed, files],\n  );\n\n  const totals = React.useMemo(\n    () =>\n      files.reduce(\n        (acc, f) => ({\n          additions: acc.additions + (f.additions ?? 0),\n          deletions: acc.deletions + (f.deletions ?? 0),\n        }),\n        { additions: 0, deletions: 0 },\n      ),\n    [files],\n  );\n\n  // Roving focus.\n  const [focusKey, setFocusKey] = React.useState<string | null>(null);\n  const rowKey = (r: Row) => `${r.node.kind}:${r.node.path}`;\n  const tabStop =\n    rows.find((r) => rowKey(r) === focusKey) ??\n    rows.find((r) => r.node.path === selected && r.node.kind === \"file\") ??\n    rows[0];\n  const rowEls = React.useRef(new Map<string, HTMLElement>());\n  const focusRow = (r: Row | undefined) => {\n    if (!r) return;\n    const key = rowKey(r);\n    setFocusKey(key);\n    rowEls.current.get(key)?.focus();\n  };\n\n  const toggleFolder = (path: string, open?: boolean) =>\n    setCollapsed((prev) => {\n      const next = new Set(prev);\n      const isOpen = !next.has(path);\n      if ((open ?? !isOpen) === isOpen) return prev;\n      if (isOpen) next.add(path);\n      else next.delete(path);\n      return next;\n    });\n\n  const select = (path: string) => {\n    setSelected(path);\n    onSelect?.(path);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, row: Row, index: number) => {\n    const { node } = row;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusRow(rows[index + 1]);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusRow(rows[index - 1]);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusRow(rows[0]);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusRow(rows[rows.length - 1]);\n        break;\n      case \"ArrowRight\":\n        if (node.kind !== \"folder\") return;\n        event.preventDefault();\n        if (collapsed.has(node.path)) toggleFolder(node.path, true);\n        else focusRow(rows[index + 1]);\n        break;\n      case \"ArrowLeft\": {\n        event.preventDefault();\n        if (node.kind === \"folder\" && !collapsed.has(node.path)) {\n          toggleFolder(node.path, false);\n          break;\n        }\n        for (let k = index - 1; k >= 0; k--)\n          if (rows[k]!.depth < row.depth) {\n            focusRow(rows[k]);\n            break;\n          }\n        break;\n      }\n      case \"Enter\":\n      case \" \":\n        event.preventDefault();\n        if (node.kind === \"folder\") toggleFolder(node.path);\n        else select(node.path);\n        break;\n    }\n  };\n\n  const isTree = view === \"tree\";\n\n  return (\n    <div\n      data-slot=\"file-changes\"\n      data-view={view}\n      className={cn(\n        \"@container/files flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-[13px] text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"file-changes-header\"\n        className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b pr-1.5 pl-3.5\"\n      >\n        <span className=\"min-w-[3rem] truncate font-medium tracking-[-0.006em]\">{title}</span>\n        <span\n          data-slot=\"file-changes-summary\"\n          className=\"flex shrink-0 items-baseline gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n        >\n          <span className=\"hidden truncate @[20rem]/files:inline\">\n            {files.length} {files.length === 1 ? \"file\" : \"files\"}\n          </span>\n          {(totals.additions > 0 || totals.deletions > 0) && (\n            <span className=\"flex shrink-0 gap-1.5\">\n              <span className=\"text-kura-success-text\">+{totals.additions}</span>\n              <span className=\"text-kura-danger-text\">−{totals.deletions}</span>\n              <span className=\"sr-only\">\n                {totals.additions} additions, {totals.deletions} deletions\n              </span>\n            </span>\n          )}\n        </span>\n        <span className=\"flex-1\" />\n        {actions}\n        {viewToggle && files.length > 0 && (\n          <span className=\"flex shrink-0 items-center gap-0.5\">\n            <IconButton\n              size=\"xs\"\n              label=\"Tree view\"\n              aria-pressed={isTree}\n              icon={<ListTree strokeWidth={1.75} />}\n              className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n              onClick={() => setView(\"tree\")}\n            />\n            <IconButton\n              size=\"xs\"\n              label=\"List view\"\n              aria-pressed={!isTree}\n              icon={<List strokeWidth={1.75} />}\n              className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n              onClick={() => setView(\"list\")}\n            />\n          </span>\n        )}\n      </div>\n\n      {files.length === 0 ? (\n        <div\n          data-slot=\"file-changes-empty\"\n          className=\"flex h-24 flex-col items-center justify-center gap-1.5 text-kura-fg-subtle\"\n        >\n          {empty ?? (\n            <>\n              <FileDiff aria-hidden className=\"size-4\" strokeWidth={1.75} />\n              <span>No changes yet</span>\n            </>\n          )}\n        </div>\n      ) : (\n        // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is tree or listbox, both take a label\n        <div\n          data-slot=\"file-changes-rows\"\n          role={isTree ? \"tree\" : \"listbox\"}\n          aria-label={typeof title === \"string\" ? title : \"Changed files\"}\n          className={cn(\n            \"py-1\",\n            maxHeight != null && \"overflow-y-auto overscroll-contain [scrollbar-width:thin]\",\n          )}\n          style={maxHeight != null ? { maxHeight } : undefined}\n        >\n          {rows.map((row, index) => {\n            const { node } = row;\n            const key = rowKey(row);\n            const focusable = tabStop && rowKey(tabStop) === key;\n            const indent = 10 + row.depth * 14;\n            const guides = isTree\n              ? Array.from({ length: row.depth }, (_, k) => (\n                  <span\n                    key={k}\n                    aria-hidden\n                    data-slot=\"file-changes-guide\"\n                    className=\"pointer-events-none absolute inset-y-0 w-px bg-kura-border-subtle\"\n                    style={{ left: 10 + k * 14 + 6.5 }}\n                  />\n                ))\n              : null;\n            const ref = (el: HTMLDivElement | null) => {\n              if (el) rowEls.current.set(key, el);\n              else rowEls.current.delete(key);\n            };\n            const rowClass =\n              \"relative flex h-8 cursor-pointer select-none items-center gap-1.5 pr-3 outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)] motion-reduce:transition-none\";\n\n            if (node.kind === \"folder\") {\n              const open = !collapsed.has(node.path);\n              const stats = folderStats(node);\n              return (\n                <div\n                  key={key}\n                  ref={ref}\n                  tabIndex={focusable ? 0 : -1}\n                  onFocus={() => setFocusKey(key)}\n                  onKeyDown={(e) => onKeyDown(e, row, index)}\n                  style={{ paddingLeft: indent }}\n                  role=\"treeitem\"\n                  aria-level={row.depth + 1}\n                  aria-posinset={row.posinset}\n                  aria-setsize={row.setsize}\n                  aria-expanded={open}\n                  data-slot=\"file-changes-folder\"\n                  data-state={open ? \"open\" : \"closed\"}\n                  onClick={() => {\n                    setFocusKey(key);\n                    toggleFolder(node.path);\n                  }}\n                  className={cn(rowClass, \"text-kura-fg-muted\")}\n                >\n                  {guides}\n                  <ChevronRight\n                    aria-hidden\n                    strokeWidth={1.75}\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                  <span className=\"min-w-0 truncate font-kura-mono text-[12.5px]\">{node.name}</span>\n                  {!open && (\n                    <span className=\"ml-auto flex shrink-0 items-center gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n                      {stats.live && (\n                        <span\n                          aria-label=\"contains a file being edited\"\n                          role=\"img\"\n                          className=\"size-1.5 rounded-full bg-kura-accent animate-kura-pulse motion-reduce:animate-none\"\n                        />\n                      )}\n                      {stats.files}\n                    </span>\n                  )}\n                </div>\n              );\n            }\n\n            const { file } = node;\n            const isSelected = selected === file.path;\n            const dir = file.path.slice(0, file.path.length - node.name.length);\n            const live = file.state === \"editing\";\n            return (\n              // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is treeitem (tree view) or option (list view); aria-level is only set for treeitem\n              // biome-ignore lint/a11y/noStaticElementInteractions: role is treeitem or option (an expression biome cannot resolve)\n              <div\n                key={key}\n                ref={ref}\n                tabIndex={focusable ? 0 : -1}\n                onFocus={() => setFocusKey(key)}\n                onKeyDown={(e) => onKeyDown(e, row, index)}\n                style={{ paddingLeft: indent }}\n                role={isTree ? \"treeitem\" : \"option\"}\n                aria-level={isTree ? row.depth + 1 : undefined}\n                aria-posinset={row.posinset}\n                aria-setsize={row.setsize}\n                aria-selected={isSelected}\n                data-slot=\"file-changes-file\"\n                data-status={file.status}\n                data-state={file.state}\n                data-selected={isSelected || undefined}\n                onClick={() => {\n                  setFocusKey(key);\n                  select(file.path);\n                }}\n                className={cn(\n                  rowClass,\n                  \"animate-kura-fade-in\",\n                  isSelected && \"bg-kura-active hover:bg-kura-active\",\n                )}\n                title={file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}\n              >\n                {guides}\n                {isSelected && (\n                  <span\n                    aria-hidden\n                    className=\"absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-kura-accent\"\n                  />\n                )}\n                {isTree && <span aria-hidden className=\"w-3.5 shrink-0\" />}\n                <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                  <span\n                    data-slot=\"file-changes-name\"\n                    className={cn(\n                      \"min-w-0 shrink truncate font-kura-mono text-[12.5px]\",\n                      file.status === \"D\"\n                        ? \"text-kura-fg-subtle line-through decoration-kura-fg-subtle/60\"\n                        : \"text-kura-fg\",\n                    )}\n                  >\n                    {live ? <ShimmerText>{node.name}</ShimmerText> : node.name}\n                  </span>\n                  {!isTree && dir && (\n                    <span className=\"min-w-0 flex-1 truncate text-left font-kura-mono text-[12px] text-kura-fg-subtle [direction:rtl]\">\n                      <span className=\"[direction:ltr] [unicode-bidi:isolate]\">\n                        {dir.slice(0, -1)}\n                      </span>\n                    </span>\n                  )}\n                </span>\n                <span\n                  data-slot=\"file-changes-counts\"\n                  className=\"flex shrink-0 items-center gap-1.5 pl-2 font-kura-mono text-[12px] tabular-nums\"\n                >\n                  <Counts file={file} />\n                </span>\n                <span\n                  data-slot=\"file-changes-status\"\n                  className={cn(\n                    \"w-3 shrink-0 text-right font-kura-mono font-medium text-[12px]\",\n                    STATUS_CLASS[file.status],\n                  )}\n                >\n                  <span aria-hidden>{file.status}</span>\n                  <span className=\"sr-only\">\n                    , {STATUS_WORD[file.status]}\n                    {live ? \", being edited\" : file.state === \"reading\" ? \", being read\" : \"\"}\n                  </span>\n                </span>\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n"
        },
        {
          "target": "components/kura/file-changes/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type FileChange,\n  type FileChangeStatus,\n  FileChanges,\n  type FileChangesProps,\n} from \"./file-changes\";\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, Copy, Download, Mic, Paperclip, Pin, Plus, RotateCcw, Share, Trash2 } from \"lucide-react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\n\nexport function IconButtonFull() {\n  return (\n    <div className=\"flex flex-col items-center gap-4\">\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 aria-hidden 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      </div>\n      <div className=\"flex items-center gap-2\">\n        <IconButton label=\"Copy\" icon={<Copy />} />\n        <IconButton label=\"Download\" icon={<Download />} variant=\"secondary\" />\n        <IconButton label=\"Share\" icon={<Share />} variant=\"outline\" />\n        <IconButton\n          label=\"New chat\"\n          icon={<Plus />}\n          variant=\"primary\"\n          shortcut={[\"mod\", \"shift\", \"o\"]}\n        />\n        <IconButton label=\"Delete\" icon={<Trash2 />} variant=\"outline\" disabled />\n        <IconButton label=\"Pin\" icon={<Pin />} size=\"xs\" tooltip={false} />\n      </div>\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 flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[13px]\">\n        <span className=\"flex items-center gap-2\">\n          Run task <Kbd keys={[\"mod\", \"shift\", \"p\"]} size=\"sm\" />\n        </span>\n        <span className=\"flex items-center gap-2\">\n          Insert line <Kbd keys={[\"alt\", \"enter\"]} variant=\"ghost\" />\n        </span>\n        <span className=\"flex items-center gap-2\">\n          Edit last <Kbd keys={[\"up\"]} pressed />\n        </span>\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 tracking-normal \" +\n  \"transition-[transform,background-color,box-shadow,color] duration-(--kura-duration-instant) ease-kura-out\";\n\nconst keyVariants = {\n  // A soft grey cap with a hairline and a 1px bottom edge: reads as a key without skeuomorphism.\n  raised:\n    \"rounded-kura-xs bg-kura-gray-2 text-kura-fg-muted shadow-[inset_0_0_0_1px_var(--kura-border),inset_0_-1px_0_0_var(--kura-border)] \" +\n    \"data-[pressed=true]:translate-y-px data-[pressed=true]:bg-kura-gray-4 data-[pressed=true]:text-kura-fg data-[pressed=true]:shadow-[inset_0_0_0_1px_var(--kura-border-strong)] 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": "primitives",
      "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": "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 bubble (a translucent fg tint that separates from any surface, in both themes); 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        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 bubble (a translucent fg tint that separates from any surface, in both themes); 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              ? // A translucent fg tint (not a grey step) so the bubble separates from canvas, surface\n                // and surface-raised alike, in both themes.\n                \"w-fit max-w-[min(80%,36rem)] whitespace-pre-wrap break-words rounded-kura-2xl bg-[color-mix(in_oklab,var(--kura-fg)_7%,transparent)] 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": "popover",
      "name": "Popover",
      "category": "primitives",
      "status": "stable",
      "description": "Floating panel (opaque raised surface, hairline ring, soft shadow) 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 { Gauge } from \"lucide-react\";\nimport { 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=\"outline\">\n          <Gauge /> Usage\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent aria-labelledby=\"usage-title\" className=\"w-64\">\n        <div className=\"flex flex-col gap-2.5\">\n          <div className=\"flex items-baseline justify-between\">\n            <h3 id=\"usage-title\" className=\"font-medium text-sm\">\n              Weekly usage\n            </h3>\n            <span className=\"text-kura-fg-muted text-xs tabular-nums\">38 / 50</span>\n          </div>\n          <div className=\"h-1.5 overflow-hidden rounded-full bg-kura-gray-4\">\n            <div className=\"h-full w-[76%] rounded-full bg-kura-ink\" />\n          </div>\n          <p className=\"text-kura-fg-muted text-xs leading-relaxed\">\n            Opus messages reset on Monday at 09:00. Haiku is unlimited.\n          </p>\n        </div>\n      </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\nconst efforts = [\"Low\", \"Medium\", \"High\"] as const;\n\nexport function PopoverFull() {\n  const [open, setOpen] = React.useState(false);\n  const [effort, setEffort] = React.useState<(typeof efforts)[number]>(\"Medium\");\n  const [temperature, setTemperature] = React.useState(0.7);\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button variant=\"outline\">\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-72 p-0\"\n      >\n        <div className=\"flex flex-col gap-4 p-4\">\n          <div className=\"flex flex-col gap-0.5\">\n            <h3 id=\"gen-settings-title\" className=\"font-medium text-sm\">\n              Generation\n            </h3>\n            <p className=\"text-kura-fg-muted text-xs\">Applies to new replies in this chat.</p>\n          </div>\n          <fieldset className=\"flex flex-col\">\n            <legend className=\"mb-2 font-medium text-kura-fg-muted text-xs\">\n              Reasoning effort\n            </legend>\n            <div className=\"grid grid-cols-3 gap-0.5 rounded-kura-md bg-kura-surface-sunken p-0.5 shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\">\n              {efforts.map((option) => (\n                <label\n                  key={option}\n                  className=\"flex h-7 cursor-pointer items-center justify-center rounded-kura-sm font-medium text-kura-fg-muted text-xs transition-[background-color,color,box-shadow] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg has-checked:bg-kura-surface-raised has-checked:text-kura-fg has-checked:shadow-kura-sm has-focus-visible:shadow-kura-focus\"\n                >\n                  <input\n                    type=\"radio\"\n                    name=\"reasoning-effort\"\n                    value={option}\n                    checked={effort === option}\n                    onChange={() => setEffort(option)}\n                    className=\"sr-only\"\n                  />\n                  {option}\n                </label>\n              ))}\n            </div>\n          </fieldset>\n          <label className=\"flex flex-col gap-2\">\n            <span className=\"flex items-center justify-between font-medium text-kura-fg-muted text-xs\">\n              Temperature\n              <span className=\"font-kura-mono text-kura-fg tabular-nums\">\n                {temperature.toFixed(1)}\n              </span>\n            </span>\n            <input\n              type=\"range\"\n              min={0}\n              max={1}\n              step={0.1}\n              value={temperature}\n              onChange={(e) => setTemperature(Number(e.target.value))}\n              className=\"h-4 w-full cursor-pointer accent-kura-ink\"\n            />\n            <span className=\"flex justify-between text-[11px] text-kura-fg-subtle\">\n              <span>Precise</span>\n              <span>Creative</span>\n            </span>\n          </label>\n        </div>\n        <div className=\"flex items-center justify-end gap-2 border-kura-border-subtle border-t px-4 py-3\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              setEffort(\"Medium\");\n              setTemperature(0.7);\n            }}\n          >\n            Reset\n          </Button>\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. Opaque raised surface with a hairline ring and a soft shadow, origin-aware\n * 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-xl bg-kura-surface-raised p-3 font-kura-sans text-kura-fg text-sm shadow-kura-lg outline-none\",\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-queue",
      "name": "PromptQueue",
      "category": "agent",
      "status": "experimental",
      "description": "Stack of follow-up prompts queued while an agent works: reorder, edit inline, remove, or Send now to steer. Renders nothing when empty.",
      "whenToUse": [
        "The user types follow-ups while an agent is mid-turn (Claude Code / Codex queued messages) and they should send in order when it finishes.",
        "Letting the user steer: interrupt the agent and send one queued prompt immediately.",
        "Directly above a composer (variant='tray') so queued prompts read as part of the input."
      ],
      "whenNotToUse": [
        "Suggested prompts the user has not written — use prompt-suggestions.",
        "The prompt input itself — use composer (and put PromptQueue above it).",
        "Work the agent plans to do — use task-list."
      ],
      "props": [
        {
          "name": "items",
          "type": "QueuedPrompt[]",
          "description": "Queued prompts in send order (controlled): { id, text, attachments?: { name, type?: 'image' | 'file' }[] }."
        },
        {
          "name": "defaultItems",
          "type": "QueuedPrompt[]",
          "description": "Initial prompts when uncontrolled."
        },
        {
          "name": "onItemsChange",
          "type": "(items: QueuedPrompt[]) => void",
          "description": "Called with the whole new list after a reorder, edit, remove or Send now. Fires on every step while dragging."
        },
        {
          "name": "onSendNow",
          "type": "(item: QueuedPrompt) => void",
          "description": "Steer: interrupt the agent and send this prompt now. The prompt is removed from the queue first. Without it no Send now button renders."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Queued\"",
          "description": "Header title; the count follows it."
        },
        {
          "name": "hint",
          "type": "React.ReactNode",
          "default": "\"Sends when the agent finishes this turn\"",
          "description": "Secondary header text, e.g. 'Sends when Kura finishes running pnpm test'. null hides it."
        },
        {
          "name": "variant",
          "type": "\"panel\" | \"tray\"",
          "default": "\"panel\"",
          "description": "panel = standalone card; tray = sunken, rounded on top only, meant to sit flush on top of a Composer (inset it with className='mx-3')."
        }
      ],
      "parts": [
        {
          "name": "PromptQueue",
          "description": "Root with `data-variant`.",
          "dataSlot": "prompt-queue"
        },
        {
          "name": "Header",
          "description": "Title, count and prompt-queue-hint.",
          "dataSlot": "prompt-queue-header"
        },
        {
          "name": "Item",
          "description": "Focusable <li> (`data-state` = editing | dragging): index (grip on hover), text, attachments, actions.",
          "dataSlot": "prompt-queue-item"
        },
        {
          "name": "Handle",
          "description": "Drag grip shown in place of the index on hover (pointer only; keyboard uses Alt+arrows or Space).",
          "dataSlot": "prompt-queue-handle"
        },
        {
          "name": "Text",
          "description": "One truncated line; the full text is the title tooltip.",
          "dataSlot": "prompt-queue-text"
        },
        {
          "name": "Attachments",
          "description": "First attachment's icon and name in mono, then '+N'.",
          "dataSlot": "prompt-queue-attachments"
        },
        {
          "name": "Actions",
          "description": "Move up, Move down, Edit, Remove and prompt-queue-send ('Send now'); shown on hover/focus, always on touch.",
          "dataSlot": "prompt-queue-actions"
        },
        {
          "name": "Editor",
          "description": "Auto-growing textarea replacing the row while editing, with Cancel/Save.",
          "dataSlot": "prompt-queue-editor"
        }
      ],
      "a11y": {
        "role": "list (ol) of focusable items",
        "notes": [
          "The list is labelled by the title and described by visually hidden keyboard help. Items use a roving tabindex (one Tab stop) and are named '2 of 3: <text>'.",
          "A polite status region announces moves ('Moved to position 1 of 3'), removals and Send now. Actions are icon buttons with tooltips.",
          "Escape in the editor cancels without reaching the composer's own Escape (stop) handler."
        ],
        "keyboard": [
          {
            "keys": "ArrowUp / ArrowDown",
            "action": "Move focus between prompts"
          },
          {
            "keys": "Alt+ArrowUp / Alt+ArrowDown",
            "action": "Move the focused prompt up / down"
          },
          {
            "keys": "Space, then arrows, then Space",
            "action": "Grab, move and drop (Escape restores)"
          },
          {
            "keys": "Enter / F2",
            "action": "Edit inline (Enter saves, Shift+Enter newline, Escape cancels)"
          },
          {
            "keys": "Delete / Backspace",
            "action": "Remove"
          },
          {
            "keys": "Mod+Enter",
            "action": "Send now"
          }
        ],
        "reducedMotion": "Reorders jump into place instead of sliding; the drag still follows the pointer."
      },
      "dependencies": {
        "npm": [
          "lucide-react",
          "motion"
        ],
        "kura": [
          "button",
          "icon-button"
        ]
      },
      "related": [
        "composer",
        "run-header",
        "prompt-suggestions",
        "task-list"
      ],
      "tags": [
        "agent",
        "queue",
        "prompts",
        "follow-up",
        "steer",
        "reorder",
        "drag",
        "composer"
      ],
      "examples": [
        {
          "name": "PromptQueueMinimal",
          "title": "Two queued prompts",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport { PromptQueue } from \"@kura-ui/react/prompt-queue\";\n\nexport function PromptQueueMinimal() {\n  return (\n    <PromptQueue\n      className=\"w-full max-w-xl\"\n      defaultItems={[\n        { id: \"readme\", text: \"When tests pass, update the auth section of the README\" },\n        { id: \"legacy\", text: \"Don't touch src/legacy/ — it's deleted next sprint\" },\n      ]}\n      onSendNow={() => {}}\n    />\n  );\n}\n"
        },
        {
          "name": "PromptQueueFull",
          "title": "Controlled, with steering",
          "description": "Attachments, a prompt that arrives after mount, and Send now reported below.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { CornerDownRight } from \"lucide-react\";\nimport * as React from \"react\";\nimport { PromptQueue, type QueuedPrompt } from \"@kura-ui/react/prompt-queue\";\n\nconst QUEUE: QueuedPrompt[] = [\n  {\n    id: \"helper\",\n    text: \"Use the refreshSession() helper instead of calling fetch('/api/session') directly\",\n    attachments: [{ name: \"session-flow.png\", type: \"image\" }],\n  },\n  {\n    id: \"legacy\",\n    text: \"Don't touch src/legacy/ — it's being deleted next sprint\",\n  },\n  {\n    id: \"readme\",\n    text: \"When tests pass, update the auth section of the README and link the migration guide\",\n    attachments: [\n      { name: \"MIGRATION.md\", type: \"file\" },\n      { name: \"auth-diagram.png\", type: \"image\" },\n    ],\n  },\n];\n\nexport function PromptQueueFull() {\n  const [items, setItems] = React.useState<QueuedPrompt[]>(QUEUE);\n  const [steered, setSteered] = React.useState<string | null>(null);\n\n  // A teammate queues one more follow-up a moment after mount.\n  React.useEffect(() => {\n    const t = setTimeout(\n      () =>\n        setItems((list) =>\n          list.some((i) => i.id === \"pr\")\n            ? list\n            : [...list, { id: \"pr\", text: \"Open the PR as a draft and request review from @maya\" }],\n        ),\n      1600,\n    );\n    return () => clearTimeout(t);\n  }, []);\n\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-2\">\n      <PromptQueue\n        items={items}\n        onItemsChange={setItems}\n        onSendNow={(item) => setSteered(item.text)}\n        hint=\"Sends when Kura finishes running pnpm test\"\n      />\n      <p\n        className=\"flex min-h-5 items-center gap-1.5 px-1 text-[12px] text-kura-fg-subtle\"\n        aria-live=\"polite\"\n      >\n        {steered ? (\n          <>\n            <CornerDownRight aria-hidden className=\"size-3.5 shrink-0\" strokeWidth={1.75} />\n            <span className=\"truncate\">\n              Interrupted Kura and sent: <span className=\"text-kura-fg-muted\">{steered}</span>\n            </span>\n          </>\n        ) : (\n          \"Hover a prompt, or focus it and use Alt+↑/↓, Enter, Delete and ⌘↵.\"\n        )}\n      </p>\n    </div>\n  );\n}\n"
        },
        {
          "name": "PromptQueueTray",
          "title": "Tray",
          "description": "Rounded on top only, tucked onto the input below.",
          "kind": "variant",
          "code": "\"use client\";\n\nimport { PromptQueue, type QueuedPrompt } from \"@kura-ui/react/prompt-queue\";\n\nconst QUEUE: QueuedPrompt[] = [\n  {\n    id: \"helper\",\n    text: \"Use the refreshSession() helper instead of calling fetch('/api/session') directly\",\n    attachments: [{ name: \"session-flow.png\", type: \"image\" }],\n  },\n  {\n    id: \"legacy\",\n    text: \"Don't touch src/legacy/ — it's being deleted next sprint\",\n  },\n  {\n    id: \"readme\",\n    text: \"When tests pass, update the auth section of the README and link the migration guide\",\n    attachments: [\n      { name: \"MIGRATION.md\", type: \"file\" },\n      { name: \"auth-diagram.png\", type: \"image\" },\n    ],\n  },\n];\n\nexport function PromptQueueTray() {\n  return (\n    <div className=\"flex w-full max-w-xl flex-col\">\n      <PromptQueue\n        variant=\"tray\"\n        className=\"mx-3\"\n        defaultItems={QUEUE.slice(0, 2)}\n        onSendNow={() => {}}\n      />\n      <div className=\"h-16 rounded-kura-xl bg-kura-surface shadow-kura-sm\" />\n    </div>\n  );\n}\n"
        },
        {
          "name": "PromptQueueComposition",
          "title": "Above a composer",
          "description": "Messages sent while the agent works are queued; Send now steers.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { CornerDownRight } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Composer } from \"@kura-ui/react/composer\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\nimport { PromptQueue, type QueuedPrompt } from \"@kura-ui/react/prompt-queue\";\n\nconst QUEUE: QueuedPrompt[] = [\n  {\n    id: \"helper\",\n    text: \"Use the refreshSession() helper instead of calling fetch('/api/session') directly\",\n    attachments: [{ name: \"session-flow.png\", type: \"image\" }],\n  },\n  {\n    id: \"legacy\",\n    text: \"Don't touch src/legacy/ — it's being deleted next sprint\",\n  },\n  {\n    id: \"readme\",\n    text: \"When tests pass, update the auth section of the README and link the migration guide\",\n    attachments: [\n      { name: \"MIGRATION.md\", type: \"file\" },\n      { name: \"auth-diagram.png\", type: \"image\" },\n    ],\n  },\n];\n\nexport function PromptQueueComposition() {\n  const [items, setItems] = React.useState<QueuedPrompt[]>(QUEUE.slice(0, 2));\n  const [log, setLog] = React.useState<string[]>([]);\n  const [startedAt] = React.useState(() => Date.now() - 611_000);\n  return (\n    <div className=\"flex w-full max-w-xl flex-col gap-3\">\n      <RunHeader\n        variant=\"bar\"\n        title=\"Run pnpm test --filter web\"\n        status=\"running\"\n        startedAt={startedAt}\n        progress={{ done: 5, total: 7 }}\n        cost={1.52}\n        onStop={() => {}}\n        shortcuts={false}\n      />\n      {log.length > 0 && (\n        <ul className=\"flex flex-col gap-1 px-1 text-[12px] text-kura-fg-subtle\">\n          {log.map((l, i) => (\n            <li key={i} className=\"flex items-center gap-1.5 truncate\">\n              <CornerDownRight aria-hidden className=\"size-3.5 shrink-0\" strokeWidth={1.75} />\n              Steered: <span className=\"truncate text-kura-fg-muted\">{l}</span>\n            </li>\n          ))}\n        </ul>\n      )}\n      <div className=\"flex flex-col\">\n        <PromptQueue\n          variant=\"tray\"\n          className=\"mx-3\"\n          items={items}\n          onItemsChange={setItems}\n          onSendNow={(item) => setLog((l) => [...l, item.text])}\n          hint=\"Sends after this turn\"\n        />\n        <Composer\n          className=\"relative\"\n          placeholder=\"Queue a follow-up for Kura…\"\n          onSubmit={(m) => setItems((list) => [...list, { id: `q-${Date.now()}`, text: m.text }])}\n        />\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "When the agent finishes a turn, shift the first item off `items` and send it yourself — the component never sends on its own.",
        "onSendNow means 'interrupt and send': stop the current turn in your harness before sending.",
        "Keep ids stable across edits; reordering and focus rely on them.",
        "Empty queue renders only a visually hidden status region, so you can leave it mounted above the composer."
      ],
      "title": "Prompt Queue",
      "importPath": "@kura-ui/react/prompt-queue",
      "exports": [
        "PromptQueue",
        "PromptQueueProps",
        "QueuedPrompt",
        "QueuedPromptAttachment"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/prompt-queue",
      "markdownUrl": "https://kuro.abstergo.fyi/components/prompt-queue.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/prompt-queue.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/prompt-queue.json",
        "cli": "npx @kura-ui/cli add prompt-queue",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "icon-button",
        "kura-motion",
        "use-controllable-state",
        "use-reduced-motion",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "items": {
            "description": "Queued prompts in send order (controlled): { id, text, attachments?: { name, type?: 'image' | 'file' }[] }.",
            "x-ts-type": "QueuedPrompt[]",
            "type": "array"
          },
          "defaultItems": {
            "description": "Initial prompts when uncontrolled.",
            "x-ts-type": "QueuedPrompt[]",
            "type": "array"
          },
          "onItemsChange": {
            "description": "Called with the whole new list after a reorder, edit, remove or Send now. Fires on every step while dragging.",
            "x-ts-type": "(items: QueuedPrompt[]) => void"
          },
          "onSendNow": {
            "description": "Steer: interrupt the agent and send this prompt now. The prompt is removed from the queue first. Without it no Send now button renders.",
            "x-ts-type": "(item: QueuedPrompt) => void"
          },
          "title": {
            "description": "Header title; the count follows it.",
            "x-ts-type": "React.ReactNode",
            "default": "Queued"
          },
          "hint": {
            "description": "Secondary header text, e.g. 'Sends when Kura finishes running pnpm test'. null hides it.",
            "x-ts-type": "React.ReactNode",
            "default": "Sends when the agent finishes this turn"
          },
          "variant": {
            "description": "panel = standalone card; tray = sunken, rounded on top only, meant to sit flush on top of a Composer (inset it with className='mx-3').",
            "x-ts-type": "\"panel\" | \"tray\"",
            "type": "string",
            "enum": [
              "panel",
              "tray"
            ],
            "default": "panel"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/prompt-queue/index.ts",
          "type": "registry:ui",
          "content": "export {\n  PromptQueue,\n  type PromptQueueProps,\n  type QueuedPrompt,\n  type QueuedPromptAttachment,\n} from \"./prompt-queue\";\n"
        },
        {
          "target": "components/kura/prompt-queue/prompt-queue.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport {\n  ArrowDown,\n  ArrowUp,\n  FileText,\n  GripVertical,\n  Image as ImageIcon,\n  Pencil,\n  SendHorizontal,\n  X,\n} from \"lucide-react\";\nimport { Reorder, useDragControls } 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 { springs } from \"@/lib/kura/motion\";\nimport { Button } from \"@/components/kura/button\";\nimport { IconButton } from \"@/components/kura/icon-button\";\n\nexport interface QueuedPromptAttachment {\n  /** File name, e.g. \"session-flow.png\". */\n  name: string;\n  /** Picks the icon. Default \"file\". */\n  type?: \"image\" | \"file\";\n}\n\nexport interface QueuedPrompt {\n  /** Stable id. */\n  id: string;\n  /** The prompt text. */\n  text: string;\n  attachments?: QueuedPromptAttachment[];\n}\n\nexport interface PromptQueueProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\" | \"defaultValue\"> {\n  /** Queued prompts in send order (controlled). */\n  items?: QueuedPrompt[];\n  /** Initial prompts when uncontrolled. */\n  defaultItems?: QueuedPrompt[];\n  /** Called with the new list after a reorder, edit or remove. */\n  onItemsChange?: (items: QueuedPrompt[]) => void;\n  /** Steer: interrupt the agent and send this prompt now. The prompt leaves the queue first. */\n  onSendNow?: (item: QueuedPrompt) => void;\n  /** Header title. Default \"Queued\". */\n  title?: React.ReactNode;\n  /** Secondary text after the title. Default \"Sends when the agent finishes this turn\". */\n  hint?: React.ReactNode;\n  /** `panel` = a standalone card; `tray` = rounded on top only, to sit flush on a composer. Default \"panel\". */\n  variant?: \"panel\" | \"tray\";\n}\n\n/**\n * Follow-up prompts queued while an agent works, as a compact stack above the composer. Each\n * prompt can be reordered (buttons, Alt+↑/↓, Space-to-grab or drag), edited inline, removed, or\n * sent right away to steer the agent. Renders nothing when the queue is empty.\n */\nexport function PromptQueue({\n  items: itemsProp,\n  defaultItems,\n  onItemsChange,\n  onSendNow,\n  title = \"Queued\",\n  hint = \"Sends when the agent finishes this turn\",\n  variant = \"panel\",\n  className,\n  ...props\n}: PromptQueueProps) {\n  const [items, setItems] = useControllableState<QueuedPrompt[]>({\n    value: itemsProp,\n    defaultValue: defaultItems ?? [],\n    onChange: onItemsChange,\n  });\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n  const [message, setMessage] = React.useState(\"\");\n  const rows = React.useRef(new Map<string, HTMLElement>());\n\n  const focusRow = (rowId: string | undefined) => {\n    if (!rowId) return;\n    requestAnimationFrame(() => rows.current.get(rowId)?.focus());\n  };\n\n  const move = (index: number, by: -1 | 1) => {\n    const to = index + by;\n    if (to < 0 || to >= items.length) return;\n    const next = items.slice();\n    const [it] = next.splice(index, 1);\n    if (!it) return;\n    next.splice(to, 0, it);\n    setItems(next);\n    setMessage(`Moved to position ${to + 1} of ${items.length}`);\n  };\n\n  const remove = (index: number, refocus: boolean) => {\n    const it = items[index];\n    if (!it) return;\n    const next = items.filter((_, i) => i !== index);\n    setItems(next);\n    setMessage(`Removed “${truncate(it.text)}”. ${next.length} queued.`);\n    if (refocus) focusRow((next[index] ?? next[index - 1])?.id);\n  };\n\n  const sendNow = (index: number) => {\n    const it = items[index];\n    if (!it) return;\n    setItems(items.filter((_, i) => i !== index));\n    setMessage(`Sending “${truncate(it.text)}” now`);\n    onSendNow?.(it);\n  };\n\n  const save = (index: number, text: string) => {\n    const it = items[index];\n    const t = text.trim();\n    setEditingId(null);\n    if (!it) return;\n    if (!t) return remove(index, true);\n    if (t !== it.text) setItems(items.map((x, i) => (i === index ? { ...x, text: t } : x)));\n    focusRow(it.id);\n  };\n\n  if (items.length === 0) {\n    return (\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"prompt-queue\"\n      data-variant={variant}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden font-kura-sans text-kura-fg\",\n        variant === \"panel\"\n          ? \"rounded-kura-xl bg-kura-surface shadow-kura-sm\"\n          : \"rounded-t-kura-xl bg-kura-surface-sunken shadow-[inset_0_0_0_1px_var(--kura-border)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"prompt-queue-header\"\n        className=\"flex h-9 shrink-0 items-center gap-2 border-kura-border-subtle border-b px-3\"\n      >\n        <h3\n          id={`${id}-title`}\n          className=\"flex shrink-0 items-baseline gap-1.5 font-medium text-[13px]\"\n        >\n          {title}\n          <span className=\"font-kura-mono font-normal text-[12px] text-kura-fg-subtle tabular-nums\">\n            {items.length}\n          </span>\n        </h3>\n        {hint != null && (\n          <span\n            data-slot=\"prompt-queue-hint\"\n            className=\"min-w-0 truncate text-[12px] text-kura-fg-subtle\"\n          >\n            {hint}\n          </span>\n        )}\n      </div>\n      <Reorder.Group\n        as=\"ol\"\n        axis=\"y\"\n        values={items.map((it) => it.id)}\n        onReorder={(ids: string[]) => {\n          const byId = new Map(items.map((it) => [it.id, it]));\n          setItems(ids.map((i) => byId.get(i)).filter((x): x is QueuedPrompt => x != null));\n        }}\n        aria-labelledby={`${id}-title`}\n        aria-describedby={`${id}-help`}\n        className=\"flex flex-col py-1\"\n      >\n        {items.map((item, index) => (\n          <QueueRow\n            key={item.id}\n            item={item}\n            index={index}\n            count={items.length}\n            reduced={reduced}\n            editing={editingId === item.id}\n            canSend={onSendNow != null}\n            rowRef={(el) => {\n              if (el) rows.current.set(item.id, el);\n              else rows.current.delete(item.id);\n            }}\n            onMove={(by) => move(index, by)}\n            onEdit={() => setEditingId(item.id)}\n            onCancelEdit={() => {\n              setEditingId(null);\n              focusRow(item.id);\n            }}\n            onSave={(text) => save(index, text)}\n            onRemove={(refocus) => remove(index, refocus)}\n            onSendNow={() => sendNow(index)}\n          />\n        ))}\n      </Reorder.Group>\n      <p id={`${id}-help`} className=\"sr-only\">\n        Alt plus arrow keys or Space then arrows to reorder. Enter to edit, Delete to remove\n        {onSendNow ? \", Control or Command plus Enter to send now\" : \"\"}.\n      </p>\n      <span role=\"status\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n\nfunction truncate(text: string, n = 48) {\n  return text.length > n ? `${text.slice(0, n - 1)}…` : text;\n}\n\ninterface QueueRowProps {\n  item: QueuedPrompt;\n  index: number;\n  count: number;\n  reduced: boolean;\n  editing: boolean;\n  canSend: boolean;\n  rowRef: (el: HTMLElement | null) => void;\n  onMove: (by: -1 | 1) => void;\n  onEdit: () => void;\n  onCancelEdit: () => void;\n  onSave: (text: string) => void;\n  onRemove: (refocus: boolean) => void;\n  onSendNow: () => void;\n}\n\nfunction QueueRow({\n  item,\n  index,\n  count,\n  reduced,\n  editing,\n  canSend,\n  rowRef,\n  onMove,\n  onEdit,\n  onCancelEdit,\n  onSave,\n  onRemove,\n  onSendNow,\n}: QueueRowProps) {\n  const controls = useDragControls();\n  const [dragging, setDragging] = React.useState(false);\n  const attachments = item.attachments ?? [];\n  const firstAttachment = attachments[0];\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLLIElement>) => {\n    if (e.target !== e.currentTarget || editing) return;\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.altKey && (e.key === \"ArrowUp\" || e.key === \"ArrowDown\")) {\n      onMove(e.key === \"ArrowUp\" ? -1 : 1);\n    } else if (mod && e.key === \"Enter\" && canSend) {\n      onSendNow();\n    } else if (!mod && !e.altKey && (e.key === \"Enter\" || e.key === \"F2\")) {\n      onEdit();\n    } else if (e.key === \"Delete\" || e.key === \"Backspace\") {\n      onRemove(true);\n    } else return;\n    e.preventDefault();\n  };\n\n  return (\n    <Reorder.Item\n      as=\"li\"\n      value={item.id}\n      ref={rowRef}\n      dragListener={false}\n      dragControls={controls}\n      layout=\"position\"\n      transition={reduced ? { duration: 0 } : springs.smooth}\n      onDragStart={() => setDragging(true)}\n      onDragEnd={() => setDragging(false)}\n      onKeyDown={onKeyDown}\n      aria-label={`${index + 1} of ${count}: ${item.text}`}\n      aria-keyshortcuts=\"Alt+ArrowUp Alt+ArrowDown Enter Delete\"\n      data-slot=\"prompt-queue-item\"\n      data-state={editing ? \"editing\" : dragging ? \"dragging\" : undefined}\n      className={cn(\n        \"group/item relative mx-1 flex min-w-0 rounded-kura-md bg-transparent outline-none\",\n        \"focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n        !editing && \"min-h-8 items-center gap-1 pr-1 pl-1 hover:bg-kura-hover\",\n        editing && \"bg-kura-hover p-1.5\",\n        dragging && \"bg-kura-surface-raised shadow-kura-md\",\n      )}\n    >\n      {editing ? (\n        <EditBox\n          initial={item.text}\n          onCancel={onCancelEdit}\n          onSave={onSave}\n          label={`Edit queued prompt ${index + 1}`}\n        />\n      ) : (\n        <>\n          <span className=\"relative grid size-6 shrink-0 place-items-center\">\n            <span\n              aria-hidden\n              className=\"font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums transition-opacity duration-(--kura-duration-instant) group-hover/item:opacity-0\"\n            >\n              {index + 1}\n            </span>\n            <span\n              aria-hidden\n              data-slot=\"prompt-queue-handle\"\n              onPointerDown={(e) => {\n                e.preventDefault();\n                controls.start(e);\n              }}\n              className=\"absolute inset-0 grid cursor-grab touch-none place-items-center rounded-kura-sm text-kura-fg-subtle opacity-0 transition-opacity duration-(--kura-duration-instant) hover:text-kura-fg active:cursor-grabbing group-hover/item:opacity-100\"\n            >\n              <GripVertical className=\"size-3.5\" strokeWidth={1.75} />\n            </span>\n          </span>\n          <span\n            data-slot=\"prompt-queue-text\"\n            title={item.text}\n            className=\"min-w-0 flex-1 truncate py-1.5 text-[13px] leading-5\"\n          >\n            {item.text}\n          </span>\n          {firstAttachment && (\n            <span\n              data-slot=\"prompt-queue-attachments\"\n              className=\"inline-flex h-5 min-w-0 max-w-[40%] shrink items-center gap-1 rounded-kura-sm bg-kura-hover px-1.5 font-kura-mono text-[11px] text-kura-fg-muted\"\n            >\n              {firstAttachment.type === \"image\" ? (\n                <ImageIcon aria-hidden className=\"size-3 shrink-0\" strokeWidth={1.75} />\n              ) : (\n                <FileText aria-hidden className=\"size-3 shrink-0\" strokeWidth={1.75} />\n              )}\n              <span className=\"sr-only @md:not-sr-only @md:truncate\">{firstAttachment.name}</span>\n              {attachments.length > 1 && (\n                <span className=\"shrink-0 text-kura-fg-subtle\">+{attachments.length - 1}</span>\n              )}\n            </span>\n          )}\n          <span\n            data-slot=\"prompt-queue-actions\"\n            className=\"hidden shrink-0 items-center gap-0.5 group-focus-within/item:flex group-hover/item:flex [@media(hover:none)]:flex\"\n          >\n            <IconButton\n              size=\"xs\"\n              label=\"Move up\"\n              icon={<ArrowUp />}\n              disabled={index === 0}\n              onClick={() => onMove(-1)}\n            />\n            <IconButton\n              size=\"xs\"\n              label=\"Move down\"\n              icon={<ArrowDown />}\n              disabled={index === count - 1}\n              onClick={() => onMove(1)}\n            />\n            <IconButton size=\"xs\" label=\"Edit\" icon={<Pencil />} onClick={onEdit} />\n            <IconButton size=\"xs\" label=\"Remove\" icon={<X />} onClick={() => onRemove(false)} />\n            {canSend && (\n              <Button\n                data-slot=\"prompt-queue-send\"\n                variant=\"secondary\"\n                size=\"xs\"\n                className=\"ml-1\"\n                onClick={onSendNow}\n                aria-keyshortcuts=\"Meta+Enter Control+Enter\"\n              >\n                <SendHorizontal />\n                Send now\n              </Button>\n            )}\n          </span>\n        </>\n      )}\n    </Reorder.Item>\n  );\n}\n\nfunction EditBox({\n  initial,\n  label,\n  onSave,\n  onCancel,\n}: {\n  initial: string;\n  label: string;\n  onSave: (text: string) => void;\n  onCancel: () => void;\n}) {\n  const [text, setText] = React.useState(initial);\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.focus();\n    el.setSelectionRange(el.value.length, el.value.length);\n  }, []);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 160)}px`;\n  });\n  return (\n    <div className=\"flex w-full min-w-0 flex-col gap-1.5\">\n      <textarea\n        ref={ref}\n        aria-label={label}\n        data-slot=\"prompt-queue-editor\"\n        value={text}\n        rows={1}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onCancel();\n          } else if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n            e.preventDefault();\n            onSave(text);\n          }\n        }}\n        className=\"w-full resize-none rounded-kura-sm bg-kura-surface px-2 py-1.5 text-[13px] text-kura-fg leading-5 shadow-kura-xs outline-none transition-shadow duration-(--kura-duration-fast) focus:shadow-kura-focus\"\n      />\n      <div className=\"flex items-center justify-end gap-1\">\n        <span className=\"mr-auto truncate pl-1 text-[12px] text-kura-fg-subtle\">\n          Enter to save · Esc to cancel\n        </span>\n        <Button variant=\"ghost\" size=\"xs\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button variant=\"primary\" size=\"xs\" onClick={() => onSave(text)}>\n          Save\n        </Button>\n      </div>\n    </div>\n  );\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-gray-3 px-3 font-medium text-[13px] text-kura-fg-muted hover:not-disabled:bg-kura-gray-4 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:bg-kura-gray-2 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-3 text-[15px] text-kura-fg leading-relaxed\">\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      {!active && (\n        <div className=\"flex animate-kura-fade-in flex-col items-start gap-3\">\n          <p>\n            Wrap the click in a transition and render from{\" \"}\n            <code className=\"rounded-kura-xs bg-kura-active px-[0.3em] py-[0.1em] font-kura-mono text-[0.86em]\">\n              useOptimistic\n            </code>\n            . The count updates on the same frame, and if the server action throws, React drops the\n            optimistic value and the real count comes back on its own.\n          </p>\n          <Button variant=\"ghost\" size=\"xs\" className=\"-ml-2 text-kura-fg-muted\" onClick={replay}>\n            <RotateCcw /> Think again\n          </Button>\n        </div>\n      )}\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 ml-[3px] whitespace-pre-wrap border-kura-border border-l 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": "run-header",
      "name": "RunHeader",
      "category": "agent",
      "status": "experimental",
      "description": "Header for one agent run: title, agent, model, branch, status, live elapsed clock, tokens, cost vs budget, step progress and Stop/Pause controls.",
      "whenToUse": [
        "The top of a page or pane that shows one agent session (Claude Code, Codex, Devin-style runs): what it is doing, how long, how much it costs, and a way to stop it.",
        "A dense status row above a run's transcript, plan or fleet (variant='bar').",
        "Showing spend against a per-run budget, with the meter turning amber at 80% and red past 100%.",
        "Giving the human Stop / Pause / Resume with keyboard shortcuts while an agent works."
      ],
      "whenNotToUse": [
        "The run's step-by-step plan — use task-list (todo-style) or agent-timeline (steps with connectors).",
        "Parallel subagents inside a run — use agent-fleet.",
        "Context-window usage of one conversation — use context-meter.",
        "A single tool invocation — use tool-call."
      ],
      "props": [
        {
          "name": "title",
          "type": "React.ReactNode",
          "required": true,
          "description": "What the run is doing, e.g. \"Migrate session API to @acme/auth\". Truncates."
        },
        {
          "name": "agent",
          "type": "string",
          "description": "Agent name, e.g. \"Kura\". Panel meta line only."
        },
        {
          "name": "model",
          "type": "string",
          "description": "Model id in mono, e.g. \"claude-opus-5-5\". Panel meta line only."
        },
        {
          "name": "status",
          "type": "\"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\"",
          "required": true,
          "description": "Run state, drawn with StatusGlyph. running = blue arc + shimmering 'Running'; waiting = amber 'Waiting for you'; done = 'Completed'; failed = red; skipped = 'Stopped'. queued/running/waiting/paused keep the clock ticking."
        },
        {
          "name": "startedAt",
          "type": "Date | number",
          "description": "Run start. Drives the live elapsed clock (m:ss, h:mm:ss). Without it the clock shows –:––."
        },
        {
          "name": "endedAt",
          "type": "Date | number",
          "description": "Run end. Freezes the clock at endedAt − startedAt (use for finished or persisted runs)."
        },
        {
          "name": "tokens",
          "type": "{ input: number; output: number }",
          "description": "Token usage. Panel shows \"412k in · 38.1k out\"; bar shows the total."
        },
        {
          "name": "cost",
          "type": "number",
          "description": "Spend so far in USD, formatted $1.82."
        },
        {
          "name": "budget",
          "type": "number",
          "description": "Budget in USD. Shows '$1.82 of $5.00' with a 4px meter; amber from 80%, red (and red cost) past 100%."
        },
        {
          "name": "branch",
          "type": "string",
          "description": "Git branch in mono with a branch icon. In the bar it shows from ~768px container width."
        },
        {
          "name": "progress",
          "type": "{ done: number; total: number }",
          "description": "Plan progress: '4 of 7' plus a segmented bar (continuous above 24 steps). The current segment is blue while running, amber while waiting, red when failed."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra controls before Pause/Stop, e.g. an 'Open PR' Button once the run is done."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "description": "Shows Stop while the run is live (queued, running, waiting, paused). Bound to ⌘/Ctrl+."
        },
        {
          "name": "onPause",
          "type": "() => void",
          "description": "Shows Pause while running. Bound to ⌘/Ctrl+Shift+P."
        },
        {
          "name": "onResume",
          "type": "() => void",
          "description": "Shows Resume while paused. Bound to ⌘/Ctrl+Shift+P."
        },
        {
          "name": "variant",
          "type": "\"bar\" | \"panel\"",
          "default": "\"panel\"",
          "description": "bar = one 44px row (glyph, title, status, branch, steps, tokens, cost, clock, controls; stats drop out as the container narrows). panel = title + meta line + a stats strip (Elapsed, Steps, Tokens, Spend)."
        },
        {
          "name": "shortcuts",
          "type": "boolean",
          "default": "true",
          "description": "Bind ⌘/Ctrl+. (Stop) and ⌘/Ctrl+Shift+P (Pause/Resume) on the document. Only one run header on screen should bind them."
        }
      ],
      "parts": [
        {
          "name": "RunHeader",
          "description": "Root <section> labelled by the title; `data-variant`, `data-status`. A size container.",
          "dataSlot": "run-header"
        },
        {
          "name": "Title",
          "description": "<h2>, truncated.",
          "dataSlot": "run-header-title"
        },
        {
          "name": "Status",
          "description": "Status word; shimmers while running, amber when waiting, red when failed.",
          "dataSlot": "run-header-status"
        },
        {
          "name": "Agent",
          "description": "Agent name (panel).",
          "dataSlot": "run-header-agent"
        },
        {
          "name": "Model",
          "description": "Model id in mono (panel).",
          "dataSlot": "run-header-model"
        },
        {
          "name": "Branch",
          "description": "Branch icon + mono name.",
          "dataSlot": "run-header-branch"
        },
        {
          "name": "Controls",
          "description": "actions, then run-header-pause and run-header-stop.",
          "dataSlot": "run-header-controls"
        },
        {
          "name": "Stats",
          "description": "<dl>. In the panel each cell is run-header-stat with `data-stat` = elapsed | steps | tokens | cost, separated by hairlines.",
          "dataSlot": "run-header-stats"
        },
        {
          "name": "Elapsed",
          "description": "Mono tabular clock with reserved width so ticking never shifts layout.",
          "dataSlot": "run-header-elapsed"
        }
      ],
      "a11y": {
        "role": "region (section labelled by the title)",
        "notes": [
          "The stats are a <dl>: each value has a term (Elapsed, Steps, Tokens, Spend), visually hidden in the bar.",
          "A polite status region announces only status changes ('Run waiting for you'); the ticking clock and counters are not announced.",
          "Stop and Pause have tooltips with their shortcuts and aria-keyshortcuts. Status is never colour-only: the glyph shape and the status word carry it.",
          "The status glyph is decorative here (label=null) because the status word sits next to it."
        ],
        "keyboard": [
          {
            "keys": "Mod+.",
            "action": "Stop the run (when onStop is set and the run is live)"
          },
          {
            "keys": "Mod+Shift+P",
            "action": "Pause or resume"
          },
          {
            "keys": "Tab",
            "action": "Actions, Pause, Stop"
          }
        ],
        "reducedMotion": "The running arc, the current step's pulse and the status shimmer stop; meters change width without transition."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "button",
          "icon-button",
          "shimmer-text",
          "status-glyph",
          "tooltip"
        ]
      },
      "related": [
        "agent-fleet",
        "task-list",
        "checkpoints",
        "context-meter",
        "agent-timeline"
      ],
      "tags": [
        "agent",
        "run",
        "session",
        "header",
        "status",
        "cost",
        "budget",
        "tokens",
        "timer",
        "stop"
      ],
      "examples": [
        {
          "name": "RunHeaderMinimal",
          "title": "Running",
          "description": "Title, agent, model, status and a live clock.",
          "kind": "minimal",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\n\nconst minutesAgo = (m: number) => Date.now() - m * 60_000;\n\nexport function RunHeaderMinimal() {\n  const [startedAt] = React.useState(() => minutesAgo(3.2));\n  return (\n    <RunHeader\n      className=\"w-full max-w-2xl\"\n      title=\"Migrate session API to @acme/auth\"\n      agent=\"Kura\"\n      model=\"claude-opus-5-5\"\n      status=\"running\"\n      startedAt={startedAt}\n      shortcuts={false}\n    />\n  );\n}\n"
        },
        {
          "name": "RunHeaderFull",
          "title": "Live run",
          "description": "Steps advance, tokens and spend climb against a $5 budget, the run waits for approval, then completes and offers Open PR. Pause, Resume and Stop work (⌘. and ⌘⇧P).",
          "kind": "full",
          "code": "\"use client\";\n\nimport { GitPullRequestArrow } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport type { AgentStatus } from \"@kura-ui/react/status-glyph\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\n\nconst minutesAgo = (m: number) => Date.now() - m * 60_000;\n\nconst SCRIPT: { status: AgentStatus; done: number; input: number; output: number; ms: number }[] = [\n  { status: \"running\", done: 4, input: 268_400, output: 21_900, ms: 1800 },\n  { status: \"running\", done: 4, input: 297_100, output: 24_300, ms: 1600 },\n  { status: \"waiting\", done: 4, input: 301_800, output: 25_000, ms: 2600 },\n  { status: \"running\", done: 5, input: 338_200, output: 29_400, ms: 1800 },\n  { status: \"running\", done: 6, input: 376_900, output: 33_800, ms: 2000 },\n  { status: \"running\", done: 7, input: 412_300, output: 38_100, ms: 1400 },\n  { status: \"done\", done: 7, input: 412_300, output: 38_100, ms: 0 },\n];\n\nconst priceOf = (input: number, output: number) => (input * 2.5 + output * 20) / 1_000_000 + 0.03;\n\nexport function RunHeaderFull() {\n  const [startedAt] = React.useState(() => minutesAgo(11.4));\n  const [beat, setBeat] = React.useState(0);\n  const [override, setOverride] = React.useState<AgentStatus | null>(null);\n  const [endedAt, setEndedAt] = React.useState<number | undefined>();\n  const step = SCRIPT[Math.min(beat, SCRIPT.length - 1)] as (typeof SCRIPT)[number];\n  const status = override ?? step.status;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-arm the timer on every beat\n  React.useEffect(() => {\n    if (override != null || step.ms === 0) return;\n    const t = setTimeout(() => setBeat((b) => Math.min(b + 1, SCRIPT.length - 1)), step.ms);\n    return () => clearTimeout(t);\n  }, [beat, override, step.ms]);\n\n  React.useEffect(() => {\n    if ((status === \"done\" || status === \"skipped\") && endedAt == null) setEndedAt(Date.now());\n  }, [status, endedAt]);\n\n  return (\n    <RunHeader\n      className=\"w-full max-w-3xl\"\n      title=\"Migrate session API to @acme/auth\"\n      agent=\"Kura\"\n      model=\"claude-opus-5-5\"\n      branch=\"kura/auth-client\"\n      status={status}\n      startedAt={startedAt}\n      endedAt={endedAt}\n      progress={{ done: step.done, total: 7 }}\n      tokens={{ input: step.input, output: step.output }}\n      cost={priceOf(step.input, step.output)}\n      budget={5}\n      onPause={() => setOverride(\"paused\")}\n      onResume={() => setOverride(null)}\n      onStop={() => setOverride(\"skipped\")}\n      actions={\n        status === \"done\" ? (\n          <Button size=\"sm\" variant=\"primary\" className=\"animate-kura-fade-in\">\n            <GitPullRequestArrow />\n            Open PR\n          </Button>\n        ) : null\n      }\n    />\n  );\n}\n"
        },
        {
          "name": "RunHeaderBar",
          "title": "Bar",
          "description": "One dense row per run: running, waiting for you, failed and completed.",
          "kind": "variant",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\n\nexport function RunHeaderBar() {\n  const [t] = React.useState(() => Date.now());\n  return (\n    <div className=\"flex w-full max-w-3xl flex-col gap-2\">\n      <RunHeader\n        variant=\"bar\"\n        title=\"Migrate session API to @acme/auth\"\n        status=\"running\"\n        branch=\"kura/auth-client\"\n        startedAt={t - 8 * 60_000 - 42_000}\n        progress={{ done: 4, total: 7 }}\n        tokens={{ input: 268_400, output: 21_900 }}\n        cost={1.12}\n        budget={5}\n        onPause={() => {}}\n        onStop={() => {}}\n        shortcuts={false}\n      />\n      <RunHeader\n        variant=\"bar\"\n        title=\"Patch src/middleware.ts\"\n        status=\"waiting\"\n        branch=\"kura/auth-client\"\n        startedAt={t - 9 * 60_000 - 5_000}\n        progress={{ done: 4, total: 7 }}\n        tokens={{ input: 301_800, output: 25_000 }}\n        cost={1.31}\n        budget={5}\n        onStop={() => {}}\n        shortcuts={false}\n      />\n      <RunHeader\n        variant=\"bar\"\n        title=\"Fix login.test.ts › refreshes an expired session\"\n        status=\"failed\"\n        branch=\"kura/auth-client\"\n        startedAt={t - 14 * 60_000}\n        endedAt={t - 2 * 60_000 - 51_000}\n        progress={{ done: 5, total: 7 }}\n        tokens={{ input: 356_000, output: 31_200 }}\n        cost={1.58}\n        budget={5}\n      />\n      <RunHeader\n        variant=\"bar\"\n        title=\"Bump @acme/auth to 2.4.0\"\n        status=\"done\"\n        branch=\"kura/auth-client\"\n        startedAt={t - 31 * 60_000}\n        endedAt={t - 19 * 60_000 - 6_000}\n        progress={{ done: 7, total: 7 }}\n        tokens={{ input: 412_300, output: 38_100 }}\n        cost={1.82}\n        budget={5}\n      />\n    </div>\n  );\n}\n"
        },
        {
          "name": "RunHeaderComposition",
          "title": "With agent-fleet",
          "description": "A bar header above the run's subagents.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { AgentFleet, type FleetAgent } from \"@kura-ui/react/agent-fleet\";\nimport { RunHeader } from \"@kura-ui/react/run-header\";\n\nconst minutesAgo = (m: number) => Date.now() - m * 60_000;\n\nconst FLEET: FleetAgent[] = [\n  {\n    id: \"explorer\",\n    name: \"explorer\",\n    role: \"Maps call sites\",\n    status: \"done\",\n    activity: \"Found 23 calls to fetchUser() in 9 files\",\n    startedAt: minutesAgo(9),\n    endedAt: minutesAgo(7.4),\n    tokens: 84_200,\n    cost: 0.21,\n  },\n  {\n    id: \"test-writer\",\n    name: \"test-writer\",\n    role: \"Updates tests\",\n    status: \"running\",\n    activity: \"Rewriting mocks in src/auth/__tests__/login.test.ts\",\n    progress: 0.62,\n    startedAt: minutesAgo(3.1),\n    tokens: 51_600,\n    cost: 0.14,\n  },\n  {\n    id: \"reviewer\",\n    name: \"reviewer\",\n    role: \"Reviews the diff\",\n    status: \"queued\",\n    activity: \"Starts after test-writer\",\n  },\n];\n\nexport function RunHeaderComposition() {\n  const [startedAt] = React.useState(() => minutesAgo(9.6));\n  return (\n    <div className=\"flex w-full max-w-3xl flex-col gap-2\">\n      <RunHeader\n        variant=\"bar\"\n        title=\"Migrate session API to @acme/auth\"\n        status=\"running\"\n        branch=\"kura/auth-client\"\n        startedAt={startedAt}\n        progress={{ done: 5, total: 7 }}\n        tokens={{ input: 338_200, output: 29_400 }}\n        cost={1.46}\n        budget={5}\n        onPause={() => {}}\n        onStop={() => {}}\n        shortcuts={false}\n      />\n      <AgentFleet\n        agents={FLEET}\n        density=\"compact\"\n        defaultSelectedId=\"test-writer\"\n        onSelect={() => {}}\n      />\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Keep one RunHeader mounted for the life of a run and update props; pass startedAt from the server so reloads keep the right clock, and endedAt once it finishes.",
        "Set shortcuts={false} on every RunHeader except the one for the focused run, otherwise ⌘. stops all of them.",
        "Map harness states: in-progress → running; needs approval / input → waiting; interrupted by the user → skipped ('Stopped'); error → failed.",
        "tokens.input should include cache reads if you want the number to match the bill; cost is whatever you computed — the component only formats it.",
        "Use the bar inside dense layouts (above a fleet or transcript) and the panel at the top of a run page."
      ],
      "title": "Run Header",
      "importPath": "@kura-ui/react/run-header",
      "exports": [
        "RunHeader",
        "RunHeaderProgress",
        "RunHeaderProps",
        "RunHeaderTokens"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/run-header",
      "markdownUrl": "https://kuro.abstergo.fyi/components/run-header.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/run-header.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/run-header.json",
        "cli": "npx @kura-ui/cli add run-header",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "button",
        "icon-button",
        "kura-format",
        "shimmer-text",
        "status-glyph",
        "tooltip",
        "use-elapsed",
        "use-hotkey",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "title": {
            "description": "What the run is doing, e.g. \"Migrate session API to @acme/auth\". Truncates.",
            "x-ts-type": "React.ReactNode"
          },
          "agent": {
            "description": "Agent name, e.g. \"Kura\". Panel meta line only.",
            "x-ts-type": "string",
            "type": "string"
          },
          "model": {
            "description": "Model id in mono, e.g. \"claude-opus-5-5\". Panel meta line only.",
            "x-ts-type": "string",
            "type": "string"
          },
          "status": {
            "description": "Run state, drawn with StatusGlyph. running = blue arc + shimmering 'Running'; waiting = amber 'Waiting for you'; done = 'Completed'; failed = red; skipped = 'Stopped'. queued/running/waiting/paused keep the clock ticking.",
            "x-ts-type": "\"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\"",
            "type": "string",
            "enum": [
              "done",
              "failed",
              "paused",
              "queued",
              "running",
              "skipped",
              "waiting"
            ]
          },
          "startedAt": {
            "description": "Run start. Drives the live elapsed clock (m:ss, h:mm:ss). Without it the clock shows –:––.",
            "x-ts-type": "Date | number"
          },
          "endedAt": {
            "description": "Run end. Freezes the clock at endedAt − startedAt (use for finished or persisted runs).",
            "x-ts-type": "Date | number"
          },
          "tokens": {
            "description": "Token usage. Panel shows \"412k in · 38.1k out\"; bar shows the total.",
            "x-ts-type": "{ input: number; output: number }"
          },
          "cost": {
            "description": "Spend so far in USD, formatted $1.82.",
            "x-ts-type": "number",
            "type": "number"
          },
          "budget": {
            "description": "Budget in USD. Shows '$1.82 of $5.00' with a 4px meter; amber from 80%, red (and red cost) past 100%.",
            "x-ts-type": "number",
            "type": "number"
          },
          "branch": {
            "description": "Git branch in mono with a branch icon. In the bar it shows from ~768px container width.",
            "x-ts-type": "string",
            "type": "string"
          },
          "progress": {
            "description": "Plan progress: '4 of 7' plus a segmented bar (continuous above 24 steps). The current segment is blue while running, amber while waiting, red when failed.",
            "x-ts-type": "{ done: number; total: number }"
          },
          "actions": {
            "description": "Extra controls before Pause/Stop, e.g. an 'Open PR' Button once the run is done.",
            "x-ts-type": "React.ReactNode"
          },
          "onStop": {
            "description": "Shows Stop while the run is live (queued, running, waiting, paused). Bound to ⌘/Ctrl+.",
            "x-ts-type": "() => void"
          },
          "onPause": {
            "description": "Shows Pause while running. Bound to ⌘/Ctrl+Shift+P.",
            "x-ts-type": "() => void"
          },
          "onResume": {
            "description": "Shows Resume while paused. Bound to ⌘/Ctrl+Shift+P.",
            "x-ts-type": "() => void"
          },
          "variant": {
            "description": "bar = one 44px row (glyph, title, status, branch, steps, tokens, cost, clock, controls; stats drop out as the container narrows). panel = title + meta line + a stats strip (Elapsed, Steps, Tokens, Spend).",
            "x-ts-type": "\"bar\" | \"panel\"",
            "type": "string",
            "enum": [
              "bar",
              "panel"
            ],
            "default": "panel"
          },
          "shortcuts": {
            "description": "Bind ⌘/Ctrl+. (Stop) and ⌘/Ctrl+Shift+P (Pause/Resume) on the document. Only one run header on screen should bind them.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          }
        },
        "required": [
          "title",
          "status"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/run-header/index.ts",
          "type": "registry:ui",
          "content": "export {\n  RunHeader,\n  type RunHeaderProgress,\n  type RunHeaderProps,\n  type RunHeaderTokens,\n} from \"./run-header\";\n"
        },
        {
          "target": "components/kura/run-header/run-header.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { GitBranch, Pause, Play, Square } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { useHotkey } from \"@/hooks/kura/use-hotkey\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock, formatCost, formatTokens } from \"@/lib/kura/format\";\nimport { Button } from \"@/components/kura/button\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\nimport { Tooltip } from \"@/components/kura/tooltip\";\n\nexport interface RunHeaderTokens {\n  /** Prompt / input tokens, including cache reads. */\n  input: number;\n  /** Completion / output tokens. */\n  output: number;\n}\n\nexport interface RunHeaderProgress {\n  /** Steps finished. */\n  done: number;\n  /** Steps in the plan. */\n  total: number;\n}\n\nexport interface RunHeaderProps extends Omit<React.ComponentProps<\"section\">, \"title\"> {\n  /** What the run is doing, e.g. \"Migrate session API to @acme/auth\". */\n  title: React.ReactNode;\n  /** The agent's name, e.g. \"Kura\". */\n  agent?: string;\n  /** Model id, shown in mono, e.g. \"claude-opus-5-5\". */\n  model?: string;\n  /** Run state. Non-terminal states (queued, running, waiting, paused) keep the clock ticking. */\n  status: AgentStatus;\n  /** When the run started. Drives the live elapsed clock. */\n  startedAt?: Date | number;\n  /** When the run ended. Freezes the clock at endedAt − startedAt. */\n  endedAt?: Date | number;\n  /** Token usage so far. */\n  tokens?: RunHeaderTokens;\n  /** Spend so far in USD. */\n  cost?: number;\n  /** Budget in USD. Shows spend against it with a meter that turns amber at 80% and red past 100%. */\n  budget?: number;\n  /** Git branch the agent works on, shown in mono with a branch icon. */\n  branch?: string;\n  /** Plan progress, shown as \"4 of 7\" with a segmented bar. */\n  progress?: RunHeaderProgress;\n  /** Extra controls placed before Pause / Stop, e.g. an \"Open PR\" button. */\n  actions?: React.ReactNode;\n  /** Shows a Stop control while the run is live. */\n  onStop?: () => void;\n  /** Shows a Pause control while running. */\n  onPause?: () => void;\n  /** Shows a Resume control while paused. */\n  onResume?: () => void;\n  /** `bar` = one dense row; `panel` = title row plus a stats strip. Default \"panel\". */\n  variant?: \"bar\" | \"panel\";\n  /** Bind ⌘/Ctrl+. to Stop and ⌘/Ctrl+Shift+P to Pause/Resume. Only one run header on screen should bind them. Default true. */\n  shortcuts?: boolean;\n}\n\nconst STOP_KEYS = [\"mod\", \".\"] as const;\nconst PAUSE_KEYS = [\"mod\", \"shift\", \"p\"] as const;\n\nconst LIVE: AgentStatus[] = [\"queued\", \"running\", \"waiting\", \"paused\"];\n\nconst STATUS_TEXT: Record<AgentStatus, string> = {\n  queued: \"Queued\",\n  running: \"Running\",\n  waiting: \"Waiting for you\",\n  paused: \"Paused\",\n  done: \"Completed\",\n  failed: \"Failed\",\n  skipped: \"Stopped\",\n};\n\nfunction StatusLabel({ status }: { status: AgentStatus }) {\n  const text = STATUS_TEXT[status];\n  return (\n    <span\n      data-slot=\"run-header-status\"\n      className={cn(\n        \"whitespace-nowrap\",\n        status === \"waiting\" && \"text-kura-warning-text\",\n        status === \"failed\" && \"text-kura-danger-text\",\n        (status === \"done\" || status === \"paused\" || status === \"running\") && \"text-kura-fg-muted\",\n      )}\n    >\n      {status === \"running\" ? <ShimmerText>{text}</ShimmerText> : text}\n    </span>\n  );\n}\n\n/** Segmented bar for discrete steps (continuous above 24 steps). */\nfunction StepsBar({\n  progress,\n  status,\n  className,\n}: {\n  progress: RunHeaderProgress;\n  status: AgentStatus;\n  className?: string;\n}) {\n  const total = Math.max(0, progress.total);\n  const done = Math.min(total, Math.max(0, progress.done));\n  const live = status === \"running\" || status === \"waiting\";\n  const currentTone =\n    status === \"failed\"\n      ? \"bg-kura-danger\"\n      : status === \"waiting\"\n        ? \"bg-kura-warning\"\n        : status === \"running\"\n          ? \"bg-kura-accent\"\n          : \"bg-kura-hover\";\n  if (total > 24) {\n    return (\n      <span\n        aria-hidden\n        className={cn(\"flex h-1 overflow-hidden rounded-full bg-kura-hover\", className)}\n      >\n        <span\n          className=\"h-full rounded-full bg-kura-fg transition-[width] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\"\n          style={{ width: `${(done / total) * 100}%` }}\n        />\n      </span>\n    );\n  }\n  return (\n    <span aria-hidden className={cn(\"flex h-1 gap-[3px]\", className)}>\n      {Array.from({ length: total }, (_, i) => (\n        <span\n          key={i}\n          className={cn(\n            \"h-full min-w-0 flex-1 rounded-full transition-colors duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n            i < done\n              ? \"bg-kura-fg\"\n              : i === done && (live || status === \"failed\")\n                ? currentTone\n                : \"bg-kura-hover\",\n            i === done && status === \"running\" && \"animate-kura-pulse motion-reduce:animate-none\",\n          )}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction budgetTone(cost: number, budget: number) {\n  const r = cost / budget;\n  if (r > 1) return \"bg-kura-danger\";\n  if (r >= 0.8) return \"bg-kura-warning\";\n  return \"bg-kura-fg\";\n}\n\nfunction Meter({ value, tone, className }: { value: number; tone: string; className?: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\"flex h-1 overflow-hidden rounded-full bg-kura-hover\", className)}\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          tone,\n        )}\n        style={{ width: `${Math.min(1, Math.max(0, value)) * 100}%` }}\n      />\n    </span>\n  );\n}\n\n/**\n * The header of one agent run: title, agent and model, status, branch, a live elapsed clock,\n * tokens, cost against a budget, plan progress, and Stop / Pause / Resume controls with\n * keyboard shortcuts. `bar` is one dense row; `panel` adds a stats strip.\n */\nexport function RunHeader({\n  title,\n  agent,\n  model,\n  status,\n  startedAt,\n  endedAt,\n  tokens,\n  cost,\n  budget,\n  branch,\n  progress,\n  actions,\n  onStop,\n  onPause,\n  onResume,\n  variant = \"panel\",\n  shortcuts = true,\n  className,\n  ...props\n}: RunHeaderProps) {\n  const id = React.useId();\n  const live = LIVE.includes(status);\n  const ticking = useElapsed(startedAt, live && endedAt == null);\n  const elapsed =\n    startedAt != null && endedAt != null\n      ? Math.max(0, +new Date(endedAt) - +new Date(startedAt))\n      : ticking;\n  const clock = elapsed == null ? \"–:––\" : formatClock(elapsed);\n\n  const canStop = live && onStop != null;\n  const canPause = status === \"running\" && onPause != null;\n  const canResume = status === \"paused\" && onResume != null;\n\n  useHotkey(STOP_KEYS, () => onStop?.(), { enabled: shortcuts && canStop });\n  useHotkey(PAUSE_KEYS, () => (canPause ? onPause?.() : onResume?.()), {\n    enabled: shortcuts && (canPause || canResume),\n  });\n\n  const bar = variant === \"bar\";\n  const totalTokens = tokens ? tokens.input + tokens.output : null;\n  const over = cost != null && budget != null && budget > 0 && cost > budget;\n\n  const pauseButton = (canPause || canResume) && (\n    <IconButton\n      data-slot=\"run-header-pause\"\n      size=\"sm\"\n      variant={bar ? \"ghost\" : \"outline\"}\n      label={canPause ? \"Pause run\" : \"Resume run\"}\n      shortcut={shortcuts ? PAUSE_KEYS : undefined}\n      aria-keyshortcuts={shortcuts ? \"Meta+Shift+P Control+Shift+P\" : undefined}\n      icon={canPause ? <Pause className=\"fill-current\" /> : <Play className=\"fill-current\" />}\n      onClick={() => (canPause ? onPause?.() : onResume?.())}\n    />\n  );\n\n  const stopIcon = <Square className=\"size-3! fill-current\" />;\n  const stopButton =\n    canStop &&\n    (bar ? (\n      <IconButton\n        data-slot=\"run-header-stop\"\n        size=\"sm\"\n        label=\"Stop run\"\n        shortcut={shortcuts ? STOP_KEYS : undefined}\n        aria-keyshortcuts={shortcuts ? \"Meta+. Control+.\" : undefined}\n        icon={stopIcon}\n        onClick={onStop}\n      />\n    ) : (\n      <Tooltip content=\"Stop run\" shortcut={shortcuts ? STOP_KEYS : undefined}>\n        <Button\n          data-slot=\"run-header-stop\"\n          variant=\"outline\"\n          size=\"sm\"\n          onClick={onStop}\n          aria-keyshortcuts={shortcuts ? \"Meta+. Control+.\" : undefined}\n          aria-label=\"Stop run\"\n          className=\"max-@md:size-7 max-@md:px-0\"\n        >\n          {stopIcon}\n          <span className=\"hidden @md:inline\">Stop</span>\n        </Button>\n      </Tooltip>\n    ));\n\n  const controls =\n    actions != null || pauseButton || stopButton ? (\n      <div data-slot=\"run-header-controls\" className=\"flex shrink-0 items-center gap-1.5\">\n        {actions}\n        {pauseButton}\n        {stopButton}\n      </div>\n    ) : null;\n\n  const announce = (\n    <span role=\"status\" className=\"sr-only\">\n      {`Run ${STATUS_TEXT[status].toLowerCase()}`}\n    </span>\n  );\n\n  const branchChip = branch != null && (\n    <span\n      data-slot=\"run-header-branch\"\n      className=\"inline-flex min-w-0 items-center gap-1 font-kura-mono text-[12px] text-kura-fg-muted\"\n    >\n      <GitBranch aria-hidden className=\"size-3.5 shrink-0 text-kura-fg-subtle\" strokeWidth={1.75} />\n      <span className=\"truncate\">{branch}</span>\n    </span>\n  );\n\n  if (bar) {\n    return (\n      <section\n        aria-labelledby={`${id}-title`}\n        data-slot=\"run-header\"\n        data-variant=\"bar\"\n        data-status={status}\n        className={cn(\n          \"@container flex h-11 min-w-0 items-center gap-3 rounded-kura-xl bg-kura-surface pr-2 pl-3.5 font-kura-sans text-[13px] text-kura-fg shadow-kura-sm\",\n          className,\n        )}\n        {...props}\n      >\n        <StatusGlyph status={status} label={null} />\n        <div className=\"flex min-w-0 flex-1 items-center gap-2.5\">\n          <h2\n            id={`${id}-title`}\n            data-slot=\"run-header-title\"\n            className=\"min-w-0 truncate font-medium tracking-[-0.006em]\"\n          >\n            {title}\n          </h2>\n          <span className=\"hidden text-[12px] @md:inline\">\n            <StatusLabel status={status} />\n          </span>\n          {branchChip && <span className=\"hidden min-w-0 @3xl:flex\">{branchChip}</span>}\n        </div>\n        <dl\n          data-slot=\"run-header-stats\"\n          className=\"flex shrink-0 items-center gap-4 font-kura-mono text-[12px] text-kura-fg-muted tabular-nums\"\n        >\n          {progress && progress.total > 0 && (\n            <div className=\"hidden items-center gap-2 @lg:flex\">\n              <dt className=\"sr-only\">Steps</dt>\n              <StepsBar progress={progress} status={status} className=\"w-20\" />\n              <dd>\n                {Math.min(progress.done, progress.total)}\n                <span className=\"text-kura-fg-subtle\">/{progress.total}</span>\n              </dd>\n            </div>\n          )}\n          {totalTokens != null && (\n            <div className=\"hidden @2xl:block\">\n              <dt className=\"sr-only\">Tokens</dt>\n              <dd className=\"inline-block min-w-[5ch] text-right\">\n                {formatTokens(totalTokens)}\n                <span className=\"font-kura-sans text-kura-fg-subtle\"> tokens</span>\n              </dd>\n            </div>\n          )}\n          {cost != null && (\n            <div className=\"hidden @xl:block\">\n              <dt className=\"sr-only\">Cost</dt>\n              <dd className={cn(over && \"text-kura-danger-text\")}>\n                {formatCost(cost)}\n                {budget != null && (\n                  <span className=\"text-kura-fg-subtle\">\n                    <span className=\"font-kura-sans\"> of </span>\n                    {formatCost(budget)}\n                  </span>\n                )}\n              </dd>\n            </div>\n          )}\n          <div>\n            <dt className=\"sr-only\">Elapsed</dt>\n            <dd\n              className=\"inline-block min-w-[5ch] text-right text-kura-fg\"\n              data-slot=\"run-header-elapsed\"\n            >\n              {clock}\n            </dd>\n          </div>\n        </dl>\n        {controls && (\n          <>\n            <span aria-hidden className=\"h-4 w-px shrink-0 bg-kura-border\" />\n            {controls}\n          </>\n        )}\n        {announce}\n      </section>\n    );\n  }\n\n  const stats: { key: string; label: string; node: React.ReactNode }[] = [];\n  stats.push({\n    key: \"elapsed\",\n    label: \"Elapsed\",\n    node: (\n      <span data-slot=\"run-header-elapsed\" className=\"text-kura-fg\">\n        {clock}\n      </span>\n    ),\n  });\n  if (progress && progress.total > 0) {\n    stats.push({\n      key: \"steps\",\n      label: \"Steps\",\n      node: (\n        <span className=\"flex flex-col gap-2\">\n          <span>\n            <span className=\"text-kura-fg\">{Math.min(progress.done, progress.total)}</span>\n            <span className=\"text-kura-fg-subtle\">\n              <span className=\"font-kura-sans\"> of </span>\n              {progress.total}\n            </span>\n          </span>\n          <StepsBar progress={progress} status={status} />\n        </span>\n      ),\n    });\n  }\n  if (tokens) {\n    stats.push({\n      key: \"tokens\",\n      label: \"Tokens\",\n      node: (\n        <span className=\"truncate\">\n          <span className=\"text-kura-fg\">{formatTokens(tokens.input)}</span>\n          <span className=\"font-kura-sans text-kura-fg-subtle\"> in · </span>\n          <span className=\"text-kura-fg\">{formatTokens(tokens.output)}</span>\n          <span className=\"font-kura-sans text-kura-fg-subtle\"> out</span>\n        </span>\n      ),\n    });\n  }\n  if (cost != null) {\n    stats.push({\n      key: \"cost\",\n      label: budget != null ? \"Spend\" : \"Cost\",\n      node: (\n        <span className=\"flex flex-col gap-2\">\n          <span>\n            <span className={over ? \"text-kura-danger-text\" : \"text-kura-fg\"}>\n              {formatCost(cost)}\n            </span>\n            {budget != null && (\n              <span className=\"text-kura-fg-subtle\">\n                <span className=\"font-kura-sans\"> of </span>\n                {formatCost(budget)}\n              </span>\n            )}\n          </span>\n          {budget != null && budget > 0 && (\n            <Meter value={cost / budget} tone={budgetTone(cost, budget)} />\n          )}\n        </span>\n      ),\n    });\n  }\n\n  // Each meta item carries its own separator so items can drop out at narrow widths without\n  // leaving a dangling \"·\". Branch outlives model, which outlives the agent name.\n  const meta: { key: string; className: string; node: React.ReactNode }[] = [];\n  if (agent != null)\n    meta.push({\n      key: \"agent\",\n      className: \"hidden @md:flex\",\n      node: (\n        <span data-slot=\"run-header-agent\" className=\"text-kura-fg-muted\">\n          {agent}\n        </span>\n      ),\n    });\n  if (model != null)\n    meta.push({\n      key: \"model\",\n      className: \"hidden @sm:flex\",\n      node: (\n        <span data-slot=\"run-header-model\" className=\"truncate font-kura-mono\">\n          {model}\n        </span>\n      ),\n    });\n  if (branchChip) meta.push({ key: \"branch\", className: \"flex\", node: branchChip });\n\n  return (\n    <section\n      aria-labelledby={`${id}-title`}\n      data-slot=\"run-header\"\n      data-variant=\"panel\"\n      data-status={status}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"flex items-start gap-3 px-4 pt-3.5 pb-3.5\">\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n          <h2\n            id={`${id}-title`}\n            data-slot=\"run-header-title\"\n            className=\"truncate font-medium text-[15px] leading-5 tracking-[-0.011em]\"\n          >\n            {title}\n          </h2>\n          <div className=\"flex min-w-0 items-center gap-2 text-[12px] text-kura-fg-subtle leading-4\">\n            <span className=\"inline-flex shrink-0 items-center gap-1.5\">\n              <StatusGlyph status={status} size=\"sm\" label={null} />\n              <StatusLabel status={status} />\n            </span>\n            {meta.map((m) => (\n              <span key={m.key} className={cn(\"min-w-0 items-center gap-2\", m.className)}>\n                <span aria-hidden className=\"text-kura-border-strong\">\n                  ·\n                </span>\n                {m.node}\n              </span>\n            ))}\n          </div>\n        </div>\n        {controls}\n      </div>\n      <dl\n        data-slot=\"run-header-stats\"\n        className=\"grid grid-cols-2 gap-px @xl:grid-cols-[repeat(var(--run-header-stats),minmax(0,1fr))]\"\n        style={{ [\"--run-header-stats\" as string]: stats.length }}\n      >\n        {stats.map((s) => (\n          <div\n            key={s.key}\n            data-slot=\"run-header-stat\"\n            data-stat={s.key}\n            className=\"flex min-w-0 flex-col gap-1 bg-kura-surface px-4 pt-2.5 pb-3 shadow-[-1px_-1px_0_0_var(--kura-border-subtle)]\"\n          >\n            <dt className=\"text-[12px] text-kura-fg-subtle leading-4\">{s.label}</dt>\n            <dd className=\"min-w-0 font-kura-mono text-[13px] leading-5 tabular-nums\">{s.node}</dd>\n          </div>\n        ))}\n      </dl>\n      {announce}\n    </section>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "shader-canvas",
      "name": "ShaderCanvas",
      "category": "primitives",
      "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 a GPU visual inside a component, such as the agent orb.",
        "A custom decorative shader behind a hero, card or voice surface."
      ],
      "whenNotToUse": [
        "A voice-agent sphere — use agent-orb.",
        "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 sunken neutral surface with one soft accent-subtle wash (tokens, follows the theme; `data-slot=\"shader-canvas-fallback\"`)."
        },
        {
          "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"
        },
        {
          "name": "DefaultFallback",
          "description": "Rendered only when WebGL2 fails and no `fallback` is passed: a neutral token gradient with a soft accent wash.",
          "dataSlot": "shader-canvas-fallback"
        }
      ],
      "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": [
        "agent-orb"
      ],
      "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.125, 0.425, 0.905); // Kura blue, sRGB\n  vec3 b = vec3(0.054, 0.061, 0.071); // graphite\n  float t = 0.5 + 0.5 * sin(u_time * 0.5 + uv.x * 2.2 + uv.y * 1.4);\n  fragColor = vec4(mix(a, b, smoothstep(0.0, 1.0, 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 < 3; 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) * 1.4;\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.25, 0.75, f));\n  col = mix(col, u_colorC, 0.6 * smoothstep(0.75, 1.05, length(q)));\n  col += 0.012 * (hash(gl_FragCoord.xy) - 0.5); // dither\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.054, 0.061, 0.071],\n          u_colorB: [0.15, 0.25, 0.46],\n          u_colorC: [0.62, 0.68, 0.78],\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_signal;\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  // Graphite base with a slow blue 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_signal * 0.55, halo * 0.45);\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_signal, clamp(glow * 0.4, 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 < 3; 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) * 1.4;\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.25, 0.75, f));\n  col = mix(col, u_colorC, 0.6 * smoothstep(0.75, 1.05, length(q)));\n  col += 0.012 * (hash(gl_FragCoord.xy) - 0.5); // dither\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.036, 0.04, 0.044],\n            u_signal: [0.125, 0.425, 0.905],\n            u_mist: [0.814, 0.879, 0.948],\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 sunken neutral surface with one soft accent-subtle wash (tokens, follows the theme; `data-slot=\"shader-canvas-fallback\"`).",
            "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 neutral field with one soft wash of the accent, from Kura tokens. */\nfunction DefaultFallback() {\n  return (\n    <div\n      aria-hidden\n      data-slot=\"shader-canvas-fallback\"\n      className=\"absolute inset-0 bg-kura-surface-sunken bg-[radial-gradient(90%_70%_at_25%_15%,var(--kura-accent-subtle),transparent_65%),radial-gradient(100%_80%_at_85%_100%,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 * 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-xl bg-kura-surface-raised px-3.5 py-3 text-sm leading-6 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-xl bg-kura-surface-raised px-3.5 py-3 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-xl bg-kura-surface-raised font-kura-sans text-kura-fg shadow-kura-lg\",\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-md bg-kura-surface-raised 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-8 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": "Quiet ring spinner (faint track, turning quarter arc) for indeterminate loading; 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": "The arc stops rotating and the whole ring gently pulses in opacity instead."
      },
      "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\n/**\n * A quiet ring spinner: a faint track with a quarter arc turning over it. Inherits\n * `currentColor`. Under reduced motion the arc stops turning and pulses instead.\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      <svg\n        aria-hidden\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        className=\"size-full animate-kura-spin motion-reduce:animate-kura-pulse\"\n      >\n        <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.75\" opacity=\"0.2\" />\n        <path\n          d=\"M8 1.75a6.25 6.25 0 0 1 6.25 6.25\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.75\"\n          strokeLinecap=\"round\"\n        />\n      </svg>\n    </span>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "status-glyph",
      "name": "StatusGlyph",
      "category": "primitives",
      "status": "beta",
      "description": "Internal status glyph shared by agent surfaces: queued, running, waiting on you, done, failed, skipped and paused, drawn crisply in SVG.",
      "whenToUse": [
        "Inside an agent surface (task list, fleet, trace, run header) to show the state of a run, step or subagent the same way everywhere."
      ],
      "whenNotToUse": [
        "A whole tool invocation with arguments and a timer — use tool-call.",
        "A general loading indicator with no agent state — use thinking-indicator."
      ],
      "props": [
        {
          "name": "status",
          "type": "\"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\"",
          "required": true,
          "description": "The state to draw. running = turning arc in the signal colour; waiting = amber ring with a pulsing dot (the agent needs you)."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "default": "\"md\"",
          "description": "14, 16 or 20px."
        },
        {
          "name": "label",
          "type": "string | null",
          "description": "Spoken text (role=\"img\"). Default the state name, e.g. \"Running\". Pass null when adjacent text already states it."
        }
      ],
      "parts": [
        {
          "name": "StatusGlyph",
          "description": "Root span with `data-status`; contains one 16-unit SVG.",
          "dataSlot": "status-glyph"
        }
      ],
      "a11y": {
        "notes": [
          "Has role=\"img\" with the state as its name, unless label={null}, in which case it is aria-hidden.",
          "State is never conveyed by colour alone: each state has a distinct shape."
        ],
        "reducedMotion": "The running arc stops turning and the waiting dot stops pulsing."
      },
      "dependencies": {
        "npm": [],
        "kura": []
      },
      "related": [
        "task-list",
        "agent-fleet",
        "trace-waterfall",
        "run-header",
        "tool-call"
      ],
      "tags": [
        "status",
        "agent",
        "glyph",
        "internal"
      ],
      "examples": [
        {
          "name": "StatusGlyphMinimal",
          "title": "Running",
          "kind": "minimal",
          "code": "import { StatusGlyph } from \"@kura-ui/react/status-glyph\";\n\nexport function StatusGlyphMinimal() {\n  return <StatusGlyph status=\"running\" />;\n}\n"
        },
        {
          "name": "StatusGlyphFull",
          "title": "Every state and size",
          "kind": "full",
          "code": "import { type AgentStatus, StatusGlyph } from \"@kura-ui/react/status-glyph\";\n\nconst STATES: AgentStatus[] = [\n  \"queued\",\n  \"running\",\n  \"waiting\",\n  \"done\",\n  \"failed\",\n  \"skipped\",\n  \"paused\",\n];\n\nexport function StatusGlyphFull() {\n  return (\n    <div className=\"grid grid-cols-[auto_repeat(3,auto)] items-center gap-x-6 gap-y-3 text-[13px]\">\n      {STATES.map((s) => (\n        <div key={s} className=\"contents\">\n          <span className=\"text-kura-fg-muted capitalize\">{s}</span>\n          <StatusGlyph status={s} size=\"sm\" />\n          <StatusGlyph status={s} />\n          <StatusGlyph status={s} size=\"lg\" />\n        </div>\n      ))}\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Use this rather than inventing per-component status dots, so all agent surfaces share one vocabulary."
      ],
      "title": "Status Glyph",
      "importPath": "@kura-ui/react/status-glyph",
      "exports": [
        "AgentStatus",
        "StatusGlyph",
        "StatusGlyphProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/status-glyph",
      "markdownUrl": "https://kuro.abstergo.fyi/components/status-glyph.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/status-glyph.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/status-glyph.json",
        "cli": "npx @kura-ui/cli add status-glyph",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "status": {
            "description": "The state to draw. running = turning arc in the signal colour; waiting = amber ring with a pulsing dot (the agent needs you).",
            "x-ts-type": "\"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\"",
            "type": "string",
            "enum": [
              "done",
              "failed",
              "paused",
              "queued",
              "running",
              "skipped",
              "waiting"
            ]
          },
          "size": {
            "description": "14, 16 or 20px.",
            "x-ts-type": "\"sm\" | \"md\" | \"lg\"",
            "type": "string",
            "enum": [
              "lg",
              "md",
              "sm"
            ],
            "default": "md"
          },
          "label": {
            "description": "Spoken text (role=\"img\"). Default the state name, e.g. \"Running\". Pass null when adjacent text already states it.",
            "x-ts-type": "string | null"
          }
        },
        "required": [
          "status"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/status-glyph/index.ts",
          "type": "registry:ui",
          "content": "export { type AgentStatus, StatusGlyph, type StatusGlyphProps } from \"./status-glyph\";\n"
        },
        {
          "target": "components/kura/status-glyph/status-glyph.tsx",
          "type": "registry:ui",
          "content": "import type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AgentStatus =\n  | \"queued\"\n  | \"running\"\n  | \"waiting\"\n  | \"done\"\n  | \"failed\"\n  | \"skipped\"\n  | \"paused\";\n\nexport interface StatusGlyphProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** The state to draw. */\n  status: AgentStatus;\n  /** 14 / 16 / 20px. Default \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Spoken text for the state. Default: the state name (\"Running\"). Pass null when nearby text already says it. */\n  label?: string | null;\n}\n\nconst SPOKEN: Record<AgentStatus, string> = {\n  queued: \"Queued\",\n  running: \"Running\",\n  waiting: \"Waiting for you\",\n  done: \"Done\",\n  failed: \"Failed\",\n  skipped: \"Skipped\",\n  paused: \"Paused\",\n};\n\nconst PX = { sm: 14, md: 16, lg: 20 } as const;\n\n/**\n * One status vocabulary for every agent surface: queued (hollow ring), running (a turning arc in the\n * signal colour), waiting on you (amber ring with a dot), done (green check), failed (red cross),\n * skipped (dashed ring) and paused (two bars). Drawn in SVG so it stays crisp at 14–20px.\n */\nexport function StatusGlyph({ status, size = \"md\", label, className, ...props }: StatusGlyphProps) {\n  const px = PX[size];\n  const spoken = label === undefined ? SPOKEN[status] : label;\n  const glyph = (\n    <svg viewBox=\"0 0 16 16\" width={px} height={px} fill=\"none\" aria-hidden>\n      {status === \"queued\" && (\n        <circle cx=\"8\" cy=\"8\" r=\"6\" className=\"stroke-kura-border-strong\" strokeWidth=\"1.5\" />\n      )}\n      {status === \"skipped\" && (\n        <circle\n          cx=\"8\"\n          cy=\"8\"\n          r=\"6\"\n          className=\"stroke-kura-fg-subtle\"\n          strokeWidth=\"1.5\"\n          strokeDasharray=\"2.2 2.6\"\n        />\n      )}\n      {status === \"running\" && (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"6\" className=\"stroke-kura-accent-subtle\" strokeWidth=\"1.75\" />\n          <path\n            d=\"M8 2a6 6 0 0 1 6 6\"\n            className=\"origin-center animate-kura-spin stroke-kura-accent motion-reduce:animate-none\"\n            strokeWidth=\"1.75\"\n            strokeLinecap=\"round\"\n          />\n        </>\n      )}\n      {status === \"waiting\" && (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"6\" className=\"stroke-kura-warning\" strokeWidth=\"1.5\" />\n          <circle\n            cx=\"8\"\n            cy=\"8\"\n            r=\"2.25\"\n            className=\"animate-kura-pulse fill-kura-warning motion-reduce:animate-none\"\n          />\n        </>\n      )}\n      {status === \"paused\" && (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"6\" className=\"stroke-kura-border-strong\" strokeWidth=\"1.5\" />\n          <path\n            d=\"M6.6 5.8v4.4M9.4 5.8v4.4\"\n            className=\"stroke-kura-fg-muted\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n          />\n        </>\n      )}\n      {status === \"done\" && (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"7\" className=\"fill-kura-success\" />\n          <path\n            d=\"m5.2 8.2 1.9 1.9 3.8-4\"\n            className=\"stroke-kura-surface\"\n            strokeWidth=\"1.7\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </>\n      )}\n      {status === \"failed\" && (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"7\" className=\"fill-kura-danger\" />\n          <path\n            d=\"m5.8 5.8 4.4 4.4m0-4.4-4.4 4.4\"\n            className=\"stroke-kura-surface\"\n            strokeWidth=\"1.7\"\n            strokeLinecap=\"round\"\n          />\n        </>\n      )}\n    </svg>\n  );\n  const shared = {\n    \"data-slot\": \"status-glyph\",\n    \"data-status\": status,\n    className: cn(\"inline-grid shrink-0 place-items-center\", className),\n    style: { width: px, height: px },\n    ...props,\n  };\n  return spoken ? (\n    <span role=\"img\" aria-label={spoken} {...shared}>\n      {glyph}\n    </span>\n  ) : (\n    <span aria-hidden {...shared}>\n      {glyph}\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 [complete, setComplete] = React.useState(false);\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    setComplete(false);\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={() => setComplete(true)}\n        className=\"min-h-32\"\n      />\n      <div className=\"flex h-6 items-center justify-between\">\n        <span className=\"text-kura-fg-subtle text-xs tabular-nums\">\n          {complete ? `Done · ${text.length} characters` : \"Receiving tokens…\"}\n        </span>\n        {complete && (\n          <Button\n            variant=\"ghost\"\n            size=\"xs\"\n            className=\"animate-kura-fade-in\"\n            onClick={() => setRun((r) => r + 1)}\n          >\n            <RotateCcw /> Replay\n          </Button>\n        )}\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": "task-list",
      "name": "TaskList",
      "category": "agent",
      "status": "beta",
      "description": "The agent's plan as a todo list: segmented progress, shimmering current task, live and final durations, failure details, sub-tasks and a folded Completed group.",
      "whenToUse": [
        "Show the todo list a coding agent maintains while it works (Claude Code TodoWrite, Codex plan, Cursor tasks) in a run sidebar or panel.",
        "Give a dense, scannable answer to 'what is the agent doing and what is left?' — '3 of 7' plus one row per task.",
        "Let the user click a task to show its output or diff next to the list (`onSelect` + `selectedId`).",
        "Show a failed task with its reason (`detail`) and a task blocked on the human (`waiting`)."
      ],
      "whenNotToUse": [
        "A narrative step timeline with connectors and inline tool calls under each step — use agent-timeline.",
        "Parallel subagents working at the same time — use agent-fleet.",
        "Timing analysis of nested spans on a shared time axis — use trace-waterfall.",
        "A single tool invocation with arguments and result — use tool-call."
      ],
      "props": [
        {
          "name": "items",
          "type": "readonly TaskListItem[]",
          "required": true,
          "description": "The plan, in order. TaskListItem = { id, title, status: \"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\", detail?, durationMs?, startedAt?, children? }."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Plan\"",
          "description": "Heading in the panel header (panel variant only)."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Controls on the right of the header, after the progress bar (e.g. an IconButton)."
        },
        {
          "name": "variant",
          "type": "\"panel\" | \"plain\"",
          "default": "\"panel\"",
          "description": "panel = card with header, '3 of 7' and segmented progress; plain = rows only, for embedding in your own sidebar."
        },
        {
          "name": "collapseCompleted",
          "type": "boolean",
          "default": "true",
          "description": "Fold the leading run of finished (done/skipped) items into a 'Completed (n)' row while work remains. Needs at least three finished items; a fully finished plan is shown in full."
        },
        {
          "name": "completedOpen",
          "type": "boolean",
          "description": "Whether the Completed group is expanded (controlled)."
        },
        {
          "name": "defaultCompletedOpen",
          "type": "boolean",
          "default": "false",
          "description": "Initial expanded state of the Completed group when uncontrolled."
        },
        {
          "name": "onCompletedOpenChange",
          "type": "(open: boolean) => void",
          "description": "Called when the Completed group is toggled."
        },
        {
          "name": "selectedId",
          "type": "string | null",
          "description": "Highlighted item (bg-kura-active plus a 2px accent bar). Purely visual; pair with onSelect."
        },
        {
          "name": "onSelect",
          "type": "(id: string, item: TaskListItem) => void",
          "description": "Makes rows buttons (with Up/Down/Home/End navigation) and reports the clicked item. Without it rows are static."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Content when `items` is empty. Default: a quiet 'No plan yet' state."
        },
        {
          "name": "label",
          "type": "string",
          "description": "Accessible name of the list. Default: `title` when it is a string, else 'Plan'."
        }
      ],
      "parts": [
        {
          "name": "TaskList",
          "description": "Root. `data-variant`, `data-progress=\"done/total\"`; contains a polite status region.",
          "dataSlot": "task-list"
        },
        {
          "name": "Header",
          "description": "h-10 header: title, mono 'n of m', segmented progress, actions.",
          "dataSlot": "task-list-header"
        },
        {
          "name": "Progress",
          "description": "One 4px segment per top-level item, coloured by status (neutral done, blue running, amber waiting, red failed).",
          "dataSlot": "task-list-progress"
        },
        {
          "name": "Completed",
          "description": "The folded group of finished items. `data-state=\"open\" | \"closed\"`; its button has aria-expanded.",
          "dataSlot": "task-list-completed"
        },
        {
          "name": "Item",
          "description": "<li> with `data-status` and `data-selected`; the running item has aria-current=\"step\".",
          "dataSlot": "task-list-item"
        },
        {
          "name": "Row",
          "description": "The row: StatusGlyph, title, detail, duration. A <button> when onSelect is set.",
          "dataSlot": "task-list-row"
        },
        {
          "name": "Title",
          "description": "Shimmers while running; muted when done; struck through when skipped. Status is spoken after it.",
          "dataSlot": "task-list-title"
        },
        {
          "name": "Detail",
          "description": "Second line. Red for failed, amber for waiting, subtle otherwise.",
          "dataSlot": "task-list-detail"
        },
        {
          "name": "Duration",
          "description": "Mono, tabular, width-reserved. Live clock (0:42) while running, final duration (1m 12s) after.",
          "dataSlot": "task-list-duration"
        },
        {
          "name": "Children",
          "description": "Nested <ul> of sub-tasks with a hairline guide; the parent shows 'done/total'.",
          "dataSlot": "task-list-children"
        },
        {
          "name": "Empty",
          "description": "Rendered when there are no items.",
          "dataSlot": "task-list-empty"
        }
      ],
      "a11y": {
        "role": "list",
        "notes": [
          "A <ul> labelled by `label`/`title`; each item speaks its status after the title (“Run pnpm test (failed)”), and the running item has aria-current=\"step\".",
          "A polite status region announces the current task (“Task 4 of 7: Patch src/middleware.ts”), a failure, “Waiting for you” or “All tasks complete”. Live timers are never announced.",
          "With onSelect, rows are buttons with aria-pressed reflecting selectedId; the Completed toggle has aria-expanded/aria-controls.",
          "Status is never colour-only: the glyph shape and the spoken status carry it."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Moves into the list (rows are buttons when onSelect is set)."
          },
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Moves focus to the next / previous row."
          },
          {
            "keys": "Home / End",
            "action": "Moves focus to the first / last row."
          },
          {
            "keys": "Enter / Space",
            "action": "Selects the focused task, or toggles the Completed group."
          }
        ],
        "reducedMotion": "Rows fade in without rising, the title shimmer and running segment pulse stop, and the chevron turns without animating."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "shimmer-text",
          "status-glyph"
        ]
      },
      "related": [
        "agent-timeline",
        "agent-fleet",
        "run-header",
        "trace-waterfall",
        "status-glyph"
      ],
      "tags": [
        "agent",
        "plan",
        "todo",
        "tasks",
        "checklist",
        "progress",
        "claude-code",
        "run"
      ],
      "examples": [
        {
          "name": "TaskListMinimal",
          "title": "A plan in progress",
          "description": "Two done, one running with a live timer, one queued.",
          "kind": "minimal",
          "code": "import { TaskList } from \"@kura-ui/react/task-list\";\n\nexport function TaskListMinimal() {\n  return (\n    <TaskList\n      className=\"w-full max-w-md\"\n      items={[\n        { id: \"read\", title: \"Read routes in src/app\", status: \"done\", durationMs: 21_400 },\n        { id: \"map\", title: \"Map call sites of fetchUser()\", status: \"done\", durationMs: 48_200 },\n        {\n          id: \"patch\",\n          title: \"Patch src/auth/client.ts\",\n          status: \"running\",\n          startedAt: Date.now() - 34_000,\n        },\n        { id: \"test\", title: \"Run pnpm test\", status: \"queued\" },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "TaskListFull",
          "title": "Live run",
          "description": "The auth-client migration plays out: finished tasks fold into Completed, sub-tasks tick, the test run fails, a fix task appears, the re-run passes, the reviewer signs off and the PR opens.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { TaskList, type TaskListItem } from \"@kura-ui/react/task-list\";\n\nconst PLAN: TaskListItem[] = [\n  { id: \"routes\", title: \"Read routes in src/app\", status: \"done\", durationMs: 21_400 },\n  {\n    id: \"map\",\n    title: \"Map call sites of fetchUser()\",\n    status: \"done\",\n    durationMs: 48_200,\n    detail: \"14 call sites across 9 files\",\n  },\n  {\n    id: \"client\",\n    title: \"Patch src/auth/client.ts\",\n    status: \"done\",\n    durationMs: 72_900,\n  },\n  { id: \"middleware\", title: \"Patch src/middleware.ts\", status: \"running\" },\n  {\n    id: \"tests\",\n    title: \"Update tests\",\n    status: \"queued\",\n    children: [\n      { id: \"tests-login\", title: \"login.test.ts\", status: \"queued\" },\n      { id: \"tests-session\", title: \"session.test.ts\", status: \"queued\" },\n    ],\n  },\n  { id: \"run\", title: \"Run pnpm test\", status: \"queued\" },\n  { id: \"review\", title: \"Review the diff\", status: \"queued\" },\n  { id: \"pr\", title: \"Open pull request\", status: \"queued\" },\n];\n\nfunction set(items: TaskListItem[], id: string, patch: Partial<TaskListItem>): TaskListItem[] {\n  return items.map((item) =>\n    item.id === id\n      ? { ...item, ...patch }\n      : item.children\n        ? { ...item, children: set([...item.children], id, patch) }\n        : item,\n  );\n}\n\nconst SCRIPT: Beat[] = [\n  {\n    at: 2600,\n    patch: (s) =>\n      set(\n        set(set(s, \"middleware\", { status: \"done\", durationMs: 41_300 }), \"tests\", {\n          status: \"running\",\n          startedAt: Date.now(),\n        }),\n        \"tests-login\",\n        { status: \"running\", startedAt: Date.now() },\n      ),\n  },\n  {\n    at: 4400,\n    patch: (s) =>\n      set(set(s, \"tests-login\", { status: \"done\", durationMs: 18_200 }), \"tests-session\", {\n        status: \"running\",\n        startedAt: Date.now(),\n      }),\n  },\n  {\n    at: 6000,\n    patch: (s) =>\n      set(\n        set(set(s, \"tests-session\", { status: \"done\", durationMs: 12_700 }), \"tests\", {\n          status: \"done\",\n          durationMs: 30_900,\n        }),\n        \"run\",\n        { status: \"running\", startedAt: Date.now(), detail: \"pnpm test --filter @acme/web\" },\n      ),\n  },\n  {\n    at: 8400,\n    patch: (s) =>\n      insertAfter(\n        set(s, \"run\", {\n          status: \"failed\",\n          durationMs: 38_400,\n          detail: \"login.test.ts › refreshes an expired session — expected 200, received 401\",\n        }),\n        \"run\",\n        {\n          id: \"fix\",\n          title: \"Retry refresh with the rotated token\",\n          status: \"running\",\n          startedAt: Date.now(),\n          detail: \"src/auth/client.ts:88\",\n        },\n      ),\n  },\n  {\n    at: 11_000,\n    patch: (s) =>\n      set(set(s, \"fix\", { status: \"done\", durationMs: 52_100, detail: undefined }), \"run\", {\n        status: \"running\",\n        startedAt: Date.now(),\n        detail: \"pnpm test --filter @acme/web\",\n      }),\n  },\n  {\n    at: 13_200,\n    patch: (s) =>\n      set(set(s, \"run\", { status: \"done\", durationMs: 36_800, detail: \"142 passed\" }), \"review\", {\n        status: \"running\",\n        startedAt: Date.now(),\n        detail: \"reviewer subagent\",\n      }),\n  },\n  {\n    at: 15_400,\n    patch: (s) =>\n      set(\n        set(s, \"review\", { status: \"done\", durationMs: 186_000, detail: \"2 nits, 0 blocking\" }),\n        \"pr\",\n        { status: \"running\", startedAt: Date.now() },\n      ),\n  },\n  {\n    at: 17_000,\n    patch: (s) => set(s, \"pr\", { status: \"done\", durationMs: 48_100, detail: \"acme/web#482\" }),\n  },\n];\n\nfunction insertAfter(items: TaskListItem[], id: string, next: TaskListItem): TaskListItem[] {\n  const i = items.findIndex((item) => item.id === id);\n  return [...items.slice(0, i + 1), next, ...items.slice(i + 1)];\n}\n\nexport function TaskListFull() {\n  const [items, setItems] = React.useState<TaskListItem[]>(() =>\n    set(PLAN, \"middleware\", { startedAt: Date.now() - 27_000 }),\n  );\n  const [selectedId, setSelectedId] = React.useState<string | null>(null);\n  const [run, setRun] = React.useState(0);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the script\n  React.useEffect(() => {\n    setItems(set(PLAN, \"middleware\", { startedAt: Date.now() - 27_000 }));\n    const timers = SCRIPT.map((beat) => setTimeout(() => setItems(beat.patch), beat.at));\n    return () => timers.forEach(clearTimeout);\n  }, [run]);\n\n  return (\n    <TaskList\n      className=\"w-full max-w-md\"\n      items={items}\n      label=\"Migrate to @acme/auth client\"\n      selectedId={selectedId}\n      onSelect={(id) => setSelectedId((cur) => (cur === id ? null : id))}\n      actions={\n        <IconButton\n          label=\"Replay\"\n          icon={<RotateCcw />}\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"-mr-1.5\"\n          onClick={() => setRun((r) => r + 1)}\n        />\n      }\n    />\n  );\n}\n"
        },
        {
          "name": "TaskListStates",
          "title": "Every state",
          "description": "Done, skipped, waiting for approval, failed with a reason, paused and queued.",
          "kind": "state",
          "code": "import { TaskList } from \"@kura-ui/react/task-list\";\n\nexport function TaskListStates() {\n  return (\n    <TaskList\n      className=\"w-full max-w-md\"\n      title=\"Release checklist\"\n      collapseCompleted={false}\n      items={[\n        { id: \"a\", title: \"Bump version to 2.4.0\", status: \"done\", durationMs: 3_200 },\n        {\n          id: \"b\",\n          title: \"Regenerate API types\",\n          status: \"skipped\",\n          detail: \"Schema unchanged since 2.3.1\",\n        },\n        {\n          id: \"c\",\n          title: \"Run database migration 0042_sessions\",\n          status: \"waiting\",\n          detail: \"Needs your approval — touches production\",\n        },\n        {\n          id: \"d\",\n          title: \"Build preview deployment\",\n          status: \"failed\",\n          durationMs: 84_100,\n          detail: \"next build exited with code 1 — Type error in src/middleware.ts:31\",\n        },\n        { id: \"e\", title: \"Notify #releases\", status: \"paused\" },\n        { id: \"f\", title: \"Tag release\", status: \"queued\" },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "TaskListComposition",
          "title": "Plan beside output",
          "description": "The plain variant in a sidebar; selecting a task shows its output on the right.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { TaskList, type TaskListItem } from \"@kura-ui/react/task-list\";\n\nconst OUTPUT: Record<string, string[]> = {\n  routes: [\n    \"$ rg --files src/app | rg route\",\n    \"src/app/api/session/route.ts\",\n    \"src/app/login/page.tsx\",\n    \"src/app/(app)/layout.tsx\",\n  ],\n  map: [\n    \"explorer · 14 call sites of fetchUser()\",\n    \"src/app/(app)/layout.tsx:12\",\n    \"src/components/nav/user-menu.tsx:8\",\n    \"src/lib/server/session.ts:44\",\n    \"… 11 more\",\n  ],\n  client: [\n    \"Edited src/auth/client.ts  +64 −22\",\n    \"Replaced legacy REST session calls\",\n    \"with createAuthClient({ baseUrl })\",\n  ],\n  run: [\n    \"$ pnpm test\",\n    \"✕ login.test.ts › refreshes an expired session\",\n    \"  expected 200, received 401\",\n    \"141 passed, 1 failed (38.4s)\",\n  ],\n};\n\nexport function TaskListComposition() {\n  const [selectedId, setSelectedId] = React.useState(\"run\");\n  const [t0] = React.useState(() => Date.now());\n  const items: TaskListItem[] = [\n    { id: \"routes\", title: \"Read routes\", status: \"done\", durationMs: 21_400 },\n    { id: \"map\", title: \"Map fetchUser() calls\", status: \"done\", durationMs: 48_200 },\n    { id: \"client\", title: \"Patch auth client\", status: \"done\", durationMs: 72_900 },\n    {\n      id: \"run\",\n      title: \"Run pnpm test\",\n      status: \"failed\",\n      durationMs: 38_400,\n      detail: \"1 failed\",\n    },\n    { id: \"fix\", title: \"Fix token refresh\", status: \"running\", startedAt: t0 - 19_000 },\n  ];\n  const lines = OUTPUT[selectedId] ?? [\"No output yet.\"];\n  return (\n    <div className=\"grid w-full max-w-2xl overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm sm:grid-cols-[240px_1fr]\">\n      <div className=\"border-kura-border-subtle border-b sm:border-r sm:border-b-0\">\n        <div className=\"flex h-10 items-center px-3.5 font-medium text-[13px]\">Plan</div>\n        <TaskList\n          variant=\"plain\"\n          collapseCompleted={false}\n          items={items}\n          selectedId={selectedId}\n          onSelect={(id) => setSelectedId(id)}\n          label=\"Plan\"\n        />\n      </div>\n      <div className=\"flex min-w-0 flex-col\">\n        <div className=\"flex h-10 items-center gap-2 border-kura-border-subtle border-b px-3.5\">\n          <span className=\"truncate font-medium text-[13px]\">\n            {items.find((i) => i.id === selectedId)?.title}\n          </span>\n          <span className=\"ml-auto font-kura-mono text-[12px] text-kura-fg-subtle\">output</span>\n        </div>\n        <pre className=\"m-0 min-h-40 flex-1 overflow-auto bg-kura-surface-sunken px-3.5 py-3 font-kura-mono text-[12px] text-kura-fg-muted leading-5\">\n          {lines.join(\"\\n\")}\n        </pre>\n      </div>\n    </div>\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Map a model-maintained todo list directly: pending → queued, in_progress → running, completed → done. Keep ids stable so rows don't remount.",
        "Pass `startedAt` for the running item (from your server) so the live timer survives reloads; pass `durationMs` once it finishes.",
        "Only one top-level item should be `running`; it carries the blue signal and the shimmer.",
        "Use `waiting` (amber) only when the agent is blocked on the human, and put the ask in `detail`.",
        "The Completed fold only collapses the *leading* finished items, so a failed task in the middle is never hidden."
      ],
      "title": "Task List",
      "importPath": "@kura-ui/react/task-list",
      "exports": [
        "TaskList",
        "TaskListItem",
        "TaskListProps"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/task-list",
      "markdownUrl": "https://kuro.abstergo.fyi/components/task-list.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/task-list.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/task-list.json",
        "cli": "npx @kura-ui/cli add task-list",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-format",
        "shimmer-text",
        "status-glyph",
        "use-controllable-state",
        "use-elapsed",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "items": {
            "description": "The plan, in order. TaskListItem = { id, title, status: \"queued\" | \"running\" | \"waiting\" | \"done\" | \"failed\" | \"skipped\" | \"paused\", detail?, durationMs?, startedAt?, children? }.",
            "x-ts-type": "readonly TaskListItem[]",
            "type": "array"
          },
          "title": {
            "description": "Heading in the panel header (panel variant only).",
            "x-ts-type": "React.ReactNode",
            "default": "Plan"
          },
          "actions": {
            "description": "Controls on the right of the header, after the progress bar (e.g. an IconButton).",
            "x-ts-type": "React.ReactNode"
          },
          "variant": {
            "description": "panel = card with header, '3 of 7' and segmented progress; plain = rows only, for embedding in your own sidebar.",
            "x-ts-type": "\"panel\" | \"plain\"",
            "type": "string",
            "enum": [
              "panel",
              "plain"
            ],
            "default": "panel"
          },
          "collapseCompleted": {
            "description": "Fold the leading run of finished (done/skipped) items into a 'Completed (n)' row while work remains. Needs at least three finished items; a fully finished plan is shown in full.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "completedOpen": {
            "description": "Whether the Completed group is expanded (controlled).",
            "x-ts-type": "boolean",
            "type": "boolean"
          },
          "defaultCompletedOpen": {
            "description": "Initial expanded state of the Completed group when uncontrolled.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": false
          },
          "onCompletedOpenChange": {
            "description": "Called when the Completed group is toggled.",
            "x-ts-type": "(open: boolean) => void"
          },
          "selectedId": {
            "description": "Highlighted item (bg-kura-active plus a 2px accent bar). Purely visual; pair with onSelect.",
            "x-ts-type": "string | null"
          },
          "onSelect": {
            "description": "Makes rows buttons (with Up/Down/Home/End navigation) and reports the clicked item. Without it rows are static.",
            "x-ts-type": "(id: string, item: TaskListItem) => void"
          },
          "empty": {
            "description": "Content when `items` is empty. Default: a quiet 'No plan yet' state.",
            "x-ts-type": "React.ReactNode"
          },
          "label": {
            "description": "Accessible name of the list. Default: `title` when it is a string, else 'Plan'.",
            "x-ts-type": "string",
            "type": "string"
          }
        },
        "required": [
          "items"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/task-list/index.ts",
          "type": "registry:ui",
          "content": "export { TaskList, type TaskListItem, type TaskListProps } from \"./task-list\";\n"
        },
        {
          "target": "components/kura/task-list/task-list.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ChevronRight, ListTodo } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock, formatDuration } from \"@/lib/kura/format\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\n\nexport interface TaskListItem {\n  /** Stable key. Keep it stable across updates so rows don't remount. */\n  id: string;\n  /** Imperative, one line: \"Patch src/auth/client.ts\". */\n  title: React.ReactNode;\n  status: AgentStatus;\n  /** Secondary line. Always shown for failed and waiting items; for the others when given. */\n  detail?: React.ReactNode;\n  /** Final duration (ms) of a finished item, shown on the right in mono. */\n  durationMs?: number;\n  /** When a running item started (wall clock). Drives the live timer on the right. */\n  startedAt?: Date | number;\n  /** Sub-tasks, rendered indented under the item. One level is the sweet spot. */\n  children?: readonly TaskListItem[];\n}\n\nexport interface TaskListProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onSelect\"> {\n  /** The plan, in order. */\n  items: readonly TaskListItem[];\n  /** Heading in the panel header. Default \"Plan\". */\n  title?: React.ReactNode;\n  /** Extra controls on the right of the header (e.g. an IconButton). */\n  actions?: React.ReactNode;\n  /** `panel` = card with header and segmented progress (default); `plain` = rows only, for sidebars. */\n  variant?: \"panel\" | \"plain\";\n  /** Fold the leading run of finished items into a \"Completed (n)\" row while work remains. Default true. */\n  collapseCompleted?: boolean;\n  /** Whether the \"Completed\" group is expanded (controlled). */\n  completedOpen?: boolean;\n  /** Initial state of the \"Completed\" group when uncontrolled. Default false. */\n  defaultCompletedOpen?: boolean;\n  onCompletedOpenChange?: (open: boolean) => void;\n  /** Highlighted item (e.g. the one whose output is shown next to the list). */\n  selectedId?: string | null;\n  /** Makes rows interactive (buttons with arrow-key navigation) and reports the clicked item. */\n  onSelect?: (id: string, item: TaskListItem) => void;\n  /** Rendered when `items` is empty. Default: a quiet \"No plan yet\" state. */\n  empty?: React.ReactNode;\n  /** Accessible name of the list. Default: \"Plan\". */\n  label?: string;\n}\n\nconst FINISHED = new Set<AgentStatus>([\"done\", \"skipped\"]);\n\nconst SPOKEN: Record<AgentStatus, string> = {\n  queued: \"pending\",\n  running: \"in progress\",\n  waiting: \"waiting for you\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n  paused: \"paused\",\n};\n\nconst SEGMENT: Record<AgentStatus, string> = {\n  queued: \"bg-kura-hover\",\n  running: \"bg-kura-accent\",\n  waiting: \"bg-kura-warning\",\n  done: \"bg-kura-fg-muted\",\n  failed: \"bg-kura-danger\",\n  skipped: \"bg-kura-border-strong\",\n  paused: \"bg-kura-border-strong\",\n};\n\nconst rowEnter =\n  \"transition-[opacity,translate,background-color] duration-(--kura-duration-base) ease-kura-out starting:translate-y-[2px] starting:opacity-0 motion-reduce:transition-[opacity,background-color] motion-reduce:starting:translate-y-0\";\n\nfunction textColor(status: AgentStatus): string {\n  switch (status) {\n    case \"done\":\n    case \"skipped\":\n      return \"text-kura-fg-subtle\";\n    case \"queued\":\n    case \"paused\":\n      return \"text-kura-fg-muted\";\n    default:\n      return \"text-kura-fg\";\n  }\n}\n\nfunction Timer({ item }: { item: TaskListItem }) {\n  const running = item.status === \"running\";\n  const live = useElapsed(item.startedAt, running, 1000);\n  let text: string | null = null;\n  if (running && live != null) text = formatClock(live);\n  else if (item.durationMs != null) text = formatDuration(item.durationMs);\n  return (\n    <span\n      data-slot=\"task-list-duration\"\n      className={cn(\n        \"min-w-[4.5ch] shrink-0 text-right font-kura-mono text-[12px] tabular-nums\",\n        running ? \"text-kura-fg-muted\" : \"text-kura-fg-subtle\",\n      )}\n    >\n      {text}\n    </span>\n  );\n}\n\ninterface RowProps {\n  item: TaskListItem;\n  depth: number;\n  selectedId: string | null | undefined;\n  onSelect: TaskListProps[\"onSelect\"];\n}\n\nfunction Row({ item, depth, selectedId, onSelect }: RowProps) {\n  const { status } = item;\n  const interactive = onSelect != null;\n  const selected = selectedId === item.id;\n  const childDone = item.children?.filter((c) => FINISHED.has(c.status)).length ?? 0;\n  const showDetail =\n    item.detail != null &&\n    (status === \"failed\" || status === \"waiting\" || status === \"running\" || depth === 0);\n  const titleNode =\n    status === \"running\" ? (\n      <ShimmerText duration={2.4}>{item.title}</ShimmerText>\n    ) : (\n      <span>{item.title}</span>\n    );\n\n  const inner = (\n    <>\n      <span className=\"flex h-5 shrink-0 items-center\">\n        <StatusGlyph status={status} size=\"sm\" label={null} />\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span className=\"flex min-h-5 items-center gap-2\">\n          <span\n            data-slot=\"task-list-title\"\n            className={cn(\n              \"min-w-0 truncate text-[13px] leading-5 transition-colors duration-(--kura-duration-base)\",\n              textColor(status),\n              status === \"running\" && \"font-medium\",\n              status === \"skipped\" && \"line-through decoration-kura-border-strong\",\n              depth > 0 && \"text-[12.5px]\",\n            )}\n          >\n            {titleNode}\n            <span className=\"sr-only\"> ({SPOKEN[status]})</span>\n          </span>\n          {item.children && item.children.length > 0 && (\n            <span className=\"shrink-0 font-kura-mono text-[11.5px] text-kura-fg-subtle tabular-nums\">\n              {childDone}/{item.children.length}\n            </span>\n          )}\n        </span>\n        {showDetail && (\n          <span\n            data-slot=\"task-list-detail\"\n            className={cn(\n              \"text-[12px] leading-[18px]\",\n              status === \"failed\"\n                ? \"text-kura-danger-text\"\n                : status === \"waiting\"\n                  ? \"text-kura-warning-text\"\n                  : \"text-kura-fg-subtle\",\n            )}\n          >\n            {item.detail}\n          </span>\n        )}\n      </span>\n      <span className=\"flex h-5 items-center\">\n        <Timer item={item} />\n      </span>\n    </>\n  );\n\n  const rowClass = cn(\n    \"relative flex w-full items-start gap-2.5 py-1.5 pr-3.5 text-left\",\n    depth === 0 ? \"pl-3.5\" : \"pl-[calc(0.875rem+24px)]\",\n    selected &&\n      \"bg-kura-active before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-kura-accent\",\n  );\n\n  return (\n    <li\n      data-slot=\"task-list-item\"\n      data-status={status}\n      data-selected={selected || undefined}\n      aria-current={status === \"running\" ? \"step\" : undefined}\n      className={cn(rowEnter, depth === 0 && \"border-kura-border-subtle border-t first:border-t-0\")}\n    >\n      {interactive ? (\n        <button\n          type=\"button\"\n          data-slot=\"task-list-row\"\n          data-task-row=\"\"\n          aria-pressed={selected}\n          onClick={() => onSelect(item.id, item)}\n          className={cn(\n            rowClass,\n            \"cursor-default outline-none transition-colors duration-(--kura-duration-instant) hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n            selected && \"hover:bg-kura-active\",\n          )}\n        >\n          {inner}\n        </button>\n      ) : (\n        <div data-slot=\"task-list-row\" className={rowClass}>\n          {inner}\n        </div>\n      )}\n      {item.children && item.children.length > 0 && (\n        <ul data-slot=\"task-list-children\" className=\"relative pb-1\">\n          <span\n            aria-hidden\n            className=\"absolute top-0 bottom-2.5 left-[calc(0.875rem+6.5px)] w-px bg-kura-border-subtle\"\n          />\n          {item.children.map((child) => (\n            <Row\n              key={child.id}\n              item={child}\n              depth={depth + 1}\n              selectedId={selectedId}\n              onSelect={onSelect}\n            />\n          ))}\n        </ul>\n      )}\n    </li>\n  );\n}\n\nfunction sumDurations(items: readonly TaskListItem[]): number {\n  return items.reduce((acc, i) => acc + (i.durationMs ?? 0), 0);\n}\n\n/**\n * The agent's plan as a todo list (Claude Code TodoWrite style): a header with \"3 of 7\" and a\n * segmented progress bar, one row per task with a status glyph, a shimmering title for the running\n * task, live and final durations in mono, details for failures, nested sub-tasks, and the finished\n * head of the plan folded into a \"Completed (n)\" row.\n */\nexport function TaskList({\n  items,\n  title = \"Plan\",\n  actions,\n  variant = \"panel\",\n  collapseCompleted = true,\n  completedOpen: completedOpenProp,\n  defaultCompletedOpen = false,\n  onCompletedOpenChange,\n  selectedId,\n  onSelect,\n  empty,\n  label,\n  className,\n  onKeyDown,\n  ...props\n}: TaskListProps) {\n  const [completedOpen, setCompletedOpen] = useControllableState({\n    value: completedOpenProp,\n    defaultValue: defaultCompletedOpen,\n    onChange: onCompletedOpenChange,\n  });\n  const groupId = React.useId();\n\n  const total = items.length;\n  const doneCount = items.filter((i) => FINISHED.has(i.status)).length;\n  const runningIndex = items.findIndex((i) => i.status === \"running\");\n  const failedItem = items.find((i) => i.status === \"failed\");\n  const waitingItem = items.find((i) => i.status === \"waiting\");\n  const allFinished = total > 0 && doneCount === total;\n\n  // The leading run of finished items folds away while there is still work ahead.\n  let lead = 0;\n  while (lead < items.length && FINISHED.has(items[lead]!.status)) lead++;\n  const fold = collapseCompleted && !allFinished && lead >= 3;\n  const head = fold ? items.slice(0, lead) : [];\n  const rest = fold ? items.slice(lead) : items;\n\n  const runningTitle = runningIndex >= 0 ? items[runningIndex]!.title : null;\n  const announcement = failedItem\n    ? `Task failed: ${typeof failedItem.title === \"string\" ? failedItem.title : \"\"}`\n    : waitingItem\n      ? \"Waiting for you\"\n      : allFinished\n        ? \"All tasks complete\"\n        : runningIndex >= 0\n          ? `Task ${runningIndex + 1} of ${total}${typeof runningTitle === \"string\" ? `: ${runningTitle}` : \"\"}`\n          : \"\";\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || !onSelect) return;\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n    const rows = Array.from(\n      e.currentTarget.querySelectorAll<HTMLElement>(\"[data-task-row], [data-task-group]\"),\n    );\n    const index = rows.indexOf(document.activeElement as HTMLElement);\n    if (index < 0) return;\n    e.preventDefault();\n    const next =\n      e.key === \"Home\"\n        ? 0\n        : e.key === \"End\"\n          ? rows.length - 1\n          : Math.min(rows.length - 1, Math.max(0, index + (e.key === \"ArrowDown\" ? 1 : -1)));\n    rows[next]?.focus();\n  };\n\n  const listLabel = label ?? (typeof title === \"string\" ? title : \"Plan\");\n\n  const body =\n    total === 0 ? (\n      <div\n        data-slot=\"task-list-empty\"\n        className=\"flex flex-col items-center gap-1 px-6 py-8 text-center\"\n      >\n        {empty ?? (\n          <>\n            <ListTodo aria-hidden strokeWidth={1.75} className=\"mb-1 size-4 text-kura-fg-subtle\" />\n            <span className=\"font-medium text-[13px] text-kura-fg\">No plan yet</span>\n            <span className=\"text-[12px] text-kura-fg-subtle\">\n              Tasks appear here once the agent writes its plan.\n            </span>\n          </>\n        )}\n      </div>\n    ) : (\n      <ul aria-label={listLabel} data-slot=\"task-list-list\" className=\"flex flex-col py-1\">\n        {fold && (\n          <li data-slot=\"task-list-completed\" data-state={completedOpen ? \"open\" : \"closed\"}>\n            <button\n              type=\"button\"\n              data-task-group=\"\"\n              aria-expanded={completedOpen}\n              aria-controls={groupId}\n              onClick={() => setCompletedOpen((o) => !o)}\n              className=\"flex h-8 w-full cursor-default items-center gap-2.5 px-3.5 text-left text-[13px] text-kura-fg-subtle outline-none transition-colors duration-(--kura-duration-instant) hover:bg-kura-hover hover:text-kura-fg-muted focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\"\n            >\n              <span className=\"grid size-3.5 place-items-center\">\n                <ChevronRight\n                  aria-hidden\n                  strokeWidth={2}\n                  className={cn(\n                    \"size-3.5 transition-transform duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-none\",\n                    completedOpen && \"rotate-90\",\n                  )}\n                />\n              </span>\n              <span className=\"flex-1\">\n                Completed <span className=\"font-kura-mono tabular-nums\">({head.length})</span>\n              </span>\n              <span className=\"font-kura-mono text-[12px] tabular-nums\">\n                {formatDuration(sumDurations(head))}\n              </span>\n            </button>\n            <ul id={groupId} hidden={!completedOpen} aria-label=\"Completed tasks\" className=\"pb-1\">\n              {head.map((item) => (\n                <Row\n                  key={item.id}\n                  item={item}\n                  depth={0}\n                  selectedId={selectedId}\n                  onSelect={onSelect}\n                />\n              ))}\n            </ul>\n          </li>\n        )}\n        {rest.map((item) => (\n          <Row key={item.id} item={item} depth={0} selectedId={selectedId} onSelect={onSelect} />\n        ))}\n      </ul>\n    );\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: arrow-key roving between the native row buttons inside.\n    <div\n      data-slot=\"task-list\"\n      data-variant={variant}\n      data-progress={`${doneCount}/${total}`}\n      className={cn(\n        \"flex min-w-0 flex-col font-kura-sans text-kura-fg\",\n        variant === \"panel\" && \"overflow-hidden rounded-kura-xl bg-kura-surface shadow-kura-sm\",\n        className,\n      )}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      {variant === \"panel\" && (\n        <div\n          data-slot=\"task-list-header\"\n          className=\"flex h-10 shrink-0 items-center gap-3 border-kura-border-subtle border-b px-3.5\"\n        >\n          <span className=\"font-medium text-[13px] tracking-[-0.005em]\">{title}</span>\n          {total > 0 && (\n            <span className=\"font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n              {doneCount} of {total}\n            </span>\n          )}\n          <span className=\"flex-1\" />\n          {total > 0 && (\n            <span\n              data-slot=\"task-list-progress\"\n              aria-hidden\n              className=\"flex w-[clamp(64px,28%,140px)] items-center gap-[3px]\"\n            >\n              {items.map((item) => (\n                <span\n                  key={item.id}\n                  data-status={item.status}\n                  className={cn(\n                    \"h-1 min-w-0 flex-1 rounded-full transition-colors duration-(--kura-duration-slow) ease-kura-out\",\n                    SEGMENT[item.status],\n                    item.status === \"running\" && \"animate-kura-pulse motion-reduce:animate-none\",\n                  )}\n                />\n              ))}\n            </span>\n          )}\n          {actions}\n        </div>\n      )}\n      {body}\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n"
        }
      ]
    },
    {
      "slug": "terminal",
      "name": "Terminal",
      "category": "workspace",
      "status": "experimental",
      "description": "Streamed command output that behaves like a real terminal: ANSI colours, progress-bar rewrites, stick-to-bottom, live clock and exit-code badge.",
      "whenToUse": [
        "Show a shell command an agent ran (pnpm test, git status, a build) with its output, while it runs and after.",
        "Stream a child process's stdout (AsyncIterable<string>) or a growing log string into the UI.",
        "Long logs where the tail matters: collapse to the last N lines with “Show all N lines”.",
        "A dev-server or watcher log with a Stop control in the header."
      ],
      "whenNotToUse": [
        "Source code or a snippet to copy — use code-block.",
        "Reviewing file edits — use diff-view.",
        "A tool call whose output is structured JSON — use tool-call.",
        "Interactive shells that take keyboard input — this renders output only (no PTY, no input line)."
      ],
      "props": [
        {
          "name": "command",
          "type": "string",
          "description": "The command, shown on the prompt line (`$ pnpm test`) and in the accessible name."
        },
        {
          "name": "lines",
          "type": "readonly string[] | string",
          "description": "Output as lines or one growing string. ANSI SGR (colours 30–37/90–97, 256/truecolour approximated, bold, dim, italic, underline, inverse) and cursor controls (\\r, erase line, cursor up, backspace) are rendered; OSC titles/links are stripped. Append-only updates are parsed incrementally."
        },
        {
          "name": "stream",
          "type": "AsyncIterable<string>",
          "description": "Output as a stream of chunks; takes precedence over `lines`. Chunks may split lines and escape sequences. Status is “running” until it ends."
        },
        {
          "name": "onStreamEnd",
          "type": "() => void",
          "description": "Called once `stream` has ended."
        },
        {
          "name": "status",
          "type": "\"idle\" | \"running\" | \"done\" | \"failed\"",
          "description": "Lifecycle. Default: from exitCode (0 → done, otherwise failed), \"running\" while a stream is open, else \"idle\" (no glyph)."
        },
        {
          "name": "exitCode",
          "type": "number | null",
          "description": "Exit code, shown as a badge once finished (red when non-zero)."
        },
        {
          "name": "startedAt",
          "type": "Date | number",
          "description": "Start time for the live clock. Default: when status became running."
        },
        {
          "name": "duration",
          "type": "number",
          "description": "Final duration in ms for persisted runs. Default: measured from running → finished in this session."
        },
        {
          "name": "cwd",
          "type": "string",
          "description": "Working directory in the header, e.g. \"~/acme/web\"."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "description": "Header title, e.g. \"Run tests\". Default: the cwd, or \"Terminal\"."
        },
        {
          "name": "prompt",
          "type": "string",
          "default": "\"$\"",
          "description": "Prompt glyph before the command."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "default": "320",
          "description": "Viewport height in px. Output sticks to the bottom while the user is there; scrolling up shows “Jump to latest” / “N new lines”."
        },
        {
          "name": "maxLines",
          "type": "number",
          "description": "Collapse to the last N lines with a “Show all N lines” control at the top."
        },
        {
          "name": "expanded",
          "type": "boolean",
          "description": "Showing every line past maxLines (controlled)."
        },
        {
          "name": "defaultExpanded",
          "type": "boolean",
          "default": "false",
          "description": "Initial expanded state when uncontrolled."
        },
        {
          "name": "onExpandedChange",
          "type": "(expanded: boolean) => void",
          "description": "Called when “Show all” / “Show last N” is used."
        },
        {
          "name": "wrap",
          "type": "boolean",
          "default": "true",
          "description": "Soft-wrap long lines."
        },
        {
          "name": "copyable",
          "type": "boolean",
          "default": "true",
          "description": "Show the copy button: copies `$ command` plus the plain output (no escape codes)."
        },
        {
          "name": "onCopy",
          "type": "(text: string) => void",
          "description": "Called with the copied text after a successful copy."
        },
        {
          "name": "header",
          "type": "boolean",
          "default": "true",
          "description": "Show the header row."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Extra header controls before the copy button, e.g. a Stop Button."
        }
      ],
      "parts": [
        {
          "name": "Terminal",
          "description": "`data-status=\"idle\" | \"running\" | \"done\" | \"failed\"`, `data-state=\"collapsed\" | \"expanded\"` when maxLines applies.",
          "dataSlot": "terminal"
        },
        {
          "name": "Header",
          "description": "Status glyph, title (`terminal-title`), cwd, live clock / duration (`terminal-elapsed`), exit badge (`terminal-exit-code`), actions, copy.",
          "dataSlot": "terminal-header"
        },
        {
          "name": "Output",
          "description": "`role=\"log\"` scroll viewport (aria-live off), keyboard-scrollable; prompt line (`terminal-command`) and lines (`terminal-line`).",
          "dataSlot": "terminal-output"
        },
        {
          "name": "Cursor",
          "description": "Block cursor at the live edge while running.",
          "dataSlot": "terminal-cursor"
        },
        {
          "name": "Expand",
          "description": "“Show all N lines” row when collapsed (`terminal-collapse` to collapse again).",
          "dataSlot": "terminal-expand"
        },
        {
          "name": "Jump",
          "description": "Floating “Jump to latest” / “N new lines” pill when scrolled away from the bottom.",
          "dataSlot": "terminal-jump"
        }
      ],
      "a11y": {
        "role": "log",
        "notes": [
          "The output is a role=log region named “Output of <command>” with aria-live off, so streaming never floods a screen reader.",
          "A separate polite status announces only transitions: “pnpm test running”, “pnpm test failed, exit code 1”, and copy confirmation.",
          "The viewport is focusable (Tab) and scrolls with arrow keys / Page Up / Page Down.",
          "Status is text as well as colour: the exit badge reads “exit 1”; the glyph is decorative next to it."
        ],
        "keyboard": [
          {
            "keys": "Tab",
            "action": "Focus the output, then the expand, jump, action and copy buttons"
          },
          {
            "keys": "Arrow keys / Page Up / Page Down",
            "action": "Scroll the focused output"
          },
          {
            "keys": "Enter / Space",
            "action": "Activate Show all / Jump to latest / Copy"
          }
        ],
        "reducedMotion": "The cursor stops pulsing, the running glyph stops spinning, and the copy icon swap and pill entrance become plain fades. Jump to latest still scrolls smoothly only if the browser allows it."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "icon-button",
          "status-glyph"
        ]
      },
      "related": [
        "diff-view",
        "file-changes",
        "code-block",
        "tool-call",
        "agent-timeline"
      ],
      "tags": [
        "terminal",
        "shell",
        "logs",
        "ansi",
        "streaming",
        "command",
        "agent",
        "workspace"
      ],
      "examples": [
        {
          "name": "TerminalMinimal",
          "title": "Finished command",
          "description": "`git status --short` with coloured output and an exit badge.",
          "kind": "minimal",
          "code": "import { Terminal } from \"@kura-ui/react/terminal\";\n\nconst red = (s: string) => `\\x1b[31m${s}\\x1b[39m`;\n\nexport function TerminalMinimal() {\n  return (\n    <Terminal\n      className=\"w-full max-w-2xl\"\n      cwd=\"~/acme/web\"\n      command=\"git status --short\"\n      exitCode={0}\n      lines={[\n        ` ${red(\"M\")} src/auth/client.ts`,\n        ` ${red(\"M\")} src/middleware.ts`,\n        ` ${red(\"M\")} src/auth/__tests__/login.test.ts`,\n        ` ${red(\"D\")} src/lib/legacy-session.ts`,\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "TerminalFull",
          "title": "Streaming test run",
          "description": "`pnpm test` streams vitest output line by line (live clock, cursor, stick-to-bottom), ends with one failing test and exit 1.",
          "kind": "full",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Terminal } from \"@kura-ui/react/terminal\";\n\nconst TEST_OUTPUT = [\n  \"\",\n  `> @acme/web@0.42.0 test ${dim(\"/Users/kura/acme/web\")}`,\n  \"> vitest run\",\n  \"\",\n  ` ${badge(\"RUN\", 36)} ${cyan(\"v3.2.4\")} ${gray(\"/Users/kura/acme/web\")}`,\n  \"\",\n  ` ${green(\"✓\")} ${dim(\"src/auth/__tests__/\")}session.test.ts ${gray(\"(6 tests)\")} ${gray(\"41ms\")}`,\n  ` ${green(\"✓\")} ${dim(\"src/\")}middleware.test.ts ${gray(\"(4 tests)\")} ${gray(\"18ms\")}`,\n  ` ${green(\"✓\")} ${dim(\"src/routes/__tests__/\")}account.test.ts ${gray(\"(9 tests)\")} ${gray(\"112ms\")}`,\n  ` ${yellow(\"❯\")} ${dim(\"src/auth/__tests__/\")}login.test.ts ${gray(\"(5 tests | \")}${red(\"1 failed\")}${gray(\")\")} ${gray(\"87ms\")}`,\n  `   ${green(\"✓\")} login ${gray(\">\")} detects an expired session ${gray(\"2ms\")}`,\n  `   ${red(\"×\")} login ${gray(\">\")} refreshes an expired session ${gray(\"14ms\")}`,\n  `     ${red(\"→ expected 'tok_expired' to be 'tok_next' // Object.is equality\")}`,\n  ` ${green(\"✓\")} ${dim(\"src/lib/__tests__/\")}http.test.ts ${gray(\"(7 tests)\")} ${gray(\"9ms\")}`,\n  \"\",\n  red(`⎯⎯⎯⎯⎯⎯⎯ ${bold(\"Failed Tests 1\")} ⎯⎯⎯⎯⎯⎯⎯`),\n  \"\",\n  ` ${badge(\"FAIL\", 31)} src/auth/__tests__/login.test.ts ${gray(\">\")} login ${gray(\">\")} refreshes an expired session`,\n  `${red(bold(\"AssertionError\"))}${red(\": expected 'tok_expired' to be 'tok_next' // Object.is equality\")}`,\n  \"\",\n  `${green(\"- Expected\")}`,\n  `${red(\"+ Received\")}`,\n  \"\",\n  `${green(\"- tok_next\")}`,\n  `${red(\"+ tok_expired\")}`,\n  \"\",\n  ` ${cyan(\"❯\")} src/auth/__tests__/login.test.ts:18:32`,\n  gray(\"     16|     const next = await refreshSession(session);\"),\n  gray('     17|     expect(next.user.id).toBe(\"u_1\");'),\n  `     18|     expect(next.refreshToken).toBe(${green('\"tok_next\"')});`,\n  `       | ${red(\"                               ^\")}`,\n  \"\",\n  ` ${dim(\"Test Files\")}  ${bold(red(\"1 failed\"))} ${gray(\"|\")} ${bold(green(\"4 passed\"))} ${gray(\"(5)\")}`,\n  `      ${dim(\"Tests\")}  ${bold(red(\"1 failed\"))} ${gray(\"|\")} ${bold(green(\"30 passed\"))} ${gray(\"(31)\")}`,\n  `   ${dim(\"Start at\")}  14:02:11`,\n  `   ${dim(\"Duration\")}  1.42s ${gray(\"(transform 212ms, setup 0ms, collect 610ms, tests 267ms)\")}`,\n  \"\",\n  `${red(\" ELIFECYCLE \")} ${red(\"Test failed. See above for more details.\")}`,\n];\n\nasync function* replay(lines: string[], signal: { cancelled: boolean }) {\n  for (const [i, line] of lines.entries()) {\n    // Collection is slow, results arrive in bursts, the summary lands at once.\n    const wait = i < 4 ? 260 : i < 14 ? 180 + (i % 3) * 140 : 35;\n    await new Promise((r) => setTimeout(r, wait));\n    if (signal.cancelled) return;\n    yield `${line}\\n`;\n  }\n}\n\nconst dim = (s: string) => `\\x1b[2m${s}\\x1b[22m`;\n\nconst bold = (s: string) => `\\x1b[1m${s}\\x1b[22m`;\n\nconst red = (s: string) => `\\x1b[31m${s}\\x1b[39m`;\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[39m`;\n\nconst yellow = (s: string) => `\\x1b[33m${s}\\x1b[39m`;\n\nconst cyan = (s: string) => `\\x1b[36m${s}\\x1b[39m`;\n\nconst gray = (s: string) => `\\x1b[90m${s}\\x1b[39m`;\n\nconst badge = (s: string, color: 31 | 32 | 34 | 36) => `\\x1b[1;7;${color}m ${s} \\x1b[0m`;\n\nexport function TerminalFull() {\n  const [stream, setStream] = React.useState<AsyncIterable<string>>();\n  const [exitCode, setExitCode] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    const signal = { cancelled: false };\n    setStream(replay(TEST_OUTPUT, signal));\n    return () => {\n      signal.cancelled = true;\n    };\n  }, []);\n  return (\n    <Terminal\n      className=\"w-full max-w-2xl\"\n      title=\"Run tests\"\n      cwd=\"~/acme/web\"\n      command=\"pnpm test\"\n      stream={stream}\n      status={exitCode == null ? \"running\" : undefined}\n      exitCode={exitCode}\n      onStreamEnd={() => setExitCode(1)}\n      maxHeight={360}\n    />\n  );\n}\n"
        },
        {
          "name": "TerminalCollapsed",
          "title": "Collapsed build log",
          "description": "A Next.js build collapsed to the last 9 lines; one line is a \\r-rewritten progress counter.",
          "kind": "state",
          "code": "import { Terminal } from \"@kura-ui/react/terminal\";\n\nconst BUILD_OUTPUT = [\n  `   ${bold(\"▲ Next.js 16.0.1\")}`,\n  `   - Environments: .env.production`,\n  \"\",\n  `   Creating an optimized production build ...`,\n  ` ${green(\"✓\")} Compiled successfully in 8.4s`,\n  ` ${green(\"✓\")} Linting and checking validity of types`,\n  ` ${green(\"✓\")} Collecting page data`,\n  // A progress line rewritten in place with carriage returns.\n  `   Generating static pages (0/14)\\r   Generating static pages (7/14)\\r ${green(\"✓\")} Generating static pages (14/14)`,\n  ` ${green(\"✓\")} Finalizing page optimization`,\n  \"\",\n  `${bold(\"Route (app)\")}                         ${bold(\"Size\")}  ${bold(\"First Load JS\")}`,\n  `┌ ○ /                              5.21 kB         ${green(\"112 kB\")}`,\n  `├ ○ /_not-found                       990 B         ${green(\"103 kB\")}`,\n  `├ ƒ /account                        3.84 kB         ${green(\"118 kB\")}`,\n  `├ ƒ /api/health                       142 B         ${green(\"102 kB\")}`,\n  `├ ○ /login                          2.67 kB         ${green(\"115 kB\")}`,\n  `└ ○ /signup                         2.71 kB         ${green(\"115 kB\")}`,\n  `+ First Load JS shared by all       ${green(\"102 kB\")}`,\n  \"\",\n  `ƒ Middleware                        ${green(\"34.1 kB\")}`,\n  \"\",\n  `○  (Static)   prerendered as static content`,\n  `ƒ  (Dynamic)  server-rendered on demand`,\n];\n\nconst bold = (s: string) => `\\x1b[1m${s}\\x1b[22m`;\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[39m`;\n\nexport function TerminalCollapsed() {\n  return (\n    <Terminal\n      className=\"w-full max-w-2xl\"\n      title=\"Build\"\n      cwd=\"~/acme/web\"\n      command=\"pnpm build\"\n      lines={BUILD_OUTPUT}\n      exitCode={0}\n      duration={23_400}\n      maxLines={9}\n      wrap={false}\n    />\n  );\n}\n"
        },
        {
          "name": "TerminalComposition",
          "title": "Dev server with Stop",
          "description": "A Button in `actions` stops a dev server that keeps logging.",
          "kind": "composition",
          "code": "\"use client\";\n\nimport { Square } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@kura-ui/react/button\";\nimport { Terminal } from \"@kura-ui/react/terminal\";\n\nconst green = (s: string) => `\\x1b[32m${s}\\x1b[39m`;\n\nconst DEV_LINES = [\n  `  ${bold(\"▲ Next.js 16.0.1\")} (Turbopack)`,\n  `  - Local:        ${cyan(\"http://localhost:3000\")}`,\n  `  - Network:      ${cyan(\"http://192.168.1.24:3000\")}`,\n  \"\",\n  ` ${green(\"✓\")} Starting...`,\n  ` ${green(\"✓\")} Ready in 1.1s`,\n  ` ${gray(\"○\")} Compiling /login ...`,\n  ` ${green(\"✓\")} Compiled /login in 640ms`,\n  ` GET /login ${green(\"200\")} in 702ms`,\n  ` POST /api/auth/session ${green(\"200\")} in 48ms`,\n  ` GET /account ${green(\"200\")} in 131ms`,\n  ` GET /api/auth/session ${yellow(\"401\")} in 12ms`,\n  ` GET /login?next=%2Faccount ${green(\"200\")} in 22ms`,\n];\n\nconst bold = (s: string) => `\\x1b[1m${s}\\x1b[22m`;\n\nconst yellow = (s: string) => `\\x1b[33m${s}\\x1b[39m`;\n\nconst cyan = (s: string) => `\\x1b[36m${s}\\x1b[39m`;\n\nconst gray = (s: string) => `\\x1b[90m${s}\\x1b[39m`;\n\nexport function TerminalComposition() {\n  const [lines, setLines] = React.useState<string[]>(DEV_LINES.slice(0, 6));\n  const [stopped, setStopped] = React.useState(false);\n  React.useEffect(() => {\n    if (stopped) return;\n    const id = setInterval(() => {\n      setLines((prev) =>\n        prev.length < DEV_LINES.length\n          ? [...prev, DEV_LINES[prev.length]!]\n          : [...prev, ` GET /api/health ${green(\"200\")} in ${4 + (prev.length % 7)}ms`],\n      );\n    }, 1400);\n    return () => clearInterval(id);\n  }, [stopped]);\n  return (\n    <Terminal\n      className=\"w-full max-w-2xl\"\n      title=\"Dev server\"\n      cwd=\"~/acme/web\"\n      command=\"pnpm dev\"\n      lines={stopped ? [...lines, \"^C\"] : lines}\n      status={stopped ? \"done\" : \"running\"}\n      maxHeight={240}\n      actions={\n        !stopped && (\n          <Button size=\"xs\" variant=\"ghost\" onClick={() => setStopped(true)}>\n            <Square aria-hidden className=\"size-3 fill-current\" />\n            Stop\n          </Button>\n        )\n      }\n    />\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Pass raw process output; don't strip ANSI codes. Escape sequences split across chunks are buffered.",
        "For a growing string, only append — replacing earlier text triggers a full re-parse (still correct, just slower).",
        "Keep the same Terminal mounted from running → done so the measured duration works; for history pass `duration`.",
        "`stream` is consumed once; create a new iterable to restart. Set `exitCode` in onStreamEnd to finish with a badge.",
        "Colours map to Kura tokens; override any of them with --kura-terminal-{red,green,yellow,blue,magenta,cyan,white,gray,black} on a parent.",
        "Helpers exported alongside: parseAnsi(text), stripAnsi(text), AnsiScreen (incremental)."
      ],
      "title": "Terminal",
      "importPath": "@kura-ui/react/terminal",
      "exports": [
        "AnsiColor",
        "AnsiScreen",
        "AnsiSpan",
        "AnsiStyle",
        "Terminal",
        "TerminalProps",
        "TerminalStatus",
        "parseAnsi",
        "stripAnsi"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/terminal",
      "markdownUrl": "https://kuro.abstergo.fyi/components/terminal.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/terminal.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/terminal.json",
        "cli": "npx @kura-ui/cli add terminal",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "icon-button",
        "kura-format",
        "status-glyph",
        "use-controllable-state",
        "use-elapsed",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "command": {
            "description": "The command, shown on the prompt line (`$ pnpm test`) and in the accessible name.",
            "x-ts-type": "string",
            "type": "string"
          },
          "lines": {
            "description": "Output as lines or one growing string. ANSI SGR (colours 30–37/90–97, 256/truecolour approximated, bold, dim, italic, underline, inverse) and cursor controls (\\r, erase line, cursor up, backspace) are rendered; OSC titles/links are stripped. Append-only updates are parsed incrementally.",
            "x-ts-type": "readonly string[] | string"
          },
          "stream": {
            "description": "Output as a stream of chunks; takes precedence over `lines`. Chunks may split lines and escape sequences. Status is “running” until it ends.",
            "x-ts-type": "AsyncIterable<string>"
          },
          "onStreamEnd": {
            "description": "Called once `stream` has ended.",
            "x-ts-type": "() => void"
          },
          "status": {
            "description": "Lifecycle. Default: from exitCode (0 → done, otherwise failed), \"running\" while a stream is open, else \"idle\" (no glyph).",
            "x-ts-type": "\"idle\" | \"running\" | \"done\" | \"failed\"",
            "type": "string",
            "enum": [
              "done",
              "failed",
              "idle",
              "running"
            ]
          },
          "exitCode": {
            "description": "Exit code, shown as a badge once finished (red when non-zero).",
            "x-ts-type": "number | null"
          },
          "startedAt": {
            "description": "Start time for the live clock. Default: when status became running.",
            "x-ts-type": "Date | number"
          },
          "duration": {
            "description": "Final duration in ms for persisted runs. Default: measured from running → finished in this session.",
            "x-ts-type": "number",
            "type": "number"
          },
          "cwd": {
            "description": "Working directory in the header, e.g. \"~/acme/web\".",
            "x-ts-type": "string",
            "type": "string"
          },
          "title": {
            "description": "Header title, e.g. \"Run tests\". Default: the cwd, or \"Terminal\".",
            "x-ts-type": "React.ReactNode"
          },
          "prompt": {
            "description": "Prompt glyph before the command.",
            "x-ts-type": "string",
            "type": "string",
            "default": "$"
          },
          "maxHeight": {
            "description": "Viewport height in px. Output sticks to the bottom while the user is there; scrolling up shows “Jump to latest” / “N new lines”.",
            "x-ts-type": "number",
            "type": "number",
            "default": 320
          },
          "maxLines": {
            "description": "Collapse to the last N lines with a “Show all N lines” control at the top.",
            "x-ts-type": "number",
            "type": "number"
          },
          "expanded": {
            "description": "Showing every line past maxLines (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 “Show all” / “Show last N” is used.",
            "x-ts-type": "(expanded: boolean) => void"
          },
          "wrap": {
            "description": "Soft-wrap long lines.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "copyable": {
            "description": "Show the copy button: copies `$ command` plus the plain output (no escape codes).",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "onCopy": {
            "description": "Called with the copied text after a successful copy.",
            "x-ts-type": "(text: string) => void"
          },
          "header": {
            "description": "Show the header row.",
            "x-ts-type": "boolean",
            "type": "boolean",
            "default": true
          },
          "actions": {
            "description": "Extra header controls before the copy button, e.g. a Stop Button.",
            "x-ts-type": "React.ReactNode"
          }
        },
        "required": [],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/terminal/ansi.ts",
          "type": "registry:ui",
          "content": "/**\n * A small terminal \"screen\" for rendering command output: SGR colours and text styles, plus the\n * cursor controls real CLIs use for progress bars and spinners (`\\r`, erase line, cursor up,\n * backspace). Fed incrementally, so streaming output is parsed once. Pure, no DOM.\n */\n\nexport type AnsiColor =\n  | \"black\"\n  | \"red\"\n  | \"green\"\n  | \"yellow\"\n  | \"blue\"\n  | \"magenta\"\n  | \"cyan\"\n  | \"white\"\n  | \"gray\";\n\nexport interface AnsiStyle {\n  fg?: AnsiColor;\n  bg?: AnsiColor;\n  bold?: boolean;\n  dim?: boolean;\n  italic?: boolean;\n  underline?: boolean;\n  inverse?: boolean;\n  strike?: boolean;\n}\n\nexport interface AnsiSpan {\n  text: string;\n  style: AnsiStyle;\n}\n\nexport interface ScreenLine {\n  text: string;\n  /** Style id per character (index into the screen's style table). */\n  styles: number[];\n  /** Bumped on every change, for memoised rendering. */\n  version: number;\n}\n\nconst BASIC: AnsiColor[] = [\"black\", \"red\", \"green\", \"yellow\", \"blue\", \"magenta\", \"cyan\", \"white\"];\n\nfunction styleKey(s: AnsiStyle): string {\n  return `${s.fg ?? \"\"}|${s.bg ?? \"\"}|${s.bold ? 1 : 0}${s.dim ? 1 : 0}${s.italic ? 1 : 0}${s.underline ? 1 : 0}${s.inverse ? 1 : 0}${s.strike ? 1 : 0}`;\n}\n\n/** Map a 256-colour index to the nearest basic colour (0–15 exactly, greys to gray). */\nfunction color256(n: number): AnsiColor | undefined {\n  if (n < 8) return BASIC[n];\n  if (n === 8) return \"gray\";\n  if (n < 16) return BASIC[n - 8];\n  if (n >= 232) return n < 244 ? \"gray\" : \"white\";\n  // 6×6×6 cube: pick the dominant channel.\n  const v = n - 16;\n  const r = Math.floor(v / 36);\n  const g = Math.floor((v % 36) / 6);\n  const b = v % 6;\n  if (r === g && g === b) return r < 3 ? \"gray\" : \"white\";\n  const max = Math.max(r, g, b);\n  const hi = [r === max, g === max, b === max];\n  if (hi[0] && hi[1]) return \"yellow\";\n  if (hi[0] && hi[2]) return \"magenta\";\n  if (hi[1] && hi[2]) return \"cyan\";\n  return hi[0] ? \"red\" : hi[1] ? \"green\" : \"blue\";\n}\n\nfunction applySgr(style: AnsiStyle, params: number[]): AnsiStyle {\n  let s: AnsiStyle = { ...style };\n  if (params.length === 0) params = [0];\n  for (let i = 0; i < params.length; i++) {\n    const p = params[i]!;\n    if (p === 0) s = {};\n    else if (p === 1) s.bold = true;\n    else if (p === 2) s.dim = true;\n    else if (p === 3) s.italic = true;\n    else if (p === 4) s.underline = true;\n    else if (p === 7) s.inverse = true;\n    else if (p === 9) s.strike = true;\n    else if (p === 22) {\n      s.bold = false;\n      s.dim = false;\n    } else if (p === 23) s.italic = false;\n    else if (p === 24) s.underline = false;\n    else if (p === 27) s.inverse = false;\n    else if (p === 29) s.strike = false;\n    else if (p >= 30 && p <= 37) s.fg = BASIC[p - 30];\n    else if (p === 39) s.fg = undefined;\n    else if (p >= 40 && p <= 47) s.bg = BASIC[p - 40];\n    else if (p === 49) s.bg = undefined;\n    else if (p >= 90 && p <= 97) s.fg = p === 90 ? \"gray\" : BASIC[p - 90];\n    else if (p >= 100 && p <= 107) s.bg = p === 100 ? \"gray\" : BASIC[p - 100];\n    else if (p === 38 || p === 48) {\n      // 38;5;n / 38;2;r;g;b — extended colours, approximated to the nearest basic colour.\n      const mode = params[i + 1];\n      let c: AnsiColor | undefined;\n      if (mode === 5) {\n        c = color256(params[i + 2] ?? 0);\n        i += 2;\n      } else if (mode === 2) {\n        const [r = 0, g = 0, b = 0] = params.slice(i + 2, i + 5);\n        const max = Math.max(r, g, b);\n        const n =\n          16 +\n          36 * Math.round((r / 255) * 5) +\n          6 * Math.round((g / 255) * 5) +\n          Math.round((b / 255) * 5);\n        c = max < 40 ? \"black\" : color256(n);\n        i += 4;\n      }\n      if (p === 38) s.fg = c;\n      else s.bg = c;\n    }\n  }\n  for (const k of Object.keys(s) as Array<keyof AnsiStyle>) if (!s[k]) delete s[k];\n  return s;\n}\n\nexport class AnsiScreen {\n  lines: ScreenLine[] = [{ text: \"\", styles: [], version: 0 }];\n  /** Style table; id 0 is the default style. */\n  styles: AnsiStyle[] = [{}];\n  private ids = new Map<string, number>([[\"||000000\", 0]]);\n  private style: AnsiStyle = {};\n  private styleId = 0;\n  private row = 0;\n  private col = 0;\n  /** An escape sequence split across chunks. */\n  private pending = \"\";\n  private version = 0;\n\n  /** Feed more output. */\n  write(chunk: string): void {\n    const s = this.pending + chunk;\n    this.pending = \"\";\n    let i = 0;\n    let run = \"\";\n    const flush = () => {\n      if (run) this.put(run);\n      run = \"\";\n    };\n    while (i < s.length) {\n      const ch = s[i]!;\n      if (ch === \"\\x1b\") {\n        flush();\n        const next = s[i + 1];\n        if (next === undefined) {\n          this.pending = s.slice(i);\n          return;\n        }\n        if (next === \"[\") {\n          // CSI: ESC [ params intermediates final\n          const m = /^\\[([0-?]*)([ -/]*)([@-~])/.exec(s.slice(i + 1, i + 64));\n          if (!m) {\n            if (s.length - i < 64 && /^\\[[0-?]*[ -/]*$/.test(s.slice(i + 1))) {\n              this.pending = s.slice(i);\n              return;\n            }\n            i += 2;\n            continue;\n          }\n          this.csi(m[1]!, m[3]!);\n          i += m[0].length + 1;\n          continue;\n        }\n        if (next === \"]\") {\n          // OSC (titles, hyperlinks): skip to BEL or ESC \\\n          const bel = s.indexOf(\"\\x07\", i);\n          const st = s.indexOf(\"\\x1b\\\\\", i);\n          const end = bel < 0 ? st : st < 0 ? bel : Math.min(bel, st);\n          if (end < 0) {\n            this.pending = s.slice(i);\n            return;\n          }\n          i = end + (end === st ? 2 : 1);\n          continue;\n        }\n        // Two- or three-byte escapes (charset selection, save/restore cursor…): ignore.\n        i += next === \"(\" || next === \")\" ? 3 : 2;\n        continue;\n      }\n      if (ch === \"\\n\") {\n        flush();\n        this.row++;\n        this.col = 0;\n        this.ensureRow();\n      } else if (ch === \"\\r\") {\n        flush();\n        // \\r\\n is just a newline.\n        if (s[i + 1] !== \"\\n\") this.col = 0;\n      } else if (ch === \"\\b\") {\n        flush();\n        this.col = Math.max(0, this.col - 1);\n      } else if (ch === \"\\x07\" || (ch < \" \" && ch !== \"\\t\")) {\n        flush();\n      } else {\n        run += ch;\n      }\n      i++;\n    }\n    flush();\n  }\n\n  private ensureRow() {\n    while (this.lines.length <= this.row)\n      this.lines.push({ text: \"\", styles: [], version: ++this.version });\n  }\n\n  private touch(line: ScreenLine) {\n    line.version = ++this.version;\n  }\n\n  private put(text: string) {\n    this.ensureRow();\n    const line = this.lines[this.row]!;\n    if (this.col > line.text.length) {\n      const pad = this.col - line.text.length;\n      line.text += \" \".repeat(pad);\n      for (let k = 0; k < pad; k++) line.styles.push(0);\n    }\n    const end = this.col + text.length;\n    line.text = line.text.slice(0, this.col) + text + line.text.slice(end);\n    line.styles = line.styles\n      .slice(0, this.col)\n      .concat(new Array<number>(text.length).fill(this.styleId), line.styles.slice(end));\n    this.col = end;\n    this.touch(line);\n  }\n\n  private csi(rawParams: string, final: string) {\n    const params = rawParams\n      .replace(/^[?>=]/, \"\")\n      .split(/[;:]/)\n      .filter((p) => p !== \"\")\n      .map(Number);\n    const n = params[0] ?? 1;\n    switch (final) {\n      case \"m\": {\n        this.style = applySgr(\n          this.style,\n          rawParams === \"\" ? [] : rawParams.split(/[;:]/).map((p) => Number(p) || 0),\n        );\n        const key = styleKey(this.style);\n        let id = this.ids.get(key);\n        if (id === undefined) {\n          id = this.styles.length;\n          this.styles.push(this.style);\n          this.ids.set(key, id);\n        }\n        this.styleId = id;\n        break;\n      }\n      case \"A\": // cursor up\n      case \"F\":\n        this.row = Math.max(0, this.row - n);\n        if (final === \"F\") this.col = 0;\n        break;\n      case \"B\": // cursor down\n      case \"E\":\n        this.row += n;\n        this.ensureRow();\n        if (final === \"E\") this.col = 0;\n        break;\n      case \"C\":\n        this.col += n;\n        break;\n      case \"D\":\n        this.col = Math.max(0, this.col - n);\n        break;\n      case \"G\":\n        this.col = Math.max(0, n - 1);\n        break;\n      case \"K\": {\n        // erase in line: 0 = to end, 1 = to start, 2 = whole line\n        this.ensureRow();\n        const line = this.lines[this.row]!;\n        const mode = params[0] ?? 0;\n        if (mode === 0) {\n          line.text = line.text.slice(0, this.col);\n          line.styles.length = Math.min(line.styles.length, this.col);\n        } else if (mode === 1) {\n          line.text = \" \".repeat(Math.min(this.col, line.text.length)) + line.text.slice(this.col);\n          line.styles.fill(0, 0, this.col);\n        } else {\n          line.text = \"\";\n          line.styles = [];\n        }\n        this.touch(line);\n        break;\n      }\n      case \"J\": {\n        // erase in display: 0 = below (and rest of line), 2/3 = everything\n        const mode = params[0] ?? 0;\n        if (mode === 0) {\n          this.csi(\"0\", \"K\");\n          this.lines.length = this.row + 1;\n        } else if (mode >= 2) {\n          this.lines = [{ text: \"\", styles: [], version: ++this.version }];\n          this.row = 0;\n          this.col = 0;\n        }\n        break;\n      }\n      default:\n        // Other CSI sequences (cursor show/hide, scroll regions, modes) don't affect text.\n        break;\n    }\n  }\n\n  /** The lines as styled spans. */\n  spans(line: ScreenLine): AnsiSpan[] {\n    const out: AnsiSpan[] = [];\n    let start = 0;\n    for (let k = 1; k <= line.text.length; k++) {\n      if (k === line.text.length || line.styles[k] !== line.styles[start]) {\n        out.push({ text: line.text.slice(start, k), style: this.styles[line.styles[start] ?? 0]! });\n        start = k;\n      }\n    }\n    return out;\n  }\n}\n\n/** Parse a complete string into lines of styled spans. */\nexport function parseAnsi(text: string): AnsiSpan[][] {\n  const screen = new AnsiScreen();\n  screen.write(text);\n  return screen.lines.map((l) => screen.spans(l));\n}\n\n/** Remove escape sequences and apply carriage returns: the text a user would copy. */\nexport function stripAnsi(text: string): string {\n  const screen = new AnsiScreen();\n  screen.write(text);\n  return screen.lines.map((l) => l.text.replace(/\\s+$/, \"\")).join(\"\\n\");\n}\n"
        },
        {
          "target": "components/kura/terminal/index.ts",
          "type": "registry:ui",
          "content": "export {\n  type AnsiColor,\n  AnsiScreen,\n  type AnsiSpan,\n  type AnsiStyle,\n  parseAnsi,\n  stripAnsi,\n} from \"./ansi\";\nexport { Terminal, type TerminalProps, type TerminalStatus } from \"./terminal\";\n"
        },
        {
          "target": "components/kura/terminal/terminal.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { ArrowDown, Check, ChevronsUpDown, Copy } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatClock, formatDuration } from \"@/lib/kura/format\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { StatusGlyph } from \"@/components/kura/status-glyph\";\nimport { type AnsiColor, AnsiScreen, type AnsiStyle, type ScreenLine } from \"./ansi\";\n\nexport type TerminalStatus = \"idle\" | \"running\" | \"done\" | \"failed\";\n\nexport interface TerminalProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\" | \"onCopy\"> {\n  /** The command that was run, shown on the prompt line, e.g. \"pnpm test\". */\n  command?: string;\n  /** Output: an array of lines, or one (growing) string. ANSI colour codes and `\\r` progress updates are rendered. */\n  lines?: readonly string[] | string;\n  /** Output as an async stream of chunks (e.g. a child process's stdout). Takes precedence over `lines`. */\n  stream?: AsyncIterable<string>;\n  /** Called once `stream` has ended. */\n  onStreamEnd?: () => void;\n  /** Lifecycle. Default: from `exitCode` (0 → done, else failed), \"running\" while `stream` is open, otherwise \"idle\". */\n  status?: TerminalStatus;\n  /** Process exit code, shown as a badge once finished. */\n  exitCode?: number | null;\n  /** When the command started, for the live clock. Default: when status became \"running\". */\n  startedAt?: Date | number;\n  /** Final duration in ms (persisted runs). Default: measured from running → finished. */\n  duration?: number;\n  /** Working directory shown in the header, e.g. \"~/acme/web\". */\n  cwd?: string;\n  /** Header title. Default: the cwd, or \"Terminal\". */\n  title?: React.ReactNode;\n  /** Prompt glyph before the command. Default \"$\". */\n  prompt?: string;\n  /** Height of the scrolling viewport in px. Default 320. Output sticks to the bottom while you're there. */\n  maxHeight?: number;\n  /** Collapse to the last N lines with a \"Show all N lines\" control. */\n  maxLines?: number;\n  /** Showing every line past `maxLines` (controlled). */\n  expanded?: boolean;\n  /** Initial expanded state when uncontrolled. Default false. */\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Soft-wrap long lines. Default true. */\n  wrap?: boolean;\n  /** Show the copy button (copies the command and plain output). Default true. */\n  copyable?: boolean;\n  /** Called with the copied text after a successful copy. */\n  onCopy?: (text: string) => void;\n  /** Show the header row. Default true. */\n  header?: boolean;\n  /** Extra header controls, e.g. a Stop button. */\n  actions?: React.ReactNode;\n}\n\nconst COLOR_VAR: Record<AnsiColor, string> = {\n  black: \"var(--kura-terminal-black, var(--kura-fg-subtle))\",\n  red: \"var(--kura-terminal-red, var(--kura-danger-text))\",\n  green: \"var(--kura-terminal-green, var(--kura-success-text))\",\n  yellow: \"var(--kura-terminal-yellow, var(--kura-warning-text))\",\n  blue: \"var(--kura-terminal-blue, var(--kura-accent-text))\",\n  magenta: \"var(--kura-terminal-magenta, oklch(from var(--kura-fg-muted) l 0.15 330))\",\n  cyan: \"var(--kura-terminal-cyan, oklch(from var(--kura-fg-muted) l 0.1 205))\",\n  white: \"var(--kura-terminal-white, var(--kura-fg))\",\n  gray: \"var(--kura-terminal-gray, var(--kura-fg-subtle))\",\n};\n\nfunction spanStyle(s: AnsiStyle): { className?: string; style?: React.CSSProperties } {\n  const fg = s.fg && COLOR_VAR[s.fg];\n  const bg = s.bg && COLOR_VAR[s.bg];\n  const style: React.CSSProperties = {};\n  if (s.inverse) {\n    style.backgroundColor = fg ?? \"var(--kura-fg)\";\n    style.color = bg ?? \"var(--kura-surface-sunken)\";\n  } else {\n    if (fg) style.color = fg;\n    if (bg) {\n      style.backgroundColor = bg;\n      if (!fg) style.color = \"var(--kura-surface-sunken)\";\n    }\n  }\n  const className = cn(\n    s.bold && \"font-semibold\",\n    s.dim && \"opacity-60\",\n    s.italic && \"italic\",\n    s.underline && \"underline underline-offset-2\",\n    s.strike && \"line-through\",\n    (s.inverse || s.bg) && \"rounded-[2px]\",\n  );\n  return {\n    className: className || undefined,\n    style: Object.keys(style).length ? style : undefined,\n  };\n}\n\n/** Incrementally feeds append-only text into a screen, re-parsing only when the text was replaced. */\nfunction useScreen(text: string) {\n  const ref = React.useRef<{ fed: string; screen: AnsiScreen } | null>(null);\n  if (!ref.current || !text.startsWith(ref.current.fed)) {\n    ref.current = { fed: \"\", screen: new AnsiScreen() };\n  }\n  const cur = ref.current;\n  if (text.length > cur.fed.length) {\n    cur.screen.write(text.slice(cur.fed.length));\n    cur.fed = text;\n  }\n  return cur.screen;\n}\n\nconst Row = React.memo(function Row({\n  screen,\n  line,\n  cursor,\n}: {\n  screen: AnsiScreen;\n  line: ScreenLine;\n  version: number;\n  cursor: boolean;\n}) {\n  const spans = screen.spans(line);\n  return (\n    <div data-slot=\"terminal-line\" className=\"min-h-5\">\n      {spans.map((s, i) => {\n        const { className, style } = spanStyle(s.style);\n        return className || style ? (\n          <span key={i} className={className} style={style}>\n            {s.text}\n          </span>\n        ) : (\n          <React.Fragment key={i}>{s.text}</React.Fragment>\n        );\n      })}\n      {cursor && <Cursor />}\n    </div>\n  );\n});\n\nfunction Cursor() {\n  return (\n    <span\n      aria-hidden\n      data-slot=\"terminal-cursor\"\n      className=\"ml-px inline-block h-[15px] w-[7.5px] translate-y-[3px] bg-kura-fg/75 animate-kura-pulse motion-reduce:animate-none\"\n    />\n  );\n}\n\n/**\n * Streamed command output that looks and behaves like a real terminal: a prompt line, ANSI\n * colours mapped to Kura tokens, carriage-return progress bars, a live clock and exit-code badge,\n * stick-to-bottom scrolling with \"Jump to latest\", a collapsed tail with \"Show all N lines\", and\n * copy of the plain text.\n */\nexport function Terminal({\n  command,\n  lines,\n  stream,\n  onStreamEnd,\n  status: statusProp,\n  exitCode,\n  startedAt,\n  duration,\n  cwd,\n  title,\n  prompt = \"$\",\n  maxHeight = 320,\n  maxLines,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  wrap = true,\n  copyable = true,\n  onCopy,\n  header = true,\n  actions,\n  className,\n  style,\n  ...props\n}: TerminalProps) {\n  // --- output ---------------------------------------------------------------------------------\n  const [streamed, setStreamed] = React.useState(\"\");\n  const [streamOpen, setStreamOpen] = React.useState(() => stream != null);\n  const onStreamEndRef = React.useRef(onStreamEnd);\n  onStreamEndRef.current = onStreamEnd;\n  React.useEffect(() => {\n    if (!stream) return;\n    let cancelled = false;\n    setStreamed(\"\");\n    setStreamOpen(true);\n    (async () => {\n      try {\n        for await (const chunk of stream) {\n          if (cancelled) return;\n          setStreamed((prev) => prev + chunk);\n        }\n      } catch {\n        // A failing stream ends the output; the caller reports the error via status/exitCode.\n      }\n      if (cancelled) return;\n      setStreamOpen(false);\n      onStreamEndRef.current?.();\n    })();\n    return () => {\n      cancelled = true;\n    };\n  }, [stream]);\n\n  const text = stream ? streamed : typeof lines === \"string\" ? lines : (lines ?? []).join(\"\\n\");\n  const screen = useScreen(text);\n\n  const status: TerminalStatus =\n    statusProp ??\n    (exitCode != null\n      ? exitCode === 0\n        ? \"done\"\n        : \"failed\"\n      : stream && streamOpen\n        ? \"running\"\n        : \"idle\");\n  const running = status === \"running\";\n\n  // Drop the empty line after a trailing newline, except where the cursor sits while running.\n  let rows = screen.lines;\n  if (rows.length > 0 && rows[rows.length - 1]!.text === \"\" && (!running || !command))\n    rows = rows.slice(0, -1);\n  if (rows.length === 1 && rows[0]!.text === \"\" && !running) rows = [];\n  const total = rows.length;\n\n  // --- timing ---------------------------------------------------------------------------------\n  const runStart = React.useRef<number | null>(null);\n  if (running && runStart.current == null) runStart.current = Date.now();\n  const since = startedAt ?? runStart.current;\n  const elapsed = useElapsed(since, running, 1000);\n  const shownDuration = duration ?? (status === \"done\" || status === \"failed\" ? elapsed : null);\n\n  // --- collapse -------------------------------------------------------------------------------\n  const [expanded, setExpanded] = useControllableState({\n    value: expandedProp,\n    defaultValue: defaultExpanded,\n    onChange: onExpandedChange,\n  });\n  const collapsed = maxLines != null && total > maxLines && !expanded;\n  const visible = collapsed ? rows.slice(total - maxLines) : rows;\n\n  // --- stick to bottom ------------------------------------------------------------------------\n  const viewport = React.useRef<HTMLDivElement>(null);\n  const stuck = React.useRef(true);\n  const [atBottom, setAtBottom] = React.useState(true);\n  const [unseen, setUnseen] = React.useState(0);\n  const lastTotal = React.useRef(total);\n  const lastVersion = rows[rows.length - 1]?.version ?? 0;\n  // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the last line changes (lastVersion) or the collapse changes the height\n  React.useLayoutEffect(() => {\n    const el = viewport.current;\n    const added = total - lastTotal.current;\n    lastTotal.current = total;\n    if (!el) return;\n    if (stuck.current) el.scrollTop = el.scrollHeight;\n    else if (added > 0) setUnseen((n) => n + added);\n  }, [total, lastVersion, collapsed]);\n  const onScroll = () => {\n    const el = viewport.current;\n    if (!el) return;\n    const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;\n    stuck.current = bottom;\n    setAtBottom(bottom);\n    if (bottom) setUnseen(0);\n  };\n  const jumpToLatest = () => {\n    const el = viewport.current;\n    if (!el) return;\n    stuck.current = true;\n    setAtBottom(true);\n    setUnseen(0);\n    el.scrollTo?.({ top: el.scrollHeight, behavior: \"smooth\" });\n    if (!el.scrollTo) el.scrollTop = el.scrollHeight;\n  };\n\n  // --- copy -----------------------------------------------------------------------------------\n  const [copied, setCopied] = React.useState(false);\n  const copyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(copyTimer.current), []);\n  const copy = async () => {\n    const out = [\n      command ? `${prompt} ${command}` : null,\n      ...rows.map((l) => l.text.replace(/\\s+$/, \"\")),\n    ]\n      .filter((l) => l != null)\n      .join(\"\\n\");\n    try {\n      await navigator.clipboard.writeText(out);\n      setCopied(true);\n      onCopy?.(out);\n      clearTimeout(copyTimer.current);\n      copyTimer.current = setTimeout(() => setCopied(false), 1600);\n    } catch {\n      // Clipboard unavailable (insecure context): nothing to do.\n    }\n  };\n\n  // --- announcements (only on finish, never per line) -----------------------------------------\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const prevStatus = React.useRef(status);\n  React.useEffect(() => {\n    if (prevStatus.current === status) return;\n    prevStatus.current = status;\n    const what = command ?? \"Command\";\n    if (status === \"running\") setAnnouncement(`${what} running`);\n    else if (status === \"done\")\n      setAnnouncement(`${what} finished${exitCode != null ? `, exit code ${exitCode}` : \"\"}`);\n    else if (status === \"failed\")\n      setAnnouncement(`${what} failed${exitCode != null ? `, exit code ${exitCode}` : \"\"}`);\n  }, [status, command, exitCode]);\n\n  const finished = status === \"done\" || status === \"failed\";\n  const heading = title ?? cwd ?? \"Terminal\";\n\n  return (\n    <div\n      data-slot=\"terminal\"\n      data-status={status}\n      data-state={\n        maxLines != null && total > maxLines ? (expanded ? \"expanded\" : \"collapsed\") : undefined\n      }\n      className={cn(\n        \"@container/terminal relative flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      style={style}\n      {...props}\n    >\n      {header && (\n        <div\n          data-slot=\"terminal-header\"\n          className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b pr-1.5 pl-3.5\"\n        >\n          {status !== \"idle\" && (\n            <StatusGlyph\n              status={status === \"running\" ? \"running\" : status === \"done\" ? \"done\" : \"failed\"}\n              size=\"sm\"\n              label={null}\n            />\n          )}\n          <span\n            data-slot=\"terminal-title\"\n            className=\"min-w-0 truncate font-medium text-[13px] tracking-[-0.006em]\"\n          >\n            {heading}\n          </span>\n          {title != null && cwd && (\n            <span className=\"hidden min-w-0 truncate font-kura-mono text-[12px] text-kura-fg-subtle @[24rem]/terminal:inline\">\n              {cwd}\n            </span>\n          )}\n          <span className=\"flex-1\" />\n          <span\n            data-slot=\"terminal-meta\"\n            className=\"flex shrink-0 items-center gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            {running && elapsed != null && (\n              <span data-slot=\"terminal-elapsed\" className=\"min-w-[4ch] text-right\">\n                {formatClock(elapsed)}\n              </span>\n            )}\n            {finished && shownDuration != null && (\n              <span data-slot=\"terminal-elapsed\">{formatDuration(shownDuration)}</span>\n            )}\n            {finished && exitCode != null && (\n              <span\n                data-slot=\"terminal-exit-code\"\n                className={cn(\n                  \"inline-flex h-5 items-center rounded-kura-xs px-1.5 animate-kura-fade-in\",\n                  exitCode === 0\n                    ? \"bg-kura-hover text-kura-fg-muted\"\n                    : \"bg-kura-danger/10 text-kura-danger-text\",\n                )}\n              >\n                exit {exitCode}\n              </span>\n            )}\n          </span>\n          {(copyable || actions) && (\n            <span className=\"flex shrink-0 items-center gap-0.5\">\n              {actions}\n              {copyable && (\n                <IconButton\n                  size=\"sm\"\n                  label={copied ? \"Copied\" : \"Copy output\"}\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] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                          copied && \"scale-75 opacity-0 motion-reduce:scale-100\",\n                        )}\n                      />\n                      <Check\n                        className={cn(\n                          \"size-3.5 text-kura-success-text transition-[opacity,scale] duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-opacity\",\n                          !copied && \"scale-75 opacity-0 motion-reduce:scale-100\",\n                        )}\n                      />\n                    </span>\n                  }\n                />\n              )}\n            </span>\n          )}\n        </div>\n      )}\n\n      <div className=\"relative min-h-0 flex-1 bg-kura-surface-sunken\">\n        <div\n          ref={viewport}\n          data-slot=\"terminal-output\"\n          role=\"log\"\n          aria-live=\"off\"\n          aria-label={command ? `Output of ${command}` : \"Terminal output\"}\n          // biome-ignore lint/a11y/noNoninteractiveTabindex: the scrollable log must be keyboard-scrollable\n          tabIndex={0}\n          onScroll={onScroll}\n          className={cn(\n            \"h-full overflow-y-auto overscroll-contain px-3.5 py-3 font-kura-mono text-[12.5px] text-kura-fg-muted leading-5 outline-none [scrollbar-width:thin] [tab-size:4]\",\n            \"focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n            wrap ? \"whitespace-pre-wrap break-all\" : \"whitespace-pre\",\n          )}\n          style={{ maxHeight }}\n        >\n          <div className={wrap ? undefined : \"w-max min-w-full\"}>\n            {command && (\n              <div data-slot=\"terminal-command\" className=\"min-h-5 text-kura-fg\">\n                <span aria-hidden className=\"mr-2 select-none text-kura-fg-subtle\">\n                  {prompt}\n                </span>\n                <span className=\"font-medium\">{command}</span>\n                {running && total === 0 && <Cursor />}\n              </div>\n            )}\n            {collapsed && (\n              <button\n                type=\"button\"\n                data-slot=\"terminal-expand\"\n                aria-expanded={false}\n                onClick={() => setExpanded(true)}\n                className=\"my-1 flex h-6 w-full cursor-pointer items-center gap-2 rounded-kura-sm px-1.5 -mx-1.5 font-kura-sans text-[12px] text-kura-fg-subtle outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover hover:text-kura-fg-muted focus-visible:shadow-kura-focus motion-reduce:transition-none\"\n                style={{ width: \"calc(100% + 0.75rem)\" }}\n              >\n                <ChevronsUpDown aria-hidden className=\"size-3.5\" strokeWidth={1.75} />\n                Show all {total} lines\n                <span className=\"h-px flex-1 bg-kura-border-subtle\" />\n              </button>\n            )}\n            {visible.map((line, i) => (\n              <Row\n                key={collapsed ? total - maxLines + i : i}\n                screen={screen}\n                line={line}\n                version={line.version}\n                cursor={running && i === visible.length - 1 && (total > 0 || !command)}\n              />\n            ))}\n            {!command && total === 0 && !running && (\n              <div className=\"font-kura-sans text-[13px] text-kura-fg-subtle\">No output</div>\n            )}\n            {expanded && maxLines != null && total > maxLines && (\n              <button\n                type=\"button\"\n                data-slot=\"terminal-collapse\"\n                aria-expanded\n                onClick={() => setExpanded(false)}\n                className=\"mt-1 inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-kura-sm px-1.5 -mx-1.5 font-kura-sans text-[12px] text-kura-fg-subtle outline-none hover:bg-kura-hover hover:text-kura-fg-muted focus-visible:shadow-kura-focus\"\n              >\n                Show last {maxLines} lines\n              </button>\n            )}\n          </div>\n        </div>\n        {!atBottom && (\n          <button\n            type=\"button\"\n            data-slot=\"terminal-jump\"\n            onClick={jumpToLatest}\n            className={cn(\n              \"absolute bottom-3 left-1/2 inline-flex h-7 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-kura-surface-raised px-3 text-[12px] text-kura-fg-muted shadow-kura-md outline-none animate-kura-pop-in\",\n              \"transition-colors duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus motion-reduce:transition-none\",\n            )}\n          >\n            <ArrowDown aria-hidden className=\"size-3.5\" strokeWidth={1.75} />\n            {unseen > 0 ? `${unseen} new ${unseen === 1 ? \"line\" : \"lines\"}` : \"Jump to latest\"}\n          </button>\n        )}\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n        {copied ? \" Copied to clipboard\" : \"\"}\n      </span>\n    </div>\n  );\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 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",
        "agent-orb",
        "streaming-text",
        "message",
        "run-header"
      ],
      "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-surface-sunken text-kura-fg-muted shadow-kura-xs\">\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 blue default when it means “the model is working”; use a neutral grey for secondary, low-priority activity.",
        "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%, 60%, 100% { transform: translateY(0); opacity: 0.3; }\n  30% { transform: translateY(-40%); opacity: 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.3); opacity: 0.45; } 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.3s_cubic-bezier(0.45,0,0.55,1)_infinite]\",\n            motion,\n            i === 0 && \"motion-reduce:opacity-40\",\n            i === 1 && \"motion-reduce:opacity-70\",\n          )}\n          style={{ animationDelay: `${i * 0.15}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.4)] 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.42)] rounded-full bg-current [animation:kura-ti-bar_1.1s_cubic-bezier(0.45,0,0.55,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` (the blue accent 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 = blue pulse + shimmering title + ticking timer; success = check; error = cross (details open by default); awaiting-approval = blue 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",
          "status-glyph"
        ]
      },
      "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      {status === \"success\" && (\n        <Button\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"animate-kura-fade-in self-end\"\n          onClick={() => setRun((r) => r + 1)}\n        >\n          <RotateCcw /> Replay\n        </Button>\n      )}\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",
        "status-glyph",
        "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 = blue pulse + shimmering title + ticking timer; success = check; error = cross (details open by default); awaiting-approval = blue 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 { ChevronRight, Wrench } 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\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\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\nconst GLYPH: Record<ToolCallStatus, AgentStatus> = {\n  pending: \"queued\",\n  running: \"running\",\n  success: \"done\",\n  error: \"failed\",\n  \"awaiting-approval\": \"waiting\",\n};\n\nfunction StatusMark({ status, small }: { status: ToolCallStatus; small?: boolean }) {\n  return (\n    <span aria-hidden data-slot=\"tool-call-status\" className=\"grid shrink-0 place-items-center\">\n      <StatusGlyph status={GLYPH[status]} size={small ? \"sm\" : \"md\"} label={null} />\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      <StatusMark 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-kura-fg-muted text-xs\">{label}</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 min-h-6 max-w-xs select-none items-center gap-2 rounded-kura-md bg-kura-ink px-2 py-1 font-kura-sans font-medium text-kura-fg-inverted text-xs leading-4 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-kura-fg-inverted/15 [&_[data-slot=kbd-key]]:text-kura-fg-inverted/75 [&_[data-slot=kbd-key]]:opacity-100\"\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": "trace-waterfall",
      "name": "TraceWaterfall",
      "category": "agent",
      "status": "beta",
      "description": "Span tree on a shared time axis for agent runs: nested agent, LLM, tool and retrieval spans, live bars, a details row and keyboard tree navigation.",
      "whenToUse": [
        "Show where the time (and tokens, and money) went in an agent run: which model calls, tool calls and subagents ran, nested, and when.",
        "Debug a slow or failed run: spot the long LLM call, the failed `pnpm test`, the subagent that ran in parallel.",
        "Watch a run live: running spans grow against the axis and a 'now' line moves (pass `startedAt` or drive `now`).",
        "Inspect one span's input, output, error, model, tokens and cost inline by selecting it."
      ],
      "whenNotToUse": [
        "The agent's plan or todo list (what it intends to do) — use task-list.",
        "A narrative of steps with tool calls attached — use agent-timeline.",
        "Parallel subagents as a live roster with activity and progress — use agent-fleet.",
        "A single tool invocation — use tool-call."
      ],
      "props": [
        {
          "name": "spans",
          "type": "readonly TraceSpan[]",
          "required": true,
          "description": "Root spans (usually one agent span). TraceSpan = { id, name, kind: \"agent\" | \"llm\" | \"tool\" | \"retrieval\", start (ms since run start), end? (omit while running), status: AgentStatus, meta? (mono hint: path, command), model?, tokens? { input, output }, cost? (USD), input?, output?, error?, children? }."
        },
        {
          "name": "title",
          "type": "React.ReactNode",
          "default": "\"Trace\"",
          "description": "Header title."
        },
        {
          "name": "actions",
          "type": "React.ReactNode",
          "description": "Controls on the right of the header (e.g. a replay or open-in-LangSmith button)."
        },
        {
          "name": "now",
          "type": "number",
          "description": "Current run time in ms since start (a controlled clock). Running spans end here and the 'now' line sits here. Use it for replays and server-driven clocks."
        },
        {
          "name": "startedAt",
          "type": "Date | number",
          "description": "Wall-clock start of the run. When given (and `now` isn't), running spans grow live, ticking every 250ms. Without either, running spans end at the latest known time."
        },
        {
          "name": "selectedId",
          "type": "string | null",
          "description": "Selected span (controlled). Its details row opens under it."
        },
        {
          "name": "defaultSelectedId",
          "type": "string | null",
          "default": "null",
          "description": "Initially selected span when uncontrolled. May name a span that doesn't exist yet; it opens when the span appears."
        },
        {
          "name": "onSelectedIdChange",
          "type": "(id: string | null) => void",
          "description": "Called when a span is selected, or with null when the selection is cleared."
        },
        {
          "name": "defaultCollapsedIds",
          "type": "readonly string[]",
          "description": "Spans whose subtrees start collapsed (e.g. noisy subagents)."
        },
        {
          "name": "renderDetails",
          "type": "(span: TraceSpan) => React.ReactNode",
          "description": "Replace the details row body for a span (e.g. a CodeBlock with the diff a tool wrote). Return null to use the default stats + input/output."
        },
        {
          "name": "empty",
          "type": "React.ReactNode",
          "description": "Rendered when there are no spans. Default: 'No spans yet'."
        },
        {
          "name": "label",
          "type": "string",
          "default": "\"Trace\"",
          "description": "Accessible name of the tree."
        }
      ],
      "parts": [
        {
          "name": "TraceWaterfall",
          "description": "Root panel, a container-query context. `data-state=\"running\" | \"idle\"`; contains a polite status region.",
          "dataSlot": "trace-waterfall"
        },
        {
          "name": "Header",
          "description": "h-10 header: title, summary, actions.",
          "dataSlot": "trace-waterfall-header"
        },
        {
          "name": "Summary",
          "description": "Mono: span count · total duration · tokens · cost. Count/tokens/cost hide on narrow containers.",
          "dataSlot": "trace-waterfall-summary"
        },
        {
          "name": "Axis",
          "description": "Sticky row with 'Span / Duration' over the tree and nice tick labels (0, 2m, 4m…) over the bars.",
          "dataSlot": "trace-waterfall-axis"
        },
        {
          "name": "Body",
          "description": "Scroll container. Give the root a height (e.g. `h-[480px]`) to scroll long traces under the sticky axis.",
          "dataSlot": "trace-waterfall-body"
        },
        {
          "name": "Span",
          "description": "role=\"treeitem\" with `data-kind`, `data-status`, `data-selected`; wraps the row and, when selected, its details.",
          "dataSlot": "trace-waterfall-span"
        },
        {
          "name": "Row",
          "description": "h-8 grid row: indented tree cell (chevron, kind icon, name, mono meta, glyph for running/failed/waiting, duration) and the bar cell.",
          "dataSlot": "trace-waterfall-row"
        },
        {
          "name": "Toggle",
          "description": "Chevron that collapses a subtree (pointer only; ArrowLeft/Right on the keyboard).",
          "dataSlot": "trace-waterfall-toggle"
        },
        {
          "name": "Bar",
          "description": "Positioned on the shared axis. Neutral greys by kind (LLM darkest), thin for agent spans; blue while running, red failed, amber waiting, ink when selected.",
          "dataSlot": "trace-waterfall-bar"
        },
        {
          "name": "Now",
          "description": "Hairline at the current time while any span is running.",
          "dataSlot": "trace-waterfall-now"
        },
        {
          "name": "Details",
          "description": "Sunken row under the selected span: kind, start, duration, model, tokens, cost, then input and output/error.",
          "dataSlot": "trace-waterfall-details"
        },
        {
          "name": "Duration",
          "description": "Mono, tabular, width-reserved; ticks while the span runs.",
          "dataSlot": "trace-waterfall-duration"
        },
        {
          "name": "Empty",
          "description": "Rendered when there are no spans.",
          "dataSlot": "trace-waterfall-empty"
        }
      ],
      "a11y": {
        "role": "tree",
        "notes": [
          "A WAI-ARIA tree: each span is a treeitem with aria-level/posinset/setsize, aria-expanded for subtrees and aria-selected; one roving tab stop.",
          "Each treeitem is labelled “bash pnpm test, tool, 38.4s, failed” — status is spoken, never colour-only. The selected span is described by its details row.",
          "A polite status region announces failures and “Trace complete, 11m 52s” — ticking durations are not announced.",
          "The axis, grid lines, bars and now-line are decorative (aria-hidden or inside labelled items)."
        ],
        "keyboard": [
          {
            "keys": "ArrowDown / ArrowUp",
            "action": "Moves focus to the next / previous visible span."
          },
          {
            "keys": "ArrowRight",
            "action": "Expands a collapsed span; on an expanded span moves to its first child."
          },
          {
            "keys": "ArrowLeft",
            "action": "Collapses an expanded span; otherwise moves to the parent span."
          },
          {
            "keys": "Home / End",
            "action": "First / last visible span."
          },
          {
            "keys": "Enter / Space",
            "action": "Selects the focused span (opens details); again to close."
          },
          {
            "keys": "Escape",
            "action": "Clears the selection."
          }
        ],
        "reducedMotion": "Bars and the now-line jump to their new positions instead of easing; the chevron turns instantly; rows and details only fade."
      },
      "dependencies": {
        "npm": [
          "lucide-react"
        ],
        "kura": [
          "status-glyph"
        ]
      },
      "related": [
        "task-list",
        "agent-fleet",
        "run-header",
        "tool-call",
        "agent-timeline"
      ],
      "tags": [
        "agent",
        "trace",
        "observability",
        "spans",
        "waterfall",
        "timeline",
        "tokens",
        "latency"
      ],
      "examples": [
        {
          "name": "TraceWaterfallMinimal",
          "title": "A finished turn",
          "description": "One agent span with two LLM calls and a tool call.",
          "kind": "minimal",
          "code": "import { TraceWaterfall } from \"@kura-ui/react/trace-waterfall\";\n\nexport function TraceWaterfallMinimal() {\n  return (\n    <TraceWaterfall\n      className=\"w-full max-w-2xl\"\n      spans={[\n        {\n          id: \"run\",\n          name: \"Kura\",\n          kind: \"agent\",\n          meta: \"claude-opus-5-5\",\n          start: 0,\n          end: 14_200,\n          status: \"done\",\n          children: [\n            {\n              id: \"plan\",\n              name: \"Plan the fix\",\n              kind: \"llm\",\n              start: 0,\n              end: 6_400,\n              status: \"done\",\n              tokens: { input: 12_400, output: 610 },\n              cost: 0.06,\n            },\n            {\n              id: \"read\",\n              name: \"read_file\",\n              kind: \"tool\",\n              meta: \"src/auth/client.ts\",\n              start: 6_500,\n              end: 6_900,\n              status: \"done\",\n            },\n            {\n              id: \"edit\",\n              name: \"Write the patch\",\n              kind: \"llm\",\n              start: 7_000,\n              end: 14_200,\n              status: \"done\",\n              tokens: { input: 15_100, output: 1_240 },\n              cost: 0.08,\n            },\n          ],\n        },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "TraceWaterfallFull",
          "title": "Replay of a run",
          "description": "The 12-minute auth-client migration replayed on a controlled clock: subagents, a failing pnpm test (selected, with its error), the fix, review and PR. The axis rescales as the run grows.",
          "kind": "full",
          "code": "\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport * as React from \"react\";\nimport { IconButton } from \"@kura-ui/react/icon-button\";\nimport { type TraceSpan, TraceWaterfall } from \"@kura-ui/react/trace-waterfall\";\n\nconst RUN: TraceSpan = {\n  id: \"run\",\n  name: \"Kura\",\n  kind: \"agent\",\n  meta: \"acme/web · kura/auth-client\",\n  start: 0,\n  end: 11 * M + 52 * S,\n  status: \"done\",\n  children: [\n    {\n      id: \"plan\",\n      name: \"Plan the migration\",\n      kind: \"llm\",\n      model: \"claude-opus-5-5\",\n      start: 0,\n      end: 9.8 * S,\n      status: \"done\",\n      tokens: { input: 18_400, output: 1_120 },\n      cost: 0.11,\n      input:\n        \"Migrate acme/web from the legacy REST session API to @acme/auth. Read the routes first.\",\n      output:\n        \"1. Read routes in src/app\\n2. Map call sites of fetchUser()\\n3. Patch src/auth/client.ts and src/middleware.ts\\n4. Update tests, run pnpm test\\n5. Open a PR\",\n    },\n    {\n      id: \"routes\",\n      name: \"read_file\",\n      kind: \"tool\",\n      meta: \"src/app/api/session/route.ts\",\n      start: 9.9 * S,\n      end: 10.3 * S,\n      status: \"done\",\n      input: '{ \"path\": \"src/app/api/session/route.ts\" }',\n      output: \"export async function GET(req: Request) {\\n  const user = await fetchUser(req)\\n  …\",\n    },\n    {\n      id: \"search\",\n      name: \"search_code\",\n      kind: \"retrieval\",\n      meta: \"fetchUser(\",\n      start: 10.4 * S,\n      end: 12.1 * S,\n      status: \"done\",\n      output: \"14 matches in 9 files\",\n    },\n    {\n      id: \"explorer\",\n      name: \"explorer\",\n      kind: \"agent\",\n      meta: \"subagent\",\n      start: 12.5 * S,\n      end: 62 * S,\n      status: \"done\",\n      children: [\n        {\n          id: \"explorer-llm\",\n          name: \"Find call sites\",\n          kind: \"llm\",\n          model: \"claude-haiku-4-5\",\n          start: 12.6 * S,\n          end: 21 * S,\n          status: \"done\",\n          tokens: { input: 9_800, output: 420 },\n          cost: 0.01,\n        },\n        {\n          id: \"explorer-grep\",\n          name: \"bash\",\n          kind: \"tool\",\n          meta: \"rg -n 'fetchUser\\\\(' src\",\n          start: 21.1 * S,\n          end: 21.9 * S,\n          status: \"done\",\n        },\n        {\n          id: \"explorer-sum\",\n          name: \"Summarize call sites\",\n          kind: \"llm\",\n          model: \"claude-haiku-4-5\",\n          start: 22.2 * S,\n          end: 61.5 * S,\n          status: \"done\",\n          tokens: { input: 41_200, output: 2_300 },\n          cost: 0.05,\n        },\n      ],\n    },\n    {\n      id: \"patch\",\n      name: \"Draft the client patch\",\n      kind: \"llm\",\n      model: \"claude-opus-5-5\",\n      start: 63 * S,\n      end: 1 * M + 40 * S,\n      status: \"done\",\n      tokens: { input: 52_300, output: 6_900 },\n      cost: 0.31,\n    },\n    {\n      id: \"edit-client\",\n      name: \"edit_file\",\n      kind: \"tool\",\n      meta: \"src/auth/client.ts\",\n      start: 1 * M + 40.2 * S,\n      end: 1 * M + 40.9 * S,\n      status: \"done\",\n    },\n    {\n      id: \"edit-mw\",\n      name: \"edit_file\",\n      kind: \"tool\",\n      meta: \"src/middleware.ts\",\n      start: 2 * M + 8 * S,\n      end: 2 * M + 8.6 * S,\n      status: \"done\",\n    },\n    {\n      id: \"tests\",\n      name: \"test-writer\",\n      kind: \"agent\",\n      meta: \"subagent\",\n      start: 2 * M + 12 * S,\n      end: 5 * M + 28 * S,\n      status: \"done\",\n      children: [\n        {\n          id: \"tests-llm\",\n          name: \"Write session tests\",\n          kind: \"llm\",\n          model: \"claude-sonnet-4-5\",\n          start: 2 * M + 12 * S,\n          end: 4 * M + 31 * S,\n          status: \"done\",\n          tokens: { input: 88_000, output: 9_400 },\n          cost: 0.38,\n        },\n        {\n          id: \"tests-edit\",\n          name: \"edit_file\",\n          kind: \"tool\",\n          meta: \"src/auth/login.test.ts\",\n          start: 4 * M + 32 * S,\n          end: 4 * M + 33 * S,\n          status: \"done\",\n        },\n      ],\n    },\n    {\n      id: \"test-1\",\n      name: \"bash\",\n      kind: \"tool\",\n      meta: \"pnpm test\",\n      start: 5 * M + 31 * S,\n      end: 6 * M + 9.4 * S,\n      status: \"failed\",\n      input: \"pnpm test --filter @acme/web\",\n      error:\n        \"✕ login.test.ts › refreshes an expired session\\n  Expected: 200\\n  Received: 401\\n\\n  at src/auth/client.ts:88:11\\n\\nTests: 1 failed, 141 passed, 142 total\",\n    },\n    {\n      id: \"diagnose\",\n      name: \"Diagnose the failure\",\n      kind: \"llm\",\n      model: \"claude-opus-5-5\",\n      start: 6 * M + 10 * S,\n      end: 7 * M + 1 * S,\n      status: \"done\",\n      tokens: { input: 71_500, output: 4_800 },\n      cost: 0.29,\n    },\n    {\n      id: \"test-2\",\n      name: \"bash\",\n      kind: \"tool\",\n      meta: \"pnpm test\",\n      start: 7 * M + 4 * S,\n      end: 7 * M + 40.8 * S,\n      status: \"done\",\n      output: \"Tests: 142 passed, 142 total\",\n    },\n    {\n      id: \"review\",\n      name: \"reviewer\",\n      kind: \"agent\",\n      meta: \"subagent\",\n      start: 7 * M + 42 * S,\n      end: 10 * M + 48 * S,\n      status: \"done\",\n      children: [\n        {\n          id: \"review-llm\",\n          name: \"Review the diff\",\n          kind: \"llm\",\n          model: \"claude-opus-5-5\",\n          start: 7 * M + 42 * S,\n          end: 10 * M + 48 * S,\n          status: \"done\",\n          tokens: { input: 104_000, output: 8_100 },\n          cost: 0.42,\n        },\n      ],\n    },\n    {\n      id: \"pr\",\n      name: \"bash\",\n      kind: \"tool\",\n      meta: \"gh pr create\",\n      start: 10 * M + 51 * S,\n      end: 11 * M + 39 * S,\n      status: \"done\",\n      output: \"https://github.com/acme/web/pull/482\",\n    },\n    {\n      id: \"summary\",\n      name: \"Summarize the run\",\n      kind: \"llm\",\n      model: \"claude-opus-5-5\",\n      start: 11 * M + 40 * S,\n      end: 11 * M + 52 * S,\n      status: \"done\",\n      tokens: { input: 26_900, output: 4_960 },\n      cost: 0.15,\n    },\n  ],\n};\n\nfunction traceAt(span: TraceSpan, t: number): TraceSpan | null {\n  if (span.start > t) return null;\n  const finished = span.end != null && span.end <= t;\n  return {\n    ...span,\n    end: finished ? span.end : undefined,\n    status: finished ? span.status : \"running\",\n    output: finished ? span.output : undefined,\n    error: finished ? span.error : undefined,\n    tokens: finished ? span.tokens : undefined,\n    cost: finished ? span.cost : undefined,\n    children: span.children?.map((c) => traceAt(c, t)).filter((c): c is TraceSpan => c != null),\n  };\n}\n\nconst FROM = 4 * M + 40 * S;\n\nconst SPEED = 75;\n\nconst S = 1000;\n\nconst M = 60_000;\n\nexport function TraceWaterfallFull() {\n  const [t, setT] = React.useState(FROM);\n  const [run, setRun] = React.useState(0);\n  const end = RUN.end ?? 0;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: `run` replays the trace\n  React.useEffect(() => {\n    const began = Date.now();\n    setT(FROM);\n    const id = setInterval(() => {\n      const next = Math.min(end, FROM + (Date.now() - began) * SPEED);\n      setT(next);\n      if (next >= end) clearInterval(id);\n    }, 120);\n    return () => clearInterval(id);\n  }, [run]);\n\n  const root = traceAt(RUN, t);\n  return (\n    <TraceWaterfall\n      className=\"w-full max-w-3xl\"\n      title=\"Trace\"\n      spans={root ? [root] : []}\n      now={t}\n      defaultSelectedId=\"test-1\"\n      defaultCollapsedIds={[\"explorer\"]}\n      actions={\n        <IconButton\n          label=\"Replay\"\n          icon={<RotateCcw />}\n          variant=\"ghost\"\n          size=\"xs\"\n          className=\"-mr-1.5\"\n          onClick={() => setRun((r) => r + 1)}\n        />\n      }\n    />\n  );\n}\n"
        },
        {
          "name": "TraceWaterfallLive",
          "title": "Live, from a wall clock",
          "description": "A subagent that is still running: `startedAt` makes running bars grow in real time.",
          "kind": "state",
          "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { TraceWaterfall } from \"@kura-ui/react/trace-waterfall\";\n\nexport function TraceWaterfallLive() {\n  const [startedAt] = React.useState(() => Date.now() - 38_000);\n  return (\n    <TraceWaterfall\n      className=\"w-full max-w-2xl\"\n      title=\"test-writer\"\n      startedAt={startedAt}\n      spans={[\n        {\n          id: \"tw\",\n          name: \"test-writer\",\n          kind: \"agent\",\n          meta: \"subagent\",\n          start: 0,\n          status: \"running\",\n          children: [\n            {\n              id: \"tw-read\",\n              name: \"read_file\",\n              kind: \"tool\",\n              meta: \"src/auth/login.test.ts\",\n              start: 400,\n              end: 900,\n              status: \"done\",\n            },\n            {\n              id: \"tw-ctx\",\n              name: \"search_code\",\n              kind: \"retrieval\",\n              meta: \"refreshSession\",\n              start: 1_000,\n              end: 2_600,\n              status: \"done\",\n            },\n            {\n              id: \"tw-llm\",\n              name: \"Write session tests\",\n              kind: \"llm\",\n              start: 2_800,\n              end: 24_100,\n              status: \"done\",\n              tokens: { input: 31_200, output: 2_870 },\n              cost: 0.14,\n            },\n            {\n              id: \"tw-approve\",\n              name: \"edit_file\",\n              kind: \"tool\",\n              meta: \"src/auth/session.test.ts\",\n              start: 24_300,\n              end: 25_000,\n              status: \"done\",\n            },\n            {\n              id: \"tw-run\",\n              name: \"bash\",\n              kind: \"tool\",\n              meta: \"pnpm vitest session\",\n              start: 25_200,\n              status: \"running\",\n            },\n          ],\n        },\n      ]}\n    />\n  );\n}\n"
        },
        {
          "name": "TraceWaterfallCustomDetails",
          "title": "Custom details",
          "description": "`renderDetails` shows the edit a tool made as a CodeBlock.",
          "kind": "composition",
          "code": "import { CodeBlock } from \"@kura-ui/react/code-block\";\nimport { TraceWaterfall } from \"@kura-ui/react/trace-waterfall\";\n\nexport function TraceWaterfallCustomDetails() {\n  return (\n    <TraceWaterfall\n      className=\"w-full max-w-2xl\"\n      title=\"Tool calls\"\n      defaultSelectedId=\"edit\"\n      spans={[\n        {\n          id: \"turn\",\n          name: \"Fix token refresh\",\n          kind: \"agent\",\n          start: 0,\n          end: 9_300,\n          status: \"done\",\n          children: [\n            {\n              id: \"think\",\n              name: \"Diagnose the failure\",\n              kind: \"llm\",\n              start: 0,\n              end: 7_900,\n              status: \"done\",\n              tokens: { input: 71_500, output: 4_800 },\n              cost: 0.29,\n            },\n            {\n              id: \"edit\",\n              name: \"edit_file\",\n              kind: \"tool\",\n              meta: \"src/auth/client.ts\",\n              start: 8_000,\n              end: 8_400,\n              status: \"done\",\n            },\n            {\n              id: \"test\",\n              name: \"bash\",\n              kind: \"tool\",\n              meta: \"pnpm test login\",\n              start: 8_500,\n              end: 9_300,\n              status: \"done\",\n            },\n          ],\n        },\n      ]}\n      renderDetails={(span) =>\n        span.id === \"edit\" ? (\n          <CodeBlock\n            language=\"ts\"\n            filename=\"src/auth/client.ts\"\n            code={`if (res.status === 401 && session.refreshToken) {\\n  session = await auth.refresh(session.refreshToken)\\n  return retry(req, session)\\n}`}\n          />\n        ) : null\n      }\n    />\n  );\n}\n"
        }
      ],
      "agentNotes": [
        "Times are ms relative to the run start, not timestamps: convert with `span.start = +new Date(event.ts) - runStart`.",
        "Omit `end` (and set status running) for unfinished spans; pass `startedAt` (wall clock) or `now` so they grow.",
        "Put tokens/cost on leaf spans only. If every root span carries totals, the header uses the roots instead of summing.",
        "Ids must be unique across the whole tree; they drive selection, collapse state and focus.",
        "Give the root a fixed height for long traces; the axis stays sticky while the body scrolls."
      ],
      "title": "Trace Waterfall",
      "importPath": "@kura-ui/react/trace-waterfall",
      "exports": [
        "TraceSpan",
        "TraceSpanKind",
        "TraceWaterfall",
        "TraceWaterfallProps",
        "formatTick",
        "niceStep"
      ],
      "docsUrl": "https://kuro.abstergo.fyi/components/trace-waterfall",
      "markdownUrl": "https://kuro.abstergo.fyi/components/trace-waterfall.md",
      "registryUrl": "https://kuro.abstergo.fyi/r/trace-waterfall.json",
      "install": {
        "shadcn": "npx shadcn@latest add https://kuro.abstergo.fyi/r/trace-waterfall.json",
        "cli": "npx @kura-ui/cli add trace-waterfall",
        "npm": "npm install @kura-ui/react"
      },
      "registryDependencies": [
        "kura-theme",
        "kura-format",
        "status-glyph",
        "use-controllable-state",
        "use-elapsed",
        "utils"
      ],
      "propsJsonSchema": {
        "type": "object",
        "properties": {
          "spans": {
            "description": "Root spans (usually one agent span). TraceSpan = { id, name, kind: \"agent\" | \"llm\" | \"tool\" | \"retrieval\", start (ms since run start), end? (omit while running), status: AgentStatus, meta? (mono hint: path, command), model?, tokens? { input, output }, cost? (USD), input?, output?, error?, children? }.",
            "x-ts-type": "readonly TraceSpan[]",
            "type": "array"
          },
          "title": {
            "description": "Header title.",
            "x-ts-type": "React.ReactNode",
            "default": "Trace"
          },
          "actions": {
            "description": "Controls on the right of the header (e.g. a replay or open-in-LangSmith button).",
            "x-ts-type": "React.ReactNode"
          },
          "now": {
            "description": "Current run time in ms since start (a controlled clock). Running spans end here and the 'now' line sits here. Use it for replays and server-driven clocks.",
            "x-ts-type": "number",
            "type": "number"
          },
          "startedAt": {
            "description": "Wall-clock start of the run. When given (and `now` isn't), running spans grow live, ticking every 250ms. Without either, running spans end at the latest known time.",
            "x-ts-type": "Date | number"
          },
          "selectedId": {
            "description": "Selected span (controlled). Its details row opens under it.",
            "x-ts-type": "string | null"
          },
          "defaultSelectedId": {
            "description": "Initially selected span when uncontrolled. May name a span that doesn't exist yet; it opens when the span appears.",
            "x-ts-type": "string | null",
            "default": null
          },
          "onSelectedIdChange": {
            "description": "Called when a span is selected, or with null when the selection is cleared.",
            "x-ts-type": "(id: string | null) => void"
          },
          "defaultCollapsedIds": {
            "description": "Spans whose subtrees start collapsed (e.g. noisy subagents).",
            "x-ts-type": "readonly string[]",
            "type": "array"
          },
          "renderDetails": {
            "description": "Replace the details row body for a span (e.g. a CodeBlock with the diff a tool wrote). Return null to use the default stats + input/output.",
            "x-ts-type": "(span: TraceSpan) => React.ReactNode"
          },
          "empty": {
            "description": "Rendered when there are no spans. Default: 'No spans yet'.",
            "x-ts-type": "React.ReactNode"
          },
          "label": {
            "description": "Accessible name of the tree.",
            "x-ts-type": "string",
            "type": "string",
            "default": "Trace"
          }
        },
        "required": [
          "spans"
        ],
        "additionalProperties": true
      },
      "files": [
        {
          "target": "components/kura/trace-waterfall/index.ts",
          "type": "registry:ui",
          "content": "export {\n  formatTick,\n  niceStep,\n  type TraceSpan,\n  type TraceSpanKind,\n  TraceWaterfall,\n  type TraceWaterfallProps,\n} from \"./trace-waterfall\";\n"
        },
        {
          "target": "components/kura/trace-waterfall/trace-waterfall.tsx",
          "type": "registry:ui",
          "content": "\"use client\";\n\nimport { Bot, BrainCircuit, ChevronRight, FileSearch, Waypoints, Wrench } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { useElapsed } from \"@/hooks/kura/use-elapsed\";\nimport { cn } from \"@/lib/utils\";\nimport { formatCost, formatDuration, formatTokens } from \"@/lib/kura/format\";\nimport { type AgentStatus, StatusGlyph } from \"@/components/kura/status-glyph\";\n\nexport type TraceSpanKind = \"agent\" | \"llm\" | \"tool\" | \"retrieval\";\n\nexport interface TraceSpan {\n  /** Stable, unique across the whole tree. */\n  id: string;\n  /** What ran: \"explorer\", \"Plan the migration\", \"bash\". */\n  name: string;\n  kind: TraceSpanKind;\n  /** Start, in ms since the run started. */\n  start: number;\n  /** End, in ms since the run started. Omit while running: the bar grows to \"now\". */\n  end?: number;\n  status: AgentStatus;\n  /** Short machine hint after the name, in mono: a path, a command, a model. */\n  meta?: string;\n  /** Model used by an llm span. */\n  model?: string;\n  tokens?: { input: number; output: number };\n  /** USD. */\n  cost?: number;\n  /** Shown in the details row. Strings render as mono, preserving whitespace. */\n  input?: React.ReactNode;\n  output?: React.ReactNode;\n  /** Shown in the details row in red. */\n  error?: React.ReactNode;\n  children?: readonly TraceSpan[];\n}\n\nexport interface TraceWaterfallProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> {\n  /** Root spans (usually one agent span). Times are ms since the run started. */\n  spans: readonly TraceSpan[];\n  /** Header title. Default \"Trace\". */\n  title?: React.ReactNode;\n  /** Controls on the right of the header. */\n  actions?: React.ReactNode;\n  /** The run's current time in ms since start (a controlled clock). Running spans end here. */\n  now?: number;\n  /** Wall-clock start of the run. When given (and `now` isn't), running spans grow live. */\n  startedAt?: Date | number;\n  /** Selected span (controlled). Its details row opens under it. */\n  selectedId?: string | null;\n  /** Initially selected span when uncontrolled. */\n  defaultSelectedId?: string | null;\n  onSelectedIdChange?: (id: string | null) => void;\n  /** Spans whose children start collapsed. */\n  defaultCollapsedIds?: readonly string[];\n  /** Replace the details row's body for a span (return null to fall back to the default). */\n  renderDetails?: (span: TraceSpan) => React.ReactNode;\n  /** Rendered when there are no spans. */\n  empty?: React.ReactNode;\n  /** Accessible name of the tree. Default \"Trace\". */\n  label?: string;\n}\n\ninterface Row {\n  span: TraceSpan;\n  depth: number;\n  parentId: string | null;\n  posinset: number;\n  setsize: number;\n}\n\nconst KIND_ICON: Record<TraceSpanKind, typeof Bot> = {\n  agent: Bot,\n  llm: BrainCircuit,\n  tool: Wrench,\n  retrieval: FileSearch,\n};\n\nconst KIND_LABEL: Record<TraceSpanKind, string> = {\n  agent: \"agent\",\n  llm: \"LLM call\",\n  tool: \"tool\",\n  retrieval: \"retrieval\",\n};\n\nconst STATUS_WORD: Record<AgentStatus, string> = {\n  queued: \"queued\",\n  running: \"running\",\n  waiting: \"waiting for you\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n  paused: \"paused\",\n};\n\nconst STEPS = [\n  50, 100, 200, 250, 500, 1000, 2000, 5000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000,\n  600_000, 900_000, 1_800_000, 3_600_000, 7_200_000,\n];\n\n/** A tick step that yields about four to seven ticks across `total`. */\nexport function niceStep(total: number): number {\n  const raw = total / 6;\n  return STEPS.find((s) => s >= raw) ?? STEPS[STEPS.length - 1]!;\n}\n\n/** Axis label: 0, 250ms, 1.5s, 30s, 2m, 2m 30s, 1h. */\nexport function formatTick(ms: number): string {\n  if (ms === 0) return \"0\";\n  if (ms < 1000) return `${ms}ms`;\n  if (ms < 60_000) return `${+(ms / 1000).toFixed(1)}s`;\n  if (ms < 3_600_000) {\n    const m = Math.floor(ms / 60_000);\n    const s = Math.round((ms % 60_000) / 1000);\n    return s ? `${m}m ${s}s` : `${m}m`;\n  }\n  const h = Math.floor(ms / 3_600_000);\n  const m = Math.round((ms % 3_600_000) / 60_000);\n  return m ? `${h}h ${m}m` : `${h}h`;\n}\n\nfunction walk(spans: readonly TraceSpan[], fn: (s: TraceSpan) => void) {\n  for (const s of spans) {\n    fn(s);\n    if (s.children) walk(s.children, fn);\n  }\n}\n\nfunction flatten(\n  spans: readonly TraceSpan[],\n  collapsed: ReadonlySet<string>,\n  depth = 0,\n  parentId: string | null = null,\n  out: Row[] = [],\n): Row[] {\n  spans.forEach((span, i) => {\n    out.push({ span, depth, parentId, posinset: i + 1, setsize: spans.length });\n    if (span.children?.length && !collapsed.has(span.id))\n      flatten(span.children, collapsed, depth + 1, span.id, out);\n  });\n  return out;\n}\n\nfunction barTone(span: TraceSpan, selected: boolean): string {\n  switch (span.status) {\n    case \"running\":\n      return \"bg-kura-accent\";\n    case \"failed\":\n      return \"bg-kura-danger\";\n    case \"waiting\":\n      return \"bg-kura-warning\";\n    case \"queued\":\n    case \"skipped\":\n      return \"bg-transparent shadow-[inset_0_0_0_1px_var(--kura-border-strong)]\";\n    case \"paused\":\n      return \"bg-kura-gray-7\";\n  }\n  if (selected) return \"bg-kura-fg\";\n  if (span.kind === \"agent\") return \"bg-kura-gray-8\";\n  if (span.kind === \"llm\") return \"bg-kura-gray-11\";\n  return \"bg-kura-gray-9\";\n}\n\nconst TRACK = \"absolute inset-y-0 left-3 right-5\";\nconst GRID =\n  \"grid grid-cols-[minmax(0,58%)_1fr] @sm:grid-cols-[minmax(150px,54%)_1fr] @lg:grid-cols-[minmax(176px,46%)_1fr] @3xl:grid-cols-[minmax(300px,40%)_1fr]\";\n\nfunction Text({ children, tone }: { children: React.ReactNode; tone?: \"danger\" }) {\n  return (\n    <div\n      className={cn(\n        \"max-h-44 overflow-auto whitespace-pre-wrap break-words rounded-kura-md bg-kura-surface px-2.5 py-2 font-kura-mono text-[12px] leading-[18px] shadow-kura-xs\",\n        tone === \"danger\" ? \"text-kura-danger-text\" : \"text-kura-fg-muted\",\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nfunction Stat({ label, children }: { label: string; children: React.ReactNode }) {\n  return (\n    <div className=\"flex min-w-0 flex-col gap-0.5\">\n      <dt className=\"text-[11.5px] text-kura-fg-subtle\">{label}</dt>\n      <dd className=\"truncate font-kura-mono text-[12.5px] text-kura-fg tabular-nums\">\n        {children}\n      </dd>\n    </div>\n  );\n}\n\nfunction Details({ span, end }: { span: TraceSpan; end: number }) {\n  const running = span.status === \"running\";\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <dl className=\"flex flex-wrap gap-x-8 gap-y-2\">\n        <Stat label=\"Kind\">{span.kind}</Stat>\n        <Stat label=\"Start\">+{formatDuration(span.start)}</Stat>\n        <Stat label=\"Duration\">\n          {formatDuration(end - span.start)}\n          {running && <span className=\"text-kura-fg-subtle\"> so far</span>}\n        </Stat>\n        {span.model && <Stat label=\"Model\">{span.model}</Stat>}\n        {span.tokens && (\n          <Stat label=\"Tokens\">\n            {formatTokens(span.tokens.input)}\n            <span className=\"text-kura-fg-subtle\"> in · </span>\n            {formatTokens(span.tokens.output)}\n            <span className=\"text-kura-fg-subtle\"> out</span>\n          </Stat>\n        )}\n        {span.cost != null && <Stat label=\"Cost\">{formatCost(span.cost)}</Stat>}\n      </dl>\n      {(span.input != null || span.output != null || span.error != null) && (\n        <div className=\"grid gap-3 @xl:grid-cols-2\">\n          {span.input != null && (\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <span className=\"text-[11.5px] text-kura-fg-subtle\">Input</span>\n              <Text>{span.input}</Text>\n            </div>\n          )}\n          {(span.output != null || span.error != null) && (\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <span className=\"text-[11.5px] text-kura-fg-subtle\">\n                {span.error != null ? \"Error\" : \"Output\"}\n              </span>\n              <Text tone={span.error != null ? \"danger\" : undefined}>\n                {span.error ?? span.output}\n              </Text>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/**\n * A span tree on a shared time axis, LangSmith/Honeycomb style: the left column is an indented tree\n * (kind icon, name, mono hint, duration), the right column draws each span as a bar against common\n * ticks. Running spans grow live; selecting a span opens a details row (timing, tokens, cost, input\n * and output); subtrees collapse; the tree is fully keyboard navigable.\n */\nexport function TraceWaterfall({\n  spans,\n  title = \"Trace\",\n  actions,\n  now: nowProp,\n  startedAt,\n  selectedId: selectedProp,\n  defaultSelectedId = null,\n  onSelectedIdChange,\n  defaultCollapsedIds,\n  renderDetails,\n  empty,\n  label = \"Trace\",\n  className,\n  ...props\n}: TraceWaterfallProps) {\n  const [selectedId, setSelectedId] = useControllableState<string | null>({\n    value: selectedProp,\n    defaultValue: defaultSelectedId,\n    onChange: onSelectedIdChange,\n  });\n  const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(\n    () => new Set(defaultCollapsedIds),\n  );\n  const [focusedId, setFocusedId] = React.useState<string | null>(null);\n  const itemRefs = React.useRef(new Map<string, HTMLDivElement>());\n  const focusOnUpdate = React.useRef(false);\n  const detailsId = React.useId();\n\n  // Aggregate facts over the whole tree.\n  let anyRunning = false;\n  let count = 0;\n  let maxKnown = 0;\n  let tokens = 0;\n  let cost = 0;\n  let hasTokens = false;\n  let hasCost = false;\n  let failed = 0;\n  walk(spans, (s) => {\n    count++;\n    if (s.status === \"running\") anyRunning = true;\n    if (s.status === \"failed\") failed++;\n    maxKnown = Math.max(maxKnown, s.end ?? s.start, s.start);\n    if (s.tokens) {\n      hasTokens = true;\n      tokens += s.tokens.input + s.tokens.output;\n    }\n    if (s.cost != null) {\n      hasCost = true;\n      cost += s.cost;\n    }\n  });\n  // Cost and tokens live on leaves; avoid double counting when parents also report totals.\n  const rootTotals = spans.every((s) => s.tokens || s.cost != null);\n  if (rootTotals) {\n    tokens = spans.reduce((a, s) => a + (s.tokens ? s.tokens.input + s.tokens.output : 0), 0);\n    cost = spans.reduce((a, s) => a + (s.cost ?? 0), 0);\n  }\n\n  const live = useElapsed(nowProp == null ? startedAt : null, anyRunning, 250);\n  const now = nowProp ?? live ?? maxKnown;\n  const endOf = (s: TraceSpan) => s.end ?? Math.max(now, s.start);\n\n  let total = 0;\n  walk(spans, (s) => {\n    total = Math.max(total, endOf(s));\n  });\n  total = Math.max(total, 1);\n  const step = niceStep(total);\n  const axis = Math.max(step, Math.ceil((total * 1.04) / step) * step);\n  const ticks: number[] = [];\n  for (let t = 0; t <= axis + 1e-6; t += step) ticks.push(t);\n  const pct = (ms: number) => `${Math.min(100, Math.max(0, (ms / axis) * 100))}%`;\n\n  const rows = flatten(spans, collapsed);\n  const rowIndex = new Map(rows.map((r, i) => [r.span.id, i] as const));\n  const activeFocus =\n    focusedId != null && rowIndex.has(focusedId)\n      ? focusedId\n      : selectedId != null && rowIndex.has(selectedId)\n        ? selectedId\n        : (rows[0]?.span.id ?? null);\n\n  React.useEffect(() => {\n    if (!focusOnUpdate.current || activeFocus == null) return;\n    focusOnUpdate.current = false;\n    itemRefs.current.get(activeFocus)?.focus();\n  });\n\n  const moveFocus = (id: string | undefined) => {\n    if (id == null) return;\n    focusOnUpdate.current = true;\n    setFocusedId(id);\n  };\n  const toggle = (id: string, open?: boolean) =>\n    setCollapsed((prev) => {\n      const next = new Set(prev);\n      const isOpen = !prev.has(id);\n      if ((open ?? !isOpen) === true) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  const select = (id: string) => setSelectedId((cur) => (cur === id ? null : id));\n\n  const onItemKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, row: Row) => {\n    if (e.target !== e.currentTarget) return;\n    const i = rowIndex.get(row.span.id) ?? 0;\n    const hasKids = !!row.span.children?.length;\n    const open = hasKids && !collapsed.has(row.span.id);\n    let handled = true;\n    switch (e.key) {\n      case \"ArrowDown\":\n        moveFocus(rows[i + 1]?.span.id);\n        break;\n      case \"ArrowUp\":\n        moveFocus(rows[i - 1]?.span.id);\n        break;\n      case \"Home\":\n        moveFocus(rows[0]?.span.id);\n        break;\n      case \"End\":\n        moveFocus(rows[rows.length - 1]?.span.id);\n        break;\n      case \"ArrowRight\":\n        if (hasKids && !open) toggle(row.span.id, true);\n        else if (open) moveFocus(rows[i + 1]?.span.id);\n        break;\n      case \"ArrowLeft\":\n        if (open) toggle(row.span.id, false);\n        else if (row.parentId) moveFocus(row.parentId);\n        break;\n      case \"Enter\":\n      case \" \":\n        select(row.span.id);\n        break;\n      case \"Escape\":\n        if (selectedId != null) setSelectedId(null);\n        else handled = false;\n        break;\n      default:\n        handled = false;\n    }\n    if (handled) e.preventDefault();\n  };\n\n  const runningNow = anyRunning ? now : null;\n  const announcement = failed\n    ? `${failed} span${failed > 1 ? \"s\" : \"\"} failed`\n    : count > 0 && !anyRunning\n      ? `Trace complete, ${formatDuration(total)}`\n      : \"\";\n\n  return (\n    <div\n      data-slot=\"trace-waterfall\"\n      data-state={anyRunning ? \"running\" : \"idle\"}\n      className={cn(\n        \"@container flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"trace-waterfall-header\"\n        className=\"flex h-10 shrink-0 items-center gap-3 border-kura-border-subtle border-b px-3.5\"\n      >\n        <Waypoints aria-hidden strokeWidth={1.75} className=\"size-3.5 text-kura-fg-subtle\" />\n        <span className=\"font-medium text-[13px] tracking-[-0.005em]\">{title}</span>\n        <span className=\"flex-1\" />\n        {count > 0 && (\n          <span\n            data-slot=\"trace-waterfall-summary\"\n            className=\"flex items-center gap-1.5 whitespace-nowrap font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n          >\n            <span className=\"hidden @md:inline\">{count} spans</span>\n            <span aria-hidden className=\"hidden @md:inline\">\n              ·\n            </span>\n            <span className={cn(\"min-w-[6ch] text-right\", anyRunning && \"text-kura-fg-muted\")}>\n              {formatDuration(total)}\n            </span>\n            {hasTokens && (\n              <>\n                <span aria-hidden className=\"hidden @lg:inline\">\n                  ·\n                </span>\n                <span className=\"hidden @lg:inline\">{formatTokens(tokens)} tok</span>\n              </>\n            )}\n            {hasCost && (\n              <>\n                <span aria-hidden className=\"hidden @lg:inline\">\n                  ·\n                </span>\n                <span className=\"hidden @lg:inline\">{formatCost(cost)}</span>\n              </>\n            )}\n          </span>\n        )}\n        {actions}\n      </div>\n\n      {count === 0 ? (\n        <div\n          data-slot=\"trace-waterfall-empty\"\n          className=\"flex flex-col items-center gap-1 px-6 py-10 text-center\"\n        >\n          {empty ?? (\n            <>\n              <span className=\"font-medium text-[13px]\">No spans yet</span>\n              <span className=\"text-[12px] text-kura-fg-subtle\">\n                Spans appear here as the agent calls models and tools.\n              </span>\n            </>\n          )}\n        </div>\n      ) : (\n        <div data-slot=\"trace-waterfall-body\" className=\"relative min-h-0 flex-1 overflow-auto\">\n          {/* Axis */}\n          <div\n            data-slot=\"trace-waterfall-axis\"\n            aria-hidden\n            className={cn(\n              GRID,\n              \"sticky top-0 z-20 h-7 border-kura-border-subtle border-b bg-kura-surface text-[11px] text-kura-fg-subtle\",\n            )}\n          >\n            <div className=\"flex items-center justify-between border-kura-border-subtle border-r px-3.5\">\n              <span>Span</span>\n              <span className=\"hidden @md:inline\">Duration</span>\n            </div>\n            <div className=\"relative\">\n              <div className={TRACK}>\n                {ticks.map((t, i) => (\n                  <span\n                    key={t}\n                    className={cn(\n                      \"absolute top-1/2 -translate-y-1/2 whitespace-nowrap font-kura-mono tabular-nums\",\n                      i === 0 ? \"\" : \"-translate-x-1/2\",\n                      // Narrow containers keep only the first and last labels.\n                      i > 0 && i < ticks.length - 1 && \"hidden @lg:inline\",\n                      // Keep the outermost labels inside the track.\n                      i === ticks.length - 1 && \"-translate-x-full\",\n                    )}\n                    style={{ left: pct(t) }}\n                  >\n                    {formatTick(t)}\n                  </span>\n                ))}\n                {anyRunning && (\n                  <span\n                    className=\"absolute bottom-0 h-2 w-px bg-kura-accent transition-[left] duration-(--kura-duration-base) ease-linear motion-reduce:transition-none\"\n                    style={{ left: pct(now) }}\n                  />\n                )}\n              </div>\n            </div>\n          </div>\n\n          <div className=\"relative\">\n            {/* Grid lines and the \"now\" marker, under the rows. */}\n            <div aria-hidden className={cn(GRID, \"pointer-events-none absolute inset-0 z-0\")}>\n              <div className=\"border-kura-border-subtle border-r\" />\n              <div className=\"relative\">\n                <div className={TRACK}>\n                  {ticks.map((t) => (\n                    <span\n                      key={t}\n                      className=\"absolute inset-y-0 w-px bg-kura-border-subtle\"\n                      style={{ left: pct(t) }}\n                    />\n                  ))}\n                  {runningNow != null && (\n                    <span\n                      data-slot=\"trace-waterfall-now\"\n                      className=\"absolute inset-y-0 w-px bg-kura-accent transition-[left] duration-(--kura-duration-base) ease-linear motion-reduce:transition-none\"\n                      style={{ left: pct(runningNow) }}\n                    />\n                  )}\n                </div>\n              </div>\n            </div>\n\n            <div role=\"tree\" aria-label={label} className=\"relative z-10 py-1\">\n              {rows.map((row) => {\n                const { span, depth } = row;\n                const end = endOf(span);\n                const hasKids = !!span.children?.length;\n                const open = hasKids && !collapsed.has(span.id);\n                const selected = selectedId === span.id;\n                const Icon = KIND_ICON[span.kind];\n                const dur = end - span.start;\n                const showGlyph =\n                  span.status === \"running\" ||\n                  span.status === \"failed\" ||\n                  span.status === \"waiting\";\n                const details = selected\n                  ? (renderDetails?.(span) ?? <Details span={span} end={end} />)\n                  : null;\n                return (\n                  <div\n                    key={span.id}\n                    ref={(el) => {\n                      if (el) itemRefs.current.set(span.id, el);\n                      else itemRefs.current.delete(span.id);\n                    }}\n                    role=\"treeitem\"\n                    aria-level={depth + 1}\n                    aria-posinset={row.posinset}\n                    aria-setsize={row.setsize}\n                    aria-expanded={hasKids ? open : undefined}\n                    aria-selected={selected}\n                    aria-label={`${span.name}${span.meta ? ` ${span.meta}` : \"\"}, ${KIND_LABEL[span.kind]}, ${formatDuration(dur)}, ${STATUS_WORD[span.status]}`}\n                    aria-describedby={selected ? detailsId : undefined}\n                    tabIndex={activeFocus === span.id ? 0 : -1}\n                    data-slot=\"trace-waterfall-span\"\n                    data-kind={span.kind}\n                    data-status={span.status}\n                    data-selected={selected || undefined}\n                    onKeyDown={(e) => onItemKeyDown(e, row)}\n                    onFocus={(e) => {\n                      if (e.target === e.currentTarget) setFocusedId(span.id);\n                    }}\n                    className=\"group/item outline-none transition-opacity duration-(--kura-duration-base) ease-kura-out starting:opacity-0\"\n                  >\n                    {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer shortcut; the treeitem owns keyboard interaction. */}\n                    {/* biome-ignore lint/a11y/useKeyWithClickEvents: Enter/Space are handled on the treeitem. */}\n                    <div\n                      data-slot=\"trace-waterfall-row\"\n                      onClick={() => {\n                        select(span.id);\n                        setFocusedId(span.id);\n                      }}\n                      className={cn(\n                        GRID,\n                        \"relative h-8 cursor-default transition-colors duration-(--kura-duration-instant)\",\n                        \"group-focus-visible/item:shadow-[inset_0_0_0_2px_var(--kura-ring)]\",\n                        selected\n                          ? \"bg-kura-active before:absolute before:inset-y-0 before:left-0 before:z-10 before:w-[2px] before:bg-kura-accent\"\n                          : \"hover:bg-kura-hover\",\n                      )}\n                    >\n                      <div\n                        className=\"flex min-w-0 items-center gap-1.5 pr-3.5\"\n                        style={{ paddingLeft: `${8 + depth * 16}px` }}\n                      >\n                        {hasKids ? (\n                          // biome-ignore lint/a11y/noStaticElementInteractions: mouse affordance; ArrowLeft/Right on the treeitem do the same.\n                          // biome-ignore lint/a11y/useKeyWithClickEvents: ArrowLeft/Right on the treeitem do the same.\n                          <span\n                            data-slot=\"trace-waterfall-toggle\"\n                            onClick={(e) => {\n                              e.stopPropagation();\n                              toggle(span.id);\n                              setFocusedId(span.id);\n                            }}\n                            className=\"-my-1 grid size-5 shrink-0 place-items-center rounded-kura-xs text-kura-fg-subtle hover:bg-kura-hover hover:text-kura-fg\"\n                          >\n                            <ChevronRight\n                              aria-hidden\n                              strokeWidth={2}\n                              className={cn(\n                                \"size-3.5 transition-transform duration-(--kura-duration-fast) ease-kura-out motion-reduce:transition-none\",\n                                open && \"rotate-90\",\n                              )}\n                            />\n                          </span>\n                        ) : (\n                          <span className=\"size-5 shrink-0\" />\n                        )}\n                        <Icon\n                          aria-hidden\n                          strokeWidth={1.75}\n                          className={cn(\n                            \"size-3.5 shrink-0\",\n                            span.kind === \"agent\" ? \"text-kura-fg-muted\" : \"text-kura-fg-subtle\",\n                          )}\n                        />\n                        <span\n                          className={cn(\n                            \"min-w-0 shrink truncate text-[13px]\",\n                            span.kind === \"agent\" ? \"font-medium\" : \"\",\n                            span.kind === \"tool\" && \"font-kura-mono text-[12.5px]\",\n                            span.status === \"queued\" && \"text-kura-fg-subtle\",\n                          )}\n                        >\n                          {span.name}\n                        </span>\n                        {span.meta && (\n                          <span className=\"hidden min-w-0 flex-1 truncate font-kura-mono text-[12px] text-kura-fg-subtle @md:inline\">\n                            {span.meta}\n                          </span>\n                        )}\n                        <span className={cn(\"flex-1\", span.meta && \"@md:hidden\")} />\n                        {showGlyph && <StatusGlyph status={span.status} size=\"sm\" label={null} />}\n                        <span\n                          data-slot=\"trace-waterfall-duration\"\n                          className={cn(\n                            \"hidden min-w-[6ch] shrink-0 text-right font-kura-mono text-[12px] tabular-nums @sm:inline\",\n                            span.status === \"running\"\n                              ? \"text-kura-fg-muted\"\n                              : \"text-kura-fg-subtle\",\n                          )}\n                        >\n                          {span.status === \"queued\" ? \"—\" : formatDuration(dur)}\n                        </span>\n                      </div>\n                      <div className=\"relative\">\n                        <div className={TRACK}>\n                          <span\n                            data-slot=\"trace-waterfall-bar\"\n                            className={cn(\n                              \"absolute top-1/2 -translate-y-1/2 rounded-[3px] transition-[left,width,background-color] duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                              span.kind === \"agent\" && depth === 0 ? \"h-1\" : \"h-2.5\",\n                              span.kind === \"agent\" && depth > 0 && \"h-1.5\",\n                              barTone(span, selected),\n                            )}\n                            style={{\n                              left: pct(span.start),\n                              width: `max(3px, calc(${pct(end)} - ${pct(span.start)}))`,\n                            }}\n                          />\n                        </div>\n                      </div>\n                    </div>\n                    {details != null && (\n                      <div\n                        id={detailsId}\n                        data-slot=\"trace-waterfall-details\"\n                        className=\"relative border-kura-border-subtle border-y bg-kura-surface-sunken px-3.5 py-3 animate-kura-fade-in\"\n                        style={{ paddingLeft: `${36 + depth * 16}px` }}\n                      >\n                        {details}\n                      </div>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\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 blue accent 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"
        }
      ]
    },
    {
      "name": "useElapsed",
      "file": "use-elapsed",
      "description": "Live elapsed time since a start instant while something is running, frozen when it stops. SSR-safe (null until mounted).",
      "signature": "useElapsed(since: Date | number | null | undefined, running: boolean, interval?: number): number | null",
      "returns": "Milliseconds since `since`, or null before mount or without a start time.",
      "example": "const ms = useElapsed(run.startedAt, run.status === \"running\");\n<span>{ms == null ? \"—\" : formatClock(ms)}</span>",
      "tags": [
        "timer",
        "elapsed",
        "agent"
      ],
      "importPath": "@kura-ui/react/hooks",
      "registryUrl": "https://kuro.abstergo.fyi/r/use-elapsed.json",
      "files": [
        {
          "target": "hooks/kura/use-elapsed.ts",
          "type": "registry:hook",
          "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Live elapsed milliseconds since `since` while `running`; frozen (last value) when stopped.\n * Re-renders every `interval` ms (default 1000; use 100 for sub-second timers). SSR-safe: returns\n * null until mounted so server and client markup agree.\n */\nexport function useElapsed(\n  since: Date | number | null | undefined,\n  running: boolean,\n  interval = 1000,\n): number | null {\n  const [now, setNow] = React.useState<number | null>(null);\n  React.useEffect(() => {\n    setNow(Date.now());\n    if (!running) return;\n    const id = setInterval(() => setNow(Date.now()), interval);\n    return () => clearInterval(id);\n  }, [running, interval]);\n  if (since == null || now == null) return null;\n  return Math.max(0, now - +new Date(since));\n}\n"
        }
      ]
    }
  ],
  "libs": [
    {
      "name": "format",
      "registryUrl": "https://kuro.abstergo.fyi/r/kura-format.json",
      "files": [
        {
          "target": "lib/kura/format.ts",
          "type": "registry:lib",
          "content": "/**\n * Number formatting shared by agent surfaces, so every component reads the same:\n * 820ms · 4.2s · 1m 04s · 1h 12m, 812 · 84.2k · 1.2M tokens, $0.04 · $1.82 · $12.40.\n */\n\nexport function formatDuration(ms: number): string {\n  if (!Number.isFinite(ms) || ms < 0) return \"0ms\";\n  if (ms < 1000) return `${Math.round(ms / 10) * 10}ms`;\n  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;\n  const s = Math.floor(ms / 1000);\n  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n  return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, \"0\")}m`;\n}\n\n/** Clock style for running timers: 0:42, 12:04, 1:02:09. */\nexport function formatClock(ms: number): string {\n  const s = Math.max(0, Math.floor(ms / 1000));\n  const h = Math.floor(s / 3600);\n  const m = Math.floor((s % 3600) / 60);\n  const ss = String(s % 60).padStart(2, \"0\");\n  return h > 0 ? `${h}:${String(m).padStart(2, \"0\")}:${ss}` : `${m}:${ss}`;\n}\n\nexport function formatTokens(n: number): string {\n  if (!Number.isFinite(n)) return \"0\";\n  const a = Math.abs(n);\n  if (a < 1000) return String(Math.round(n));\n  if (a < 1_000_000) return `${(n / 1000).toFixed(a < 100_000 ? 1 : 0)}k`;\n  return `${(n / 1_000_000).toFixed(1)}M`;\n}\n\nexport function formatCost(usd: number): string {\n  if (!Number.isFinite(usd)) return \"$0.00\";\n  if (usd > 0 && usd < 0.01) return \"<$0.01\";\n  return `$${usd.toFixed(2)}`;\n}\n"
        }
      ]
    },
    {
      "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": "10px",
      "--radius-kura-xl": "14px",
      "--radius-kura-2xl": "18px",
      "--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.99 0 0)",
      "--kura-gray-2": "oklch(0.975 0 0)",
      "--kura-gray-3": "oklch(0.96 0 0)",
      "--kura-gray-4": "oklch(0.94 0 0)",
      "--kura-gray-5": "oklch(0.92 0 0)",
      "--kura-gray-6": "oklch(0.895 0 0)",
      "--kura-gray-7": "oklch(0.86 0 0)",
      "--kura-gray-8": "oklch(0.79 0 0)",
      "--kura-gray-9": "oklch(0.64 0 0)",
      "--kura-gray-10": "oklch(0.58 0 0)",
      "--kura-gray-11": "oklch(0.46 0 0)",
      "--kura-gray-12": "oklch(0.16 0 0)",
      "--kura-canvas": "oklch(0.99 0 0)",
      "--kura-surface": "oklch(1 0 0)",
      "--kura-surface-raised": "oklch(1 0 0)",
      "--kura-surface-sunken": "oklch(0.97 0 0)",
      "--kura-overlay": "oklch(1 0 0 / 0.96)",
      "--kura-hover": "oklch(0.16 0 0 / 0.045)",
      "--kura-active": "oklch(0.16 0 0 / 0.08)",
      "--kura-fg": "var(--kura-gray-12)",
      "--kura-fg-muted": "var(--kura-gray-11)",
      "--kura-fg-subtle": "var(--kura-gray-10)",
      "--kura-fg-inverted": "oklch(0.99 0 0)",
      "--kura-border": "oklch(0.16 0 0 / 0.1)",
      "--kura-border-subtle": "oklch(0.16 0 0 / 0.065)",
      "--kura-border-strong": "oklch(0.16 0 0 / 0.18)",
      "--kura-ink": "var(--kura-gray-12)",
      "--kura-ink-hover": "oklch(0.28 0 0)",
      "--kura-accent": "oklch(0.56 0.2 260)",
      "--kura-accent-hover": "oklch(0.51 0.2 260)",
      "--kura-accent-fg": "oklch(0.99 0 0)",
      "--kura-accent-subtle": "oklch(0.955 0.025 260)",
      "--kura-accent-text": "oklch(0.5 0.19 260)",
      "--kura-danger": "oklch(0.58 0.21 27)",
      "--kura-danger-subtle": "oklch(0.96 0.02 27)",
      "--kura-danger-text": "oklch(0.52 0.19 27)",
      "--kura-success": "oklch(0.6 0.15 155)",
      "--kura-success-text": "oklch(0.48 0.12 155)",
      "--kura-warning": "oklch(0.76 0.15 75)",
      "--kura-warning-text": "oklch(0.52 0.12 65)",
      "--kura-ring": "oklch(0.56 0.2 260 / 0.45)",
      "--kura-shadow-xs": "0 0 0 1px var(--kura-border), 0 1px 1px 0 oklch(0 0 0 / 0.03)",
      "--kura-shadow-sm": "0 0 0 1px var(--kura-border), 0 1px 2px 0 oklch(0 0 0 / 0.05)",
      "--kura-shadow-md": "0 0 0 1px var(--kura-border), 0 2px 4px -1px oklch(0 0 0 / 0.04), 0 6px 16px -4px oklch(0 0 0 / 0.08)",
      "--kura-shadow-lg": "0 0 0 1px var(--kura-border), 0 4px 8px -2px oklch(0 0 0 / 0.05), 0 16px 40px -8px oklch(0 0 0 / 0.14)",
      "--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.145 0 0)",
      "--kura-gray-2": "oklch(0.17 0 0)",
      "--kura-gray-3": "oklch(0.2 0 0)",
      "--kura-gray-4": "oklch(0.225 0 0)",
      "--kura-gray-5": "oklch(0.25 0 0)",
      "--kura-gray-6": "oklch(0.28 0 0)",
      "--kura-gray-7": "oklch(0.33 0 0)",
      "--kura-gray-8": "oklch(0.41 0 0)",
      "--kura-gray-9": "oklch(0.55 0 0)",
      "--kura-gray-10": "oklch(0.62 0 0)",
      "--kura-gray-11": "oklch(0.74 0 0)",
      "--kura-gray-12": "oklch(0.97 0 0)",
      "--kura-canvas": "oklch(0.145 0 0)",
      "--kura-surface": "oklch(0.17 0 0)",
      "--kura-surface-raised": "oklch(0.2 0 0)",
      "--kura-surface-sunken": "oklch(0.125 0 0)",
      "--kura-overlay": "oklch(0.19 0 0 / 0.96)",
      "--kura-hover": "oklch(0.97 0 0 / 0.055)",
      "--kura-active": "oklch(0.97 0 0 / 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": "oklch(0.145 0 0)",
      "--kura-border": "oklch(0.97 0 0 / 0.1)",
      "--kura-border-subtle": "oklch(0.97 0 0 / 0.065)",
      "--kura-border-strong": "oklch(0.97 0 0 / 0.18)",
      "--kura-ink": "var(--kura-gray-12)",
      "--kura-ink-hover": "oklch(0.88 0 0)",
      "--kura-accent": "oklch(0.68 0.17 258)",
      "--kura-accent-hover": "oklch(0.73 0.15 258)",
      "--kura-accent-fg": "oklch(0.15 0.02 260)",
      "--kura-accent-subtle": "oklch(0.26 0.06 260)",
      "--kura-accent-text": "oklch(0.76 0.13 258)",
      "--kura-danger": "oklch(0.65 0.2 25)",
      "--kura-danger-subtle": "oklch(0.26 0.06 25)",
      "--kura-danger-text": "oklch(0.74 0.16 25)",
      "--kura-success": "oklch(0.72 0.15 155)",
      "--kura-success-text": "oklch(0.78 0.14 155)",
      "--kura-warning": "oklch(0.82 0.14 80)",
      "--kura-warning-text": "oklch(0.84 0.13 80)",
      "--kura-ring": "oklch(0.68 0.17 258 / 0.55)",
      "--kura-shadow-xs": "0 0 0 1px var(--kura-border)",
      "--kura-shadow-sm": "0 0 0 1px var(--kura-border), 0 1px 2px 0 oklch(0 0 0 / 0.4)",
      "--kura-shadow-md": "0 0 0 1px var(--kura-border), 0 8px 20px -6px oklch(0 0 0 / 0.55)",
      "--kura-shadow-lg": "0 0 0 1px var(--kura-border-strong), 0 16px 40px -8px oklch(0 0 0 / 0.7)",
      "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
        ]
      }
    }
  }
}
