{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "team-grid",
  "title": "Team Grid",
  "description": "A four-state team section with equal-height member cards, initials-fallback avatars, real social links and optional department grouping.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/team-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AtSign,\n  Briefcase,\n  Camera,\n  GitBranch,\n  Globe,\n  type LucideIcon,\n  Mail,\n  MessageCircle,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type { TeamGridData, TeamGridItem, TeamSocialChannel } from \"./team-grid.contract\"\n\n/**\n * 通道图标是**中性字形**,不是品牌标。平台名走 `label`(读屏可听的可访问名),\n * 想要真品牌标就整表换成 simple-icons 之类的 mark —— 见 Prompt 的 Customization levers。\n */\nconst CHANNEL_ICONS: Record<TeamSocialChannel, LucideIcon> = {\n  chat: MessageCircle,\n  code: GitBranch,\n  email: Mail,\n  photo: Camera,\n  post: AtSign,\n  website: Globe,\n  work: Briefcase,\n}\n\n/**\n * 列宽由容器决定而不是视口断点:同一个块塞进侧栏、文档正文或整页都自己换行。\n * auto-fill(不是 auto-fit)保证只剩一个人时卡片不会被拉成一整行宽。\n */\nconst GRID = \"grid gap-4 grid-cols-[repeat(auto-fill,minmax(min(13rem,100%),1fr))]\"\n\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\n/** 骨架卡的简介行数错落一点,预演真实数据「有人写了简介有人没写」的轮廓 */\nconst SKELETON_BIO_LINES = [2, 0, 3, 1, 2, 0]\n\nconst firstChar = (word: string) => Array.from(word)[0] ?? \"\"\n\n/**\n * 首字母降级:拉丁姓名取前两个词的首字母;CJK 等不用空格分词的姓名只取一个字\n * 认不出人,取前两个字。任何情况都能被 item.initials 覆盖。\n */\nfunction initialsOf(member: Pick<TeamGridItem, \"initials\" | \"name\">) {\n  if (member.initials) return member.initials\n  const words = member.name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  if (words.length > 1) return words.slice(0, 2).map(firstChar).join(\"\").toUpperCase()\n  const chars = Array.from(words[0])\n  return (/[A-Za-z]/.test(words[0]) ? chars.slice(0, 1) : chars.slice(0, 2)).join(\"\").toUpperCase()\n}\n\nfunction MemberAvatar({ member }: { member: TeamGridItem }) {\n  const [failed, setFailed] = React.useState(false)\n  const showImage = Boolean(member.avatarUrl) && !failed\n\n  /**\n   * 预渲染页面上,缓存命中的图片可能在 hydration 挂上 onError 之前就已经失败,\n   * 事件永远不来、破图永远留着 —— ref 回调里补一次同步探测。\n   */\n  const probe = React.useCallback((node: HTMLImageElement | null) => {\n    if (node?.complete && node.naturalWidth === 0) setFailed(true)\n  }, [])\n\n  return (\n    <span className=\"flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-base font-medium text-muted-foreground\">\n      {showImage ? (\n        // eslint-disable-next-line @next/next/no-img-element -- 可移植 registry 源码,不绑 next/image\n        <img\n          alt=\"\"\n          className=\"size-full object-cover\"\n          onError={() => setFailed(true)}\n          ref={probe}\n          src={member.avatarUrl}\n        />\n      ) : (\n        // 姓名就在下面一行,首字母是纯装饰,读出来只会重复\n        <span aria-hidden=\"true\">{initialsOf(member)}</span>\n      )}\n    </span>\n  )\n}\n\nfunction MemberCard({ member }: { member: TeamGridItem }) {\n  const socials = member.socials ?? []\n\n  return (\n    <li className=\"flex h-full flex-col items-center gap-3 rounded-xl border bg-card p-5 text-center text-card-foreground transition-colors hover:border-primary/40 motion-reduce:transition-none\">\n      {/* key = 头像地址:数据换人时把失败状态一起丢掉,不用在 effect 里 setState */}\n      <MemberAvatar key={member.avatarUrl ?? \"initials\"} member={member} />\n\n      <div className=\"flex w-full min-w-0 flex-col gap-0.5\">\n        {/* break-words 只在真放不下时断词;break-all 会把 min-content 塌成一个字符宽 */}\n        <p className=\"text-sm font-semibold break-words\">{member.name}</p>\n        <p className=\"text-sm break-words text-muted-foreground\">{member.role}</p>\n      </div>\n\n      {member.bio && (\n        <p className=\"w-full min-w-0 text-sm leading-relaxed break-words text-muted-foreground\">\n          {member.bio}\n        </p>\n      )}\n\n      {socials.length > 0 && (\n        // mt-auto:卡片被网格拉到同一高度后,社交行仍然贴着底边对齐\n        <ul className=\"mt-auto flex flex-wrap items-center justify-center gap-1 pt-1\">\n          {socials.map(social => {\n            const Icon = CHANNEL_ICONS[social.channel]\n            const external = /^https?:/i.test(social.href)\n            return (\n              <li key={`${social.channel}-${social.href}`}>\n                <a\n                  // 没有这个名字,读屏只会听到一串没有归属的空链接\n                  aria-label={`${social.label}, ${member.name}`}\n                  className={cn(\n                    \"flex size-8 items-center justify-center rounded-lg border text-muted-foreground\",\n                    \"transition-colors hover:bg-muted hover:text-foreground\",\n                    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                    \"motion-reduce:transition-none\",\n                  )}\n                  href={social.href}\n                  rel={external ? \"noreferrer\" : undefined}\n                  target={external ? \"_blank\" : undefined}\n                >\n                  <Icon aria-hidden=\"true\" className=\"size-4\" />\n                </a>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n    </li>\n  )\n}\n\nexport interface TeamGridProps extends TeamGridData {\n  /** 按 department 分组渲染:一组一个小标题 + 一个网格,组内保持数据顺序 */\n  grouped?: boolean\n  onRetry?: () => void\n  className?: string\n}\n\nexport function TeamGrid({\n  status,\n  heading,\n  subheading,\n  items,\n  grouped = false,\n  onRetry,\n  className,\n}: TeamGridProps) {\n  const baseId = React.useId()\n\n  /** 分组按「首次出现顺序」而不是字母序:数据怎么排,页面就怎么读 */\n  const groups = React.useMemo(() => {\n    const order: (string | undefined)[] = []\n    const buckets = new Map<string | undefined, TeamGridItem[]>()\n    for (const item of items) {\n      const key = item.department?.trim() || undefined\n      const bucket = buckets.get(key)\n      if (bucket) bucket.push(item)\n      else {\n        buckets.set(key, [item])\n        order.push(key)\n      }\n    }\n    return order.map(key => ({ key, members: buckets.get(key) ?? [] }))\n  }, [items])\n\n  // 没有 section 标题时组标题就是本块最高一级,别跳级\n  const GroupHeading = heading ? \"h3\" : \"h2\"\n  // 标题只在 ready 分支渲染,别的状态下指过去就是一条断掉的 aria 引用\n  const headingRendered = status === \"ready\" && Boolean(heading)\n\n  return (\n    <section\n      aria-labelledby={headingRendered ? `${baseId}-heading` : undefined}\n      className={cn(\"flex w-full flex-col gap-8\", className)}\n    >\n      {status === \"ready\" && (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 team members\n          </span>\n          <div aria-hidden=\"true\" className={GRID}>\n            {SKELETON_BIO_LINES.map((lines, i) => (\n              <div\n                className=\"flex flex-col items-center gap-3 rounded-xl border bg-card p-5\"\n                key={i}\n              >\n                <div className={cn(\"size-16 shrink-0 rounded-full\", pulse)} />\n                <div className={cn(\"h-3.5 w-24\", pulse)} />\n                <div className={cn(\"h-3 w-20\", pulse)} />\n                {Array.from({ length: lines }, (_, j) => (\n                  <div className={cn(\"h-3\", pulse, j === lines - 1 ? \"w-2/3\" : \"w-full\")} key={j} />\n                ))}\n                <div className=\"mt-auto flex gap-1 pt-1\">\n                  {Array.from({ length: 3 }, (_, j) => (\n                    <div className={cn(\"size-8 rounded-lg\", pulse)} key={j} />\n                  ))}\n                </div>\n              </div>\n            ))}\n          </div>\n        </>\n      )}\n\n      {status === \"empty\" && (\n        <div className=\"flex flex-col items-center gap-2 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">No team members yet</p>\n          <p className=\"text-sm text-muted-foreground\">\n            People appear here once profiles are published.\n          </p>\n        </div>\n      )}\n\n      {status === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">Couldn&apos;t load the team</p>\n          <p className=\"text-sm text-muted-foreground\">The data source didn&apos;t respond.</p>\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        </div>\n      )}\n\n      {status === \"ready\" &&\n        (grouped ? (\n          <div className=\"flex flex-col gap-8\">\n            {groups.map((group, i) => (\n              <div className=\"flex flex-col gap-4\" key={group.key ?? `${baseId}-ungrouped`}>\n                {group.key && (\n                  <GroupHeading\n                    className=\"text-xs font-semibold tracking-wide text-muted-foreground uppercase\"\n                    id={`${baseId}-group-${i}`}\n                  >\n                    {group.key}\n                  </GroupHeading>\n                )}\n                <ul\n                  aria-labelledby={group.key ? `${baseId}-group-${i}` : undefined}\n                  className={GRID}\n                >\n                  {group.members.map(member => (\n                    <MemberCard key={member.id} member={member} />\n                  ))}\n                </ul>\n              </div>\n            ))}\n          </div>\n        ) : (\n          <ul className={GRID}>\n            {items.map(member => (\n              <MemberCard key={member.id} member={member} />\n            ))}\n          </ul>\n        ))}\n    </section>\n  )\n}\n\nexport default TeamGrid\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/team-grid.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * 交互诚实度红线:社交链接必须指向真实目标(公网 URL / mailto / 本站路由),\n * 契约层直接拒掉死锚点,免得「先渲染个图标再说」的假链接混进产品。\n */\nconst hrefSchema = z\n  .string()\n  .min(1)\n  .refine(href => href.trim() !== \"\" && !href.trim().startsWith(\"#\"), {\n    message: 'href must point at a real destination — dead anchors like \"#\" are rejected',\n  })\n\n/**\n * 通道语义而非品牌名 —— 组件只知道「这是代码托管 / 长文 / 职场档案…」,\n * 具体是哪家平台由 label(读屏可听的可访问名)决定,图标可整表替换。\n */\nexport const teamSocialChannelSchema = z.enum([\n  \"chat\",\n  \"code\",\n  \"email\",\n  \"photo\",\n  \"post\",\n  \"website\",\n  \"work\",\n])\n\nexport const teamSocialSchema = z.object({\n  channel: teamSocialChannelSchema,\n  /** 可访问名的平台部分,如 \"GitHub\" / \"Email\";组件把它与姓名拼成完整 aria-label */\n  label: z.string().min(1),\n  href: hrefSchema,\n})\n\nexport const teamGridItemSchema = z.object({\n  id: z.string(),\n  name: z.string().min(1),\n  /** 职位一行写满也不裁,过长时换行,卡片靠网格拉伸保持等高 */\n  role: z.string().min(1),\n  /** 可选简介;缺省时卡片自然收敛,不留空洞 */\n  bio: z.string().optional(),\n  /** 头像地址;缺省 **或加载失败** 都降级成首字母,永不留破图 */\n  avatarUrl: z.string().optional(),\n  /** 覆盖自动推导的首字母(艺名、单名、非拉丁姓名的特例) */\n  initials: z.string().optional(),\n  /** 分组键,同时用作组标题;缺省的成员归进一组不带标题的兜底组 */\n  department: z.string().optional(),\n  socials: z.array(teamSocialSchema).optional(),\n})\n\nexport const teamGridSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  heading: z.string().optional(),\n  subheading: z.string().optional(),\n  items: z.array(teamGridItemSchema),\n})\n\nexport type TeamSocialChannel = z.infer<typeof teamSocialChannelSchema>\nexport type TeamSocial = z.infer<typeof teamSocialSchema>\nexport type TeamGridItem = z.infer<typeof teamGridItemSchema>\nexport type TeamGridData = z.infer<typeof teamGridSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:block"
}