{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "integration-grid",
  "title": "Integration Grid",
  "description": "A four-state integrations directory: debounced search and category facets that really filter, a live result count, and per-card connect / disconnect with pending, error and retry.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.zyeon.ai/r/use-debounce-value.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/integration-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Calendar,\n  ChartLine,\n  Check,\n  CreditCard,\n  Database,\n  GitBranch,\n  HardDrive,\n  LifeBuoy,\n  Loader2,\n  type LucideIcon,\n  Mail,\n  MessageCircle,\n  Plug,\n  Search,\n  SquareKanban,\n  TriangleAlert,\n  Video,\n  Workflow,\n  X,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n// 唯一的外部 hook,已在 registry 条目里声明为 registryDependency 一起安装。\n// shadcn 会把它落到你项目的 hooks 别名下,装完把这一行改成 \"@/hooks/use-debounce-value\"。\nimport { useDebounceValue } from \"@/hooks/use-debounce-value\"\nimport type {\n  IntegrationConnection,\n  IntegrationGridData,\n  IntegrationGridItem,\n  IntegrationIcon,\n} from \"./integration-grid.contract\"\n\n/**\n * 中性字形,不是品牌标 —— 目录条目描述的是「这一类集成」。要真 logo 就整表换成\n * simple-icons 之类的 mark(见 Prompt 的 Customization levers),契约不用动。\n */\nconst ICONS: Record<IntegrationIcon, LucideIcon> = {\n  analytics: ChartLine,\n  automation: Workflow,\n  calendar: Calendar,\n  chat: MessageCircle,\n  code: GitBranch,\n  database: Database,\n  mail: Mail,\n  payments: CreditCard,\n  storage: HardDrive,\n  support: LifeBuoy,\n  tasks: SquareKanban,\n  video: Video,\n}\n\n/**\n * 列宽由容器决定而不是视口断点:同一个块塞进设置页正文、侧栏或整页都自己换行。\n * auto-fill(不是 auto-fit)保证筛剩一张卡时它不会被拉成一整行宽。\n */\nconst GRID = \"grid gap-4 grid-cols-[repeat(auto-fill,minmax(min(17rem,100%),1fr))]\"\n\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\n/** 骨架卡的描述行数错落一点,预演真实数据「有的两行有的三行」的轮廓 */\nconst SKELETON_LINES = [2, 3, 2, 2, 3, 2]\n\n/** 卡片实际呈现的状态 = 服务端持久状态 ∪ 本次点击的在途态 */\ntype CardState = IntegrationConnection | \"disconnecting\"\n\ntype Attempt = \"connect\" | \"disconnect\"\n\nconst STATUS_LABELS: Record<CardState, string> = {\n  connected: \"Connected\",\n  connecting: \"Connecting…\",\n  disconnecting: \"Disconnecting…\",\n  disconnected: \"Not connected\",\n  error: \"Needs attention\",\n}\n\nconst STATUS_TONES: Record<CardState, string> = {\n  connected: \"bg-primary/10 text-primary\",\n  connecting: \"bg-muted text-muted-foreground\",\n  disconnecting: \"bg-muted text-muted-foreground\",\n  disconnected: \"bg-muted text-muted-foreground\",\n  error: \"bg-destructive/10 text-destructive\",\n}\n\nconst CHIP =\n  \"inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n\nconst ACTION =\n  \"inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none aria-disabled:cursor-progress aria-disabled:opacity-70\"\n\n/** 搜索命中名称、描述和分类三处 —— 用户记得住的往往不是名字而是「那个做日历的」 */\nfunction matches(item: IntegrationGridItem, needle: string) {\n  return (\n    item.name.toLowerCase().includes(needle) ||\n    item.description.toLowerCase().includes(needle) ||\n    item.category.toLowerCase().includes(needle)\n  )\n}\n\nfunction StatusPanel({\n  action,\n  className,\n  detail,\n  title,\n}: {\n  action?: React.ReactNode\n  className?: string\n  detail: React.ReactNode\n  title: string\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center gap-2 rounded-xl border bg-card py-14 text-center\",\n        className,\n      )}\n    >\n      <p className=\"text-sm font-medium\">{title}</p>\n      <p className=\"max-w-md px-6 text-sm text-balance text-muted-foreground\">{detail}</p>\n      {action}\n    </div>\n  )\n}\n\nfunction IntegrationCard({\n  actionable,\n  busy,\n  failure,\n  item,\n  onAction,\n  state,\n}: {\n  actionable: boolean\n  busy: boolean\n  failure?: string\n  item: IntegrationGridItem\n  onAction: () => void\n  state: CardState\n}) {\n  const errorId = React.useId()\n  const Icon = ICONS[item.icon]\n  const showError = state === \"error\"\n  const actionLabel = busy\n    ? STATUS_LABELS[state === \"disconnecting\" ? \"disconnecting\" : \"connecting\"]\n    : showError\n      ? \"Try again\"\n      : item.connection === \"connected\"\n        ? \"Disconnect\"\n        : \"Connect\"\n\n  return (\n    <li className=\"flex h-full min-w-0 flex-col gap-3 rounded-xl border bg-card p-5 text-card-foreground\">\n      <div className=\"flex items-start gap-3\">\n        <span className=\"flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground\">\n          <Icon aria-hidden=\"true\" className=\"size-5\" />\n        </span>\n        {/*\n          这一列不能用 items-start:那会把子项按 fit-content 定宽,而 break-words 不降低\n          min-content,于是一个不带空格的长名字会把自己撑出卡片(实测 375px 下溢出 21px)。\n          保持默认 stretch(子项 = 容器宽),再单独给徽章 w-fit,长名字才会真的折行。\n        */}\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n          {/* break-words 只在真放不下时断词;break-all 会把 min-content 塌成一个字符宽 */}\n          <p className=\"text-sm font-semibold break-words\">{item.name}</p>\n          <span\n            className={cn(\n              \"inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium\",\n              STATUS_TONES[state],\n            )}\n          >\n            {busy ? (\n              <Loader2\n                aria-hidden=\"true\"\n                className=\"size-3 animate-spin motion-reduce:animate-none\"\n              />\n            ) : state === \"connected\" ? (\n              <Check aria-hidden=\"true\" className=\"size-3\" />\n            ) : state === \"error\" ? (\n              <TriangleAlert aria-hidden=\"true\" className=\"size-3\" />\n            ) : (\n              <Plug aria-hidden=\"true\" className=\"size-3\" />\n            )}\n            {STATUS_LABELS[state]}\n          </span>\n        </div>\n      </div>\n\n      {/* 不设固定高度、不 line-clamp:长描述把卡片撑高,网格自己对齐,永不静默截断 */}\n      <p className=\"text-sm leading-relaxed break-words text-muted-foreground\">\n        {item.description}\n      </p>\n\n      {showError && (\n        <p className=\"text-sm break-words text-destructive\" id={errorId}>\n          {failure ?? \"The last connection attempt didn't finish.\"}\n        </p>\n      )}\n\n      <div className=\"mt-auto flex flex-wrap items-center justify-between gap-2 pt-1\">\n        <span className=\"text-xs text-muted-foreground\">{item.category}</span>\n        {actionable && (\n          <button\n            // 原生 disabled 会让浏览器立刻 blur 按钮,键盘用户点完就失去焦点、\n            // 也读不到旁边的错误说明;用 aria-disabled + handler 里同步拦截。\n            aria-describedby={showError ? errorId : undefined}\n            aria-disabled={busy || undefined}\n            className={cn(\n              ACTION,\n              item.connection === \"connected\"\n                ? \"border hover:bg-muted\"\n                : \"bg-primary text-primary-foreground hover:bg-primary/90\",\n            )}\n            onClick={onAction}\n            type=\"button\"\n          >\n            {busy && (\n              <Loader2\n                aria-hidden=\"true\"\n                className=\"size-3.5 animate-spin motion-reduce:animate-none\"\n              />\n            )}\n            {actionLabel}\n            <span className=\"sr-only\"> {item.name}</span>\n          </button>\n        )}\n      </div>\n    </li>\n  )\n}\n\nexport interface IntegrationGridProps extends IntegrationGridData {\n  /**\n   * 点 Connect 时调用。返回 Promise 就由它的 settle 决定 pending 何时结束、失败要不要报错;\n   * 组件自己不发请求也不改数据 —— 成功之后由你回写 items 里的 connection。\n   */\n  onConnect?: (item: IntegrationGridItem) => void | Promise<void>\n  /** 点 Disconnect 时调用,语义同 onConnect。不传就不渲染已连接卡片上的动作按钮。 */\n  onDisconnect?: (item: IntegrationGridItem) => void | Promise<void>\n  /** status === \"error\" 时渲染 Try again;不传就只显示错误面板。 */\n  onRetry?: () => void\n  className?: string\n}\n\nexport function IntegrationGrid({\n  className,\n  heading,\n  items,\n  onConnect,\n  onDisconnect,\n  onRetry,\n  status,\n  subheading,\n}: IntegrationGridProps) {\n  const baseId = React.useId()\n  const [query, setQuery] = React.useState(\"\")\n  const [category, setCategory] = React.useState<string | null>(null)\n  const [pending, setPending] = React.useState<Record<string, Attempt>>({})\n  /** 失败连同「当时的服务端状态」一起记:数据一变这条失败就自动作废,不会留在页面上撒谎 */\n  const [failures, setFailures] = React.useState<\n    Record<string, { from: IntegrationConnection; message: string }>\n  >({})\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  /**\n   * React 的 state 要等下一次渲染才更新,同一批里的连点会整批穿过基于 state 的判断。\n   * 这个 ref 在点击那一刻同步置位,才真的挡得住重复提交。\n   */\n  const inFlightRef = React.useRef<Set<string>>(new Set())\n  const aliveRef = React.useRef(true)\n  React.useEffect(() => {\n    aliveRef.current = true\n    return () => {\n      aliveRef.current = false\n    }\n  }, [])\n\n  // 搜索走 debounce:每个按键都重算 + 重报计数,在服务端搜索场景下就是每键一次请求\n  const debouncedQuery = useDebounceValue(query, 200)\n  const term = debouncedQuery.trim()\n  const needle = term.toLowerCase()\n\n  /** 分类按首次出现顺序推导,不另立清单 —— 于是不存在「点了没有结果」的死 chip */\n  const categories = React.useMemo(() => {\n    const seen = new Set<string>()\n    const order: string[] = []\n    for (const item of items) {\n      const label = item.category.trim()\n      if (label && !seen.has(label)) {\n        seen.add(label)\n        order.push(label)\n      }\n    }\n    return order\n  }, [items])\n\n  // 数据换了一批、当前分类不复存在时自动退回 All,不然筛选会卡在一个选不掉的空集上\n  const activeCategory = category && categories.includes(category) ? category : null\n\n  const searchMatched = React.useMemo(\n    () => (needle ? items.filter(item => matches(item, needle)) : items),\n    [items, needle],\n  )\n\n  /** facet 计数是在搜索结果之上算的:chip 上的数字就是点下去会看到的条数 */\n  const counts = React.useMemo(() => {\n    const map = new Map<string, number>()\n    for (const item of searchMatched) {\n      const label = item.category.trim()\n      map.set(label, (map.get(label) ?? 0) + 1)\n    }\n    return map\n  }, [searchMatched])\n\n  const visible = React.useMemo(\n    () =>\n      activeCategory\n        ? searchMatched.filter(item => item.category.trim() === activeCategory)\n        : searchMatched,\n    [activeCategory, searchMatched],\n  )\n\n  const filtering = needle.length > 0 || activeCategory !== null\n\n  const clearFilters = () => {\n    setQuery(\"\")\n    setCategory(null)\n  }\n\n  const settle = (id: string, failure?: { from: IntegrationConnection; message: string }) => {\n    inFlightRef.current.delete(id)\n    if (!aliveRef.current) return\n    setPending(prev => {\n      if (!(id in prev)) return prev\n      const next = { ...prev }\n      delete next[id]\n      return next\n    })\n    if (failure) setFailures(prev => ({ ...prev, [id]: failure }))\n  }\n\n  const runAction = (item: IntegrationGridItem) => {\n    // 服务端说还在握手中的条目同样不接受点击,否则会并发发起第二次连接\n    if (inFlightRef.current.has(item.id) || item.connection === \"connecting\") return\n    const attempt: Attempt = item.connection === \"connected\" ? \"disconnect\" : \"connect\"\n    const handler = attempt === \"connect\" ? onConnect : onDisconnect\n    if (!handler) return\n\n    inFlightRef.current.add(item.id)\n    setPending(prev => ({ ...prev, [item.id]: attempt }))\n    setFailures(prev => {\n      if (!(item.id in prev)) return prev\n      const next = { ...prev }\n      delete next[item.id]\n      return next\n    })\n    setAnnouncement(`${attempt === \"connect\" ? \"Connecting\" : \"Disconnecting\"} ${item.name}…`)\n\n    // Promise.resolve(handler(...)) 拦不住同步抛出的异常 —— 它在 resolve 拿到之前就逃走了,\n    // 按钮会永远停在 pending。executor 形式会把同步抛错转成 rejection。\n    new Promise<void>(resolve => {\n      resolve(handler(item))\n    })\n      .then(() => {\n        settle(item.id)\n        if (aliveRef.current) {\n          setAnnouncement(\n            `${item.name} ${attempt === \"connect\" ? \"connected\" : \"disconnected\"}.`,\n          )\n        }\n      })\n      .catch((error: unknown) => {\n        const message =\n          error instanceof Error && error.message\n            ? error.message\n            : `Couldn't ${attempt} ${item.name}.`\n        settle(item.id, { from: item.connection, message })\n        if (aliveRef.current) setAnnouncement(message)\n      })\n  }\n\n  return (\n    <section\n      aria-labelledby={heading ? `${baseId}-heading` : undefined}\n      className={cn(\"flex w-full flex-col gap-6\", className)}\n    >\n      {(heading || subheading) && (\n        <div className=\"flex max-w-2xl flex-col gap-2\">\n          {heading && (\n            <h2 className=\"text-2xl font-semibold tracking-tight\" id={`${baseId}-heading`}>\n              {heading}\n            </h2>\n          )}\n          {subheading && <p className=\"text-sm text-muted-foreground\">{subheading}</p>}\n        </div>\n      )}\n\n      {status === \"loading\" && (\n        <>\n          <span className=\"sr-only\" role=\"status\">\n            Loading integrations\n          </span>\n          <div aria-hidden=\"true\" className=\"flex flex-col gap-6\">\n            <div className=\"flex flex-wrap items-center gap-3\">\n              <div className={cn(\"h-9 flex-1 basis-64\", pulse)} />\n              <div className={cn(\"h-4 w-32\", pulse)} />\n            </div>\n            <div className={GRID}>\n              {SKELETON_LINES.map((lines, i) => (\n                <div className=\"flex flex-col gap-3 rounded-xl border bg-card p-5\" key={i}>\n                  <div className=\"flex items-start gap-3\">\n                    <div className={cn(\"size-10 shrink-0 rounded-lg\", pulse)} />\n                    <div className=\"flex flex-1 flex-col gap-2\">\n                      <div className={cn(\"h-3.5 w-2/3\", pulse)} />\n                      <div className={cn(\"h-4 w-24 rounded-full\", pulse)} />\n                    </div>\n                  </div>\n                  {Array.from({ length: lines }, (_, j) => (\n                    <div className={cn(\"h-3\", pulse, j === lines - 1 ? \"w-3/5\" : \"w-full\")} key={j} />\n                  ))}\n                  <div className=\"mt-auto flex items-center justify-between gap-2 pt-1\">\n                    <div className={cn(\"h-3 w-20\", pulse)} />\n                    <div className={cn(\"h-8 w-24 rounded-lg\", pulse)} />\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n        </>\n      )}\n\n      {status === \"empty\" && (\n        <StatusPanel\n          detail=\"Once your workspace publishes a catalog, every available app shows up here.\"\n          title=\"No integrations available\"\n        />\n      )}\n\n      {status === \"error\" && (\n        <StatusPanel\n          action={\n            onRetry && (\n              <button\n                className=\"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                onClick={onRetry}\n                type=\"button\"\n              >\n                Try again\n              </button>\n            )\n          }\n          detail=\"The catalog didn't respond, so we can't tell which apps are connected.\"\n          title=\"Couldn't load integrations\"\n        />\n      )}\n\n      {status === \"ready\" && (\n        <>\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex flex-wrap items-center gap-3\">\n              <div className=\"relative min-w-0 flex-1 basis-56\">\n                <label className=\"sr-only\" htmlFor={`${baseId}-search`}>\n                  Search integrations\n                </label>\n                <Search\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\"\n                />\n                <input\n                  autoComplete=\"off\"\n                  className={cn(\n                    \"h-9 w-full rounded-md border border-input bg-transparent pr-9 pl-9 text-sm shadow-xs transition-colors\",\n                    \"placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none\",\n                    \"[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\",\n                  )}\n                  id={`${baseId}-search`}\n                  onChange={event => setQuery(event.target.value)}\n                  onKeyDown={event => {\n                    if (event.key === \"Escape\" && query) {\n                      // 原生 type=search 的 Esc 清空绕过 React,受控值会立刻被写回去\n                      event.preventDefault()\n                      setQuery(\"\")\n                    }\n                  }}\n                  placeholder=\"Search integrations\"\n                  type=\"search\"\n                  value={query}\n                />\n                {query && (\n                  <button\n                    aria-label=\"Clear search\"\n                    className=\"absolute top-1/2 right-2 inline-flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                    onClick={() => setQuery(\"\")}\n                    type=\"button\"\n                  >\n                    <X aria-hidden=\"true\" className=\"size-3.5\" />\n                  </button>\n                )}\n              </div>\n\n              <p className=\"text-sm text-muted-foreground tabular-nums\" role=\"status\">\n                Showing {visible.length} of {items.length} integrations\n              </p>\n            </div>\n\n            {categories.length > 0 && (\n              <div aria-label=\"Filter by category\" className=\"flex flex-wrap gap-2\" role=\"group\">\n                <button\n                  aria-pressed={activeCategory === null}\n                  className={cn(\n                    CHIP,\n                    activeCategory === null\n                      ? \"border-primary bg-primary text-primary-foreground hover:bg-primary/90\"\n                      : \"hover:bg-muted\",\n                  )}\n                  onClick={() => setCategory(null)}\n                  type=\"button\"\n                >\n                  All\n                  <span className=\"text-xs tabular-nums opacity-70\">{searchMatched.length}</span>\n                </button>\n                {categories.map(label => {\n                  const active = activeCategory === label\n                  return (\n                    <button\n                      aria-pressed={active}\n                      className={cn(\n                        CHIP,\n                        active\n                          ? \"border-primary bg-primary text-primary-foreground hover:bg-primary/90\"\n                          : \"hover:bg-muted\",\n                      )}\n                      key={label}\n                      onClick={() => setCategory(active ? null : label)}\n                      type=\"button\"\n                    >\n                      {label}\n                      <span className=\"text-xs tabular-nums opacity-70\">\n                        {counts.get(label) ?? 0}\n                      </span>\n                    </button>\n                  )\n                })}\n              </div>\n            )}\n          </div>\n\n          <span className=\"sr-only\" role=\"status\">\n            {announcement}\n          </span>\n\n          {visible.length > 0 ? (\n            <ul className={GRID}>\n              {visible.map(item => {\n                const attempt: Attempt | undefined = pending[item.id]\n                const busy = Boolean(attempt) || item.connection === \"connecting\"\n                // 失败是对着某个服务端状态记的;状态一变(别处刷新了数据)这条就作废\n                const recorded = failures[item.id]\n                const failure = recorded?.from === item.connection ? recorded : undefined\n                const state: CardState = attempt\n                  ? attempt === \"disconnect\"\n                    ? \"disconnecting\"\n                    : \"connecting\"\n                  : item.connection === \"connecting\"\n                    ? \"connecting\"\n                    : failure\n                      ? \"error\"\n                      : item.connection\n                return (\n                  <IntegrationCard\n                    actionable={Boolean(\n                      item.connection === \"connected\" ? onDisconnect : onConnect,\n                    )}\n                    busy={busy}\n                    failure={failure?.message}\n                    item={item}\n                    key={item.id}\n                    onAction={() => runAction(item)}\n                    state={state}\n                  />\n                )\n              })}\n            </ul>\n          ) : filtering ? (\n            // 「筛没了」和「一个集成都没有」是两件事,文案和动作都不一样\n            <StatusPanel\n              action={\n                <button\n                  className=\"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                  onClick={clearFilters}\n                  type=\"button\"\n                >\n                  Clear filters\n                </button>\n              }\n              className=\"border-dashed\"\n              detail={\n                <>\n                  Nothing{term ? ` matches “${term}”` : \"\"}\n                  {activeCategory ? ` in ${activeCategory}` : \"\"}. Clear the filters to see all{\" \"}\n                  {items.length} integrations.\n                </>\n              }\n              title=\"No integrations match your filters\"\n            />\n          ) : (\n            <StatusPanel\n              detail=\"Once your workspace publishes a catalog, every available app shows up here.\"\n              title=\"No integrations available\"\n            />\n          )}\n        </>\n      )}\n    </section>\n  )\n}\n\nexport default IntegrationGrid\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/integration-grid.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * 中性字形键,不是品牌标 —— 目录里的条目是「这一类集成」,所以不硬编码任何品牌名或品牌色。\n * 想要真 logo,整张 ICONS 映射表换成 simple-icons 之类的 mark 即可,契约一个字都不用动。\n */\nexport const integrationIconSchema = z.enum([\n  \"analytics\",\n  \"automation\",\n  \"calendar\",\n  \"chat\",\n  \"code\",\n  \"database\",\n  \"mail\",\n  \"payments\",\n  \"storage\",\n  \"support\",\n  \"tasks\",\n  \"video\",\n])\n\n/**\n * 服务端持有的连接状态。`connecting` 是服务端报告的在途握手(OAuth 还没回调回来),\n * `error` 是上一次连接留下的失败。本次点击产生的在途/失败是组件自己的瞬时状态,\n * 由组件与这份持久状态合并后再渲染 —— 合并规则见组件顶部注释。\n */\nexport const integrationConnectionSchema = z.enum([\n  \"disconnected\",\n  \"connecting\",\n  \"connected\",\n  \"error\",\n])\n\nexport const integrationGridItemSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  /** 一句话说明这个集成干什么。组件不截断:长文案把卡片撑高,而不是被裁掉。 */\n  description: z.string(),\n  /** 直接就是人读的分类名;筛选 chip 行按首次出现顺序从 items 推导,不另立一份清单。 */\n  category: z.string(),\n  icon: integrationIconSchema,\n  connection: integrationConnectionSchema,\n})\n\nexport const integrationGridSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  heading: z.string().optional(),\n  subheading: z.string().optional(),\n  items: z.array(integrationGridItemSchema),\n})\n\nexport type IntegrationIcon = z.infer<typeof integrationIconSchema>\nexport type IntegrationConnection = z.infer<typeof integrationConnectionSchema>\nexport type IntegrationGridItem = z.infer<typeof integrationGridItemSchema>\nexport type IntegrationGridData = z.infer<typeof integrationGridSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:block"
}
