{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "case-study-grid",
  "title": "Case Study Grid",
  "description": "A four-state customer-story grid where the quantified result is the first-class field: every metric carries value, unit, direction and a required baseline (or says \"No baseline captured\"), industry and company-size facets really filter with truthful counts, and logos of any aspect ratio are optically area-matched.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/case-study-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, TrendingDown, TrendingUp } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  CaseStudyGridData,\n  CaseStudyGridItem,\n  CaseStudyLogo,\n  CaseStudyMetric,\n  CaseStudySize,\n} from \"./case-study-grid.contract\"\n\n/** 规模是闭集,所以 chip 顺序永远小→大,而不是数据碰巧的出现顺序。 */\nconst SIZE_ORDER: readonly CaseStudySize[] = [\"startup\", \"mid-market\", \"enterprise\"]\n\n/** 员工数分档是行业惯例的一种,不是唯一一种 —— 换成 ARR / 席位数只改这张表,契约不动。 */\nconst SIZE_LABELS: Record<CaseStudySize, string> = {\n  enterprise: \"500+ staff\",\n  \"mid-market\": \"51–500 staff\",\n  startup: \"1–50 staff\",\n}\n\n/* ------------------------------------------------------------------ *\n * 光学对齐\n * ------------------------------------------------------------------ */\n\n/** 超出这个区间的宽高比是坏数据,不是设计决定 —— 夹住而不是让它炸开版面。 */\nconst MIN_RATIO = 0.15\nconst MAX_RATIO = 14\n/** 以 --case-study-logo 为单位的硬上限,免得一个极端字标独占整条 logo 带。 */\nconst MAX_WIDTH = 2.6\nconst MAX_HEIGHT = 1.5\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))\nconst finite = (n: number, fallback: number) => (Number.isFinite(n) ? n : fallback)\n\n/**\n * 客户 logo 到得五花八门:方形 app mark 和 5:1 的字标混在一排。\n * 按固定**高度**排(最顺手的做法)等的是错的量:同高时字标覆盖的面积是方形标的\n * `ratio` 倍,看上去就更响。所以等的是**面积**:令 w/h = r,取 w = r^0.5、h = r^-0.5,\n * 面积恒为 1,与宽高比无关。上限只在极端比例时介入,且永远两轴同比缩放,不拉伸画稿。\n *\n * 返回值是 --case-study-logo 的倍数(不是像素),消费者只需要调那一个变量。\n * (同一套公式在 blocks/logo-cloud 里有可调 `balance` 的完整版;这里的卡片头部\n *  只需要「等面积」这一档,20 行内联比拖进整个 logo 墙依赖更省。)\n */\nexport function opticalLogoBox(logo: Pick<CaseStudyLogo, \"width\" | \"height\">): {\n  w: number\n  h: number\n} {\n  const width = Math.max(finite(logo.width, 1), Number.EPSILON)\n  const height = Math.max(finite(logo.height, 1), Number.EPSILON)\n  const ratio = clamp(width / height, MIN_RATIO, MAX_RATIO)\n\n  let w = Math.sqrt(ratio)\n  let h = 1 / Math.sqrt(ratio)\n  if (w > MAX_WIDTH) {\n    h *= MAX_WIDTH / w\n    w = MAX_WIDTH\n  }\n  if (h > MAX_HEIGHT) {\n    w *= MAX_HEIGHT / h\n    h = MAX_HEIGHT\n  }\n  return { w, h }\n}\n\nconst logoStyle = (logo: CaseStudyLogo): React.CSSProperties => {\n  const { h, w } = opticalLogoBox(logo)\n  return {\n    height: `calc(var(--case-study-logo) * ${h.toFixed(4)})`,\n    width: `calc(var(--case-study-logo) * ${w.toFixed(4)})`,\n  }\n}\n\n/** 首字母降级:拉丁名取前两词首字母,不用空格分词的名字(CJK 等)取前两个字。 */\nfunction initialsOf(company: string) {\n  const words = company.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  if (words.length > 1) {\n    return words\n      .slice(0, 2)\n      .map(word => Array.from(word)[0] ?? \"\")\n      .join(\"\")\n      .toUpperCase()\n  }\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 CompanyLogo({ item }: { item: CaseStudyGridItem }) {\n  const [failed, setFailed] = React.useState(false)\n  const logo = item.logo\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  if (!logo || failed) {\n    // 没有画稿(或画稿加载失败)时的降级:首字母方块。名字就在下一行,\n    // 读屏再念一遍首字母只是重复,所以整块 aria-hidden。\n    return (\n      <span\n        aria-hidden=\"true\"\n        className=\"flex h-[calc(var(--case-study-logo)*1.25)] w-[calc(var(--case-study-logo)*1.25)] items-center justify-center rounded-md border bg-muted text-xs font-semibold text-muted-foreground\"\n      >\n        {initialsOf(item.company)}\n      </span>\n    )\n  }\n\n  const style = logoStyle(logo)\n  const classes = \"max-w-full object-contain\"\n  return (\n    <>\n      {/*\n        alt=\"\":公司名就在 logo 正下方作为文本渲染,给 logo 再配一份 alt 只会让读屏\n        把同一个名字念两遍。图挂了也不丢信息 —— 名字还在,而且会降级成首字母方块。\n        (两张图里永远只有一张 display:block,另一张对读屏不存在。)\n      */}\n      {/* eslint-disable-next-line @next/next/no-img-element -- 可移植 registry 源码,logo 由消费者托管 */}\n      <img\n        alt=\"\"\n        className={cn(classes, logo.darkSrc && \"dark:hidden\")}\n        decoding=\"async\"\n        height={logo.height}\n        loading=\"lazy\"\n        onError={() => setFailed(true)}\n        ref={probe}\n        src={logo.src}\n        style={style}\n        width={logo.width}\n      />\n      {logo.darkSrc && (\n        // eslint-disable-next-line @next/next/no-img-element -- 同上\n        <img\n          alt=\"\"\n          className={cn(classes, \"hidden dark:block\")}\n          decoding=\"async\"\n          height={logo.height}\n          loading=\"lazy\"\n          src={logo.darkSrc}\n          style={style}\n          width={logo.width}\n        />\n      )}\n    </>\n  )\n}\n\n/* ------------------------------------------------------------------ *\n * 指标\n * ------------------------------------------------------------------ */\n\n/**\n * `flat` **不配图标**,而不是配一个横杠:实测截图里 `Minus` 紧挨着数字时读成了\n * 「−72」—— 一个绝对水平值(NPS 72)看起来成了负数。占位方块保住同一条数字左边缘。\n */\nconst DIRECTION_ICON = { down: TrendingDown, flat: null, up: TrendingUp }\n/** U+2212 真减号,不是连字符:配 tabular-nums 才和 + 等宽。 */\nconst DIRECTION_SIGN = { down: \"−\", flat: \"\", up: \"+\" }\n\nfunction formatMetric(metric: CaseStudyMetric, formatter: Intl.NumberFormat) {\n  return `${DIRECTION_SIGN[metric.direction]}${metric.prefix ?? \"\"}${formatter.format(\n    metric.value,\n  )}${metric.unit ?? \"\"}`\n}\n\n/** 没有基线时组件自己说出口 —— 这句话是固定的,数据层没有机会把它改成一句像基线的话。 */\nconst NO_BASELINE = \"No baseline captured\"\n\nfunction MetricCell({\n  formatter,\n  metric,\n}: {\n  formatter: Intl.NumberFormat\n  metric: CaseStudyMetric\n}) {\n  const Icon = DIRECTION_ICON[metric.direction]\n  return (\n    <div className=\"flex flex-col gap-0.5\">\n      {/*\n        dt 在 dd 之前是 <dl> 的内容模型要求,同时也是更好的朗读顺序\n        (「On-time delivery,+38%,vs. …」)。视觉上标签在小字上方、数字在下方,\n        DOM / 视觉 / 朗读三者同序,不需要 order-* 把它们拧开。\n      */}\n      <dt className=\"text-xs font-medium wrap-anywhere text-muted-foreground\">{metric.label}</dt>\n      <dd className=\"flex flex-col gap-0.5\">\n        <span className=\"flex items-center gap-1 text-2xl leading-tight font-semibold tabular-nums tracking-tight\">\n          {/* 方向已经由 +/− 号写在文本里了,箭头只是重复的装饰 —— 不做唯一识别通道 */}\n          {Icon ? (\n            <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-primary\" />\n          ) : (\n            <span aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n          )}\n          <span className=\"wrap-anywhere\">{formatMetric(metric, formatter)}</span>\n        </span>\n        <span className=\"text-xs wrap-anywhere text-muted-foreground\">\n          {metric.baseline.kind === \"comparison\" ? metric.baseline.label : NO_BASELINE}\n        </span>\n      </dd>\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ *\n * 卡片\n * ------------------------------------------------------------------ */\n\n/**\n * `wrap-anywhere`(= overflow-wrap: anywhere)是承重的,`min-w-0` 不是 —— 两者分别拆掉验证过:\n * · 去掉全部 8 处 wrap-anywhere:375px 下真实 stress 数据(引语里一条 74 字符的 URL)\n *   让 blockquote 溢出自己的盒子 19px;80 字符德语复合词的极端数据把文档撑出\n *   299px 真实横向滚动、16 个元素溢出各自的盒子(最多 354px)。\n * · 在此基础上再补 min-w-0:上面每一个数字**一字不差**,它救不回来任何东西。\n * · 出货版再补 min-w-0:同样一字不差(零溢出),它也没有多防住什么。\n * 卡片是纵向堆叠,横向 flex 只有 logo 带和数值行(内容都短),`min-w-0` 在这个结构里\n * 没有作用点;真正压得住无空格长串的是 wrap-anywhere 把 min-content 降到一个字符。\n * (`break-words` 不行:它不降低 min-content。)\n */\nconst BADGE =\n  \"inline-flex w-fit items-center rounded-full border px-2.5 py-0.5 text-xs font-medium wrap-anywhere text-muted-foreground\"\n\n/**\n * auto-fill(不是 auto-fit):空轨道不塌陷,于是只有 1 条指标的卡片,它的指标格\n * 宽度和 3 条指标那张卡的第一格**一样宽**,同排卡片的数字左边缘对齐。\n * auto-fit 会把那一条拉成整行宽,同一排里就变成「一个巨号数字 vs 三个小号数字」。\n *\n * 7rem 这个下限配合下面 22rem 的卡片列宽:实测 1440 / 1024 / 900 / 768 四种容器宽度下,\n * 3 条指标都排得进一行(卡片内容宽 310~680px,每格 ≥112px)。真排不下时它会换行,\n * 卡片高度随之被同排最高的那张拉齐 —— 富余高度由卡片的 justify-between 摊到各个\n * 间隙里,而不是在短卡底部堆成一个洞(见 CaseCard)。\n */\nconst METRIC_GRID = \"grid grid-cols-[repeat(auto-fill,minmax(min(7rem,100%),1fr))] gap-x-4 gap-y-3\"\n\n/**\n * 列宽由容器决定而不是视口断点:同一个块塞进正文栏、侧栏或整页都自己换行。\n * auto-fill 保证筛剩一张卡时它不会被拉成一整行宽。\n */\nconst GRID = \"grid items-stretch gap-5 grid-cols-[repeat(auto-fill,minmax(min(22rem,100%),1fr))]\"\n\nfunction CaseCard({\n  formatter,\n  headingTag: Heading,\n  item,\n}: {\n  formatter: Intl.NumberFormat\n  headingTag: \"h2\" | \"h3\"\n  item: CaseStudyGridItem\n}) {\n  const attribution = item.quote?.attribution\n\n  return (\n    <li\n      className={cn(\n        // relative:「阅读全文」那个 anchor 的 ::after 以这张卡为定位参照\n        \"group relative flex h-full flex-col gap-4 rounded-xl border bg-card p-5 text-card-foreground\",\n        // justify-between 而不是给页脚 mt-auto:同排卡片被拉到等高后,富余高度会被\n        // **平摊到每个间隙**,而不是全部堆在页脚上方变成一个洞。对照实验(同一脚本跑\n        // 两份 bundle,1440px / 三列)的最大内部间隙 —— mt-auto 版 → 本版:\n        // 真实数据 116→49px;指标带换行的最坏情况(375→1600 每 25px 扫一遍)126→44px;\n        // 极端对照(近乎空的卡挨着满内容卡)305→112px。单列(375 / 768)不拉伸,两版都是 16px。\n        // auto margin 会吸掉全部富余空间,所以它和 justify-between 互斥,不能同时写。\n        \"justify-between\",\n        \"transition-shadow hover:shadow-md motion-reduce:transition-none\",\n      )}\n    >\n      <div className=\"flex flex-col gap-3\">\n        {/*\n          logo 带的高度写死成 MAX_HEIGHT × 单位尺寸:不同宽高比的画稿撑不出不同的\n          行高,于是所有卡片的公司名起始位置一致。logo 自己在这条带里垂直居中。\n        */}\n        <div className=\"flex h-[calc(var(--case-study-logo)*1.5)] items-center\">\n          <CompanyLogo item={item} />\n        </div>\n        <div className=\"flex flex-col gap-1.5\">\n          <p className=\"text-sm font-semibold wrap-anywhere\">{item.company}</p>\n          <div className=\"flex flex-wrap gap-1.5\">\n            <span className={BADGE}>{item.industry}</span>\n            <span className={BADGE}>{SIZE_LABELS[item.size]}</span>\n          </div>\n        </div>\n      </div>\n\n      <Heading className=\"text-base font-semibold tracking-tight text-pretty wrap-anywhere\">\n        {item.outcome}\n      </Heading>\n\n      {/* 指标是一等公民:自己的分隔带,不混进正文 */}\n      <dl className={cn(METRIC_GRID, \"border-y py-3\")}>\n        {item.metrics.map(metric => (\n          <MetricCell formatter={formatter} key={metric.id} metric={metric} />\n        ))}\n      </dl>\n\n      {item.quote && (\n        <figure\n          className={cn(\n            \"flex flex-col gap-1.5 border-s-2 ps-3\",\n            // 有名有姓的引语才拿强调色;没人认领的那句退回中性边框,视觉分量自己降下来\n            attribution ? \"border-primary\" : \"border-border\",\n          )}\n        >\n          <blockquote className=\"text-sm leading-relaxed wrap-anywhere text-muted-foreground\">\n            {\"“\"}\n            {item.quote.text}\n            {\"”\"}\n          </blockquote>\n          <figcaption className=\"text-xs wrap-anywhere\">\n            {attribution ? (\n              <>\n                <span className=\"font-medium\">{attribution.name}</span>\n                <span className=\"text-muted-foreground\">\n                  {\", \"}\n                  {attribution.title}, {item.company}\n                </span>\n              </>\n            ) : (\n              // 明确降级:与其伪造一个署名,不如把「没有署名」这件事写在脸上\n              <span className=\"text-muted-foreground\">Unattributed — no named source</span>\n            )}\n          </figcaption>\n        </figure>\n      )}\n\n      {/* 卡片的 justify-between 把这一行钉在底部,同排页脚基线一致 */}\n      <div className=\"pt-1\">\n        <a\n          className={cn(\n            \"inline-flex items-center gap-1.5 rounded-sm text-sm font-medium text-primary\",\n            \"after:absolute after:inset-0 after:content-['']\",\n            \"hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n          )}\n          href={item.href}\n        >\n          Read the full story\n          {/* 读屏的链接列表里,六个「Read the full story」需要能区分开 */}\n          <span className=\"sr-only\"> about {item.company}</span>\n          <ArrowRight\n            aria-hidden=\"true\"\n            className=\"size-4 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0\"\n          />\n        </a>\n      </div>\n    </li>\n  )\n}\n\n/* ------------------------------------------------------------------ *\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\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\nconst CONTROL =\n  \"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\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\n/** 骨架卡的指标条数刻意不齐(1/3/2),预演真实数据的参差 —— 也不是数据条数上限。 */\nconst SKELETON_METRICS = [1, 3, 2]\n\nfunction Skeleton() {\n  return (\n    <>\n      <span className=\"sr-only\" role=\"status\">\n        Loading case studies\n      </span>\n      <div aria-hidden=\"true\" className=\"flex flex-col gap-6\">\n        <div className=\"flex flex-col gap-3\">\n          <div className={cn(\"h-4 w-48\", pulse)} />\n          <div className=\"flex flex-wrap gap-2\">\n            {[\"w-16\", \"w-24\", \"w-20\", \"w-28\"].map(width => (\n              <div className={cn(\"h-7 rounded-full\", pulse, width)} key={width} />\n            ))}\n          </div>\n        </div>\n        <div className={GRID}>\n          {SKELETON_METRICS.map((metrics, i) => (\n            <div className=\"flex flex-col justify-between gap-4 rounded-xl border bg-card p-5\" key={i}>\n              <div className=\"flex flex-col gap-3\">\n                <div className=\"flex h-[calc(var(--case-study-logo)*1.5)] items-center\">\n                  <div className={cn(\"h-[calc(var(--case-study-logo)*0.9)] w-24\", pulse)} />\n                </div>\n                <div className=\"flex flex-col gap-1.5\">\n                  <div className={cn(\"h-4 w-32\", pulse)} />\n                  <div className=\"flex gap-1.5\">\n                    <div className={cn(\"h-5 w-20 rounded-full\", pulse)} />\n                    <div className={cn(\"h-5 w-24 rounded-full\", pulse)} />\n                  </div>\n                </div>\n              </div>\n              <div className={cn(\"h-5 w-11/12\", pulse)} />\n              <div className={cn(METRIC_GRID, \"border-y py-3\")}>\n                {Array.from({ length: metrics }, (_, j) => (\n                  <div className=\"flex flex-col gap-1.5\" key={j}>\n                    <div className={cn(\"h-3 w-16\", pulse)} />\n                    <div className={cn(\"h-6 w-14\", pulse)} />\n                    <div className={cn(\"h-3 w-20\", pulse)} />\n                  </div>\n                ))}\n              </div>\n              <div className={cn(\"h-4 w-full\", pulse)} />\n              <div className={cn(\"h-4 w-32\", pulse)} />\n            </div>\n          ))}\n        </div>\n      </div>\n    </>\n  )\n}\n\n/* ------------------------------------------------------------------ *\n * 主组件\n * ------------------------------------------------------------------ */\n\nconst matchesIndustry = (item: CaseStudyGridItem, value: string | null) =>\n  value === null || item.industry.trim() === value\n\nconst matchesSize = (item: CaseStudyGridItem, value: CaseStudySize | null) =>\n  value === null || item.size === value\n\nexport interface CaseStudyGridProps extends CaseStudyGridData {\n  /** status === \"error\" 时渲染 Try again;不传就只显示错误面板,不留死按钮。 */\n  onRetry?: () => void\n  /** Intl 的 BCP-47 locale。显式传,避免服务端 / 客户端各按各的默认值格式化数字。 */\n  locale?: string\n  className?: string\n}\n\nexport function CaseStudyGrid({\n  className,\n  heading,\n  items,\n  locale = \"en-US\",\n  onRetry,\n  status,\n  subheading,\n}: CaseStudyGridProps) {\n  const baseId = React.useId()\n  const [industry, setIndustry] = React.useState<string | null>(null)\n  const [size, setSize] = React.useState<CaseStudySize | null>(null)\n\n  const formatter = React.useMemo(\n    () => new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }),\n    [locale],\n  )\n\n  /** 行业 chip 按首次出现顺序从数据推导,不另立清单 —— 于是不存在「点了没有结果」的死 chip。 */\n  const industries = React.useMemo(() => {\n    const seen = new Set<string>()\n    const order: string[] = []\n    for (const item of items) {\n      const label = item.industry.trim()\n      if (label && !seen.has(label)) {\n        seen.add(label)\n        order.push(label)\n      }\n    }\n    return order\n  }, [items])\n\n  /** 规模 chip 同样只列出现过的,但顺序走闭集的小→大。 */\n  const sizes = React.useMemo(() => {\n    const present = new Set(items.map(item => item.size))\n    return SIZE_ORDER.filter(value => present.has(value))\n  }, [items])\n\n  // 数据换了一批、当前选项不复存在时自动退回 All,否则筛选会卡在一个选不掉的空集上\n  const activeIndustry = industry && industries.includes(industry) ? industry : null\n  const activeSize = size && sizes.includes(size) ? size : null\n\n  /**\n   * 每个 chip 的计数都是在**另一个** facet 的当前选择之上算的,所以 chip 上的数字\n   * 就是点下去会看到的条数 —— 而不是一个只在没筛过时才成立的总数。\n   */\n  const industryCounts = React.useMemo(() => {\n    const map = new Map<string, number>()\n    for (const item of items) {\n      if (!matchesSize(item, activeSize)) continue\n      const label = item.industry.trim()\n      map.set(label, (map.get(label) ?? 0) + 1)\n    }\n    return map\n  }, [activeSize, items])\n\n  const sizeCounts = React.useMemo(() => {\n    const map = new Map<CaseStudySize, number>()\n    for (const item of items) {\n      if (!matchesIndustry(item, activeIndustry)) continue\n      map.set(item.size, (map.get(item.size) ?? 0) + 1)\n    }\n    return map\n  }, [activeIndustry, items])\n\n  const industryAllCount = React.useMemo(\n    () => items.filter(item => matchesSize(item, activeSize)).length,\n    [activeSize, items],\n  )\n  const sizeAllCount = React.useMemo(\n    () => items.filter(item => matchesIndustry(item, activeIndustry)).length,\n    [activeIndustry, items],\n  )\n\n  const visible = React.useMemo(\n    () =>\n      items.filter(item => matchesIndustry(item, activeIndustry) && matchesSize(item, activeSize)),\n    [activeIndustry, activeSize, items],\n  )\n\n  const filtering = activeIndustry !== null || activeSize !== null\n\n  const clearFilters = () => {\n    setIndustry(null)\n    setSize(null)\n  }\n\n  // 没有 section 标题时,卡片标题就是本块最高一级,别跳级\n  const headingTag = heading ? \"h3\" : \"h2\"\n\n  const chip = (active: boolean) =>\n    cn(CHIP, active ? \"border-primary bg-primary text-primary-foreground hover:bg-primary/90\" : \"hover:bg-muted\")\n\n  return (\n    <section\n      // 标题只在 ready 分支渲染,所以别的状态下不能挂 aria-labelledby —— 指向一个不存在的 id\n      // 会让这个 section 既没名字也不是地标\n      aria-labelledby={status === \"ready\" && heading ? `${baseId}-heading` : undefined}\n      className={cn(\n        \"flex w-full flex-col gap-6 [--case-study-logo:2rem]\",\n        className,\n      )}\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\" && <Skeleton />}\n\n      {status === \"empty\" && (\n        <StatusPanel\n          detail=\"Customer stories appear here as soon as the first one is published.\"\n          title=\"No case studies yet\"\n        />\n      )}\n\n      {status === \"error\" && (\n        <StatusPanel\n          action={\n            onRetry && (\n              <button className={CONTROL} onClick={onRetry} type=\"button\">\n                Try again\n              </button>\n            )\n          }\n          detail=\"The content source didn't respond, so we can't show the customer stories.\"\n          title=\"Couldn't load the case studies\"\n        />\n      )}\n\n      {status === \"ready\" && items.length > 0 && (\n        <>\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"flex flex-wrap items-center justify-between gap-3\">\n              <p className=\"text-sm text-muted-foreground tabular-nums\" role=\"status\">\n                Showing {visible.length} of {items.length} case{\" \"}\n                {items.length === 1 ? \"study\" : \"studies\"}\n              </p>\n              {filtering && (\n                <button className={CONTROL} onClick={clearFilters} type=\"button\">\n                  Clear filters\n                </button>\n              )}\n            </div>\n\n            {industries.length > 1 && (\n              <div\n                aria-label=\"Filter by industry\"\n                className=\"flex flex-wrap items-center gap-2\"\n                role=\"group\"\n              >\n                <span className=\"text-xs font-medium text-muted-foreground\">Industry</span>\n                <button\n                  aria-pressed={activeIndustry === null}\n                  className={chip(activeIndustry === null)}\n                  onClick={() => setIndustry(null)}\n                  type=\"button\"\n                >\n                  All\n                  <span className=\"text-xs tabular-nums opacity-70\">{industryAllCount}</span>\n                </button>\n                {industries.map(label => {\n                  const active = activeIndustry === label\n                  return (\n                    <button\n                      aria-pressed={active}\n                      className={chip(active)}\n                      key={label}\n                      onClick={() => setIndustry(active ? null : label)}\n                      type=\"button\"\n                    >\n                      {label}\n                      <span className=\"text-xs tabular-nums opacity-70\">\n                        {industryCounts.get(label) ?? 0}\n                      </span>\n                    </button>\n                  )\n                })}\n              </div>\n            )}\n\n            {sizes.length > 1 && (\n              <div\n                aria-label=\"Filter by company size\"\n                className=\"flex flex-wrap items-center gap-2\"\n                role=\"group\"\n              >\n                <span className=\"text-xs font-medium text-muted-foreground\">Company size</span>\n                <button\n                  aria-pressed={activeSize === null}\n                  className={chip(activeSize === null)}\n                  onClick={() => setSize(null)}\n                  type=\"button\"\n                >\n                  All\n                  <span className=\"text-xs tabular-nums opacity-70\">{sizeAllCount}</span>\n                </button>\n                {sizes.map(value => {\n                  const active = activeSize === value\n                  return (\n                    <button\n                      aria-pressed={active}\n                      className={chip(active)}\n                      key={value}\n                      onClick={() => setSize(active ? null : value)}\n                      type=\"button\"\n                    >\n                      {SIZE_LABELS[value]}\n                      <span className=\"text-xs tabular-nums opacity-70\">\n                        {sizeCounts.get(value) ?? 0}\n                      </span>\n                    </button>\n                  )\n                })}\n              </div>\n            )}\n          </div>\n\n          {visible.length > 0 ? (\n            <ul className={GRID}>\n              {visible.map(item => (\n                <CaseCard formatter={formatter} headingTag={headingTag} item={item} key={item.id} />\n              ))}\n            </ul>\n          ) : (\n            // 「筛没了」和「一条都还没发」是两件事:文案不同,出路也不同\n            <StatusPanel\n              action={\n                <button className={CONTROL} onClick={clearFilters} type=\"button\">\n                  Clear filters\n                </button>\n              }\n              className=\"border-dashed\"\n              detail={\n                <>\n                  No published story is{activeIndustry ? ` in ${activeIndustry}` : \"\"}\n                  {activeSize ? ` from a company with ${SIZE_LABELS[activeSize]}` : \"\"}. Clear the\n                  filters to see all {items.length} case {items.length === 1 ? \"study\" : \"studies\"}.\n                </>\n              }\n              title=\"No case studies match these filters\"\n            />\n          )}\n        </>\n      )}\n\n      {/* ready 但一条数据都没有:等同于空态,别渲染一排只有 “All 0” 的筛选器 */}\n      {status === \"ready\" && items.length === 0 && (\n        <StatusPanel\n          detail=\"Customer stories appear here as soon as the first one is published.\"\n          title=\"No case studies yet\"\n        />\n      )}\n    </section>\n  )\n}\n\nexport default CaseStudyGrid\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/case-study-grid.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * 整卡链接必须指向真实的案例全文页。空串和 \"#\" 这类死锚点在数据层就拒掉:\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 case-study page — dead anchors like \"#\" are rejected',\n  })\n\n/**\n * 基线是**必填**的。一个没有参照物的「+38%」是无意义的营销数字 ——\n * 「相对什么」和数值本身一样重要,所以它没有 optional 这个选项。\n *\n * 真的没量到基线时,唯一合法的写法是 `{ kind: \"none\" }`,组件会显式渲染\n * “No baseline captured”。这一分支**故意不带任何自由文本**:一旦允许写字,\n * 它立刻会被填成一句听起来像基线的话,而那正是这个字段要防的事。\n */\nexport const caseStudyBaselineSchema = z.discriminatedUnion(\"kind\", [\n  z.object({\n    kind: z.literal(\"comparison\"),\n    /** 完整的对照短语,原样渲染,例:\"vs. the 90 days before rollout\" */\n    label: z.string().min(1),\n  }),\n  z.object({ kind: z.literal(\"none\") }),\n])\n\nexport const caseStudyMetricSchema = z.object({\n  id: z.string(),\n  /** 指标名,例 \"On-time delivery\" */\n  label: z.string().min(1),\n  /**\n   * 量值的**大小**,非负。正负号由 `direction` 渲染 —— 在数据里再写一次负号\n   * 会得到 \"−-62%\",而且两处符号可以互相矛盾。\n   */\n  value: z.number().nonnegative(),\n  /**\n   * 紧贴数字渲染的单位,原样输出:`\"%\"` / `\"×\"` / `\"M\"` 直接贴上去。\n   * 词单位自己带前导不换行空格(`\" pts\"`),因为「贴紧还是分开」只有你知道:\n   * `$1.4M` 要贴,`18 pts` 要分开,两者的单位都是字母。\n   */\n  unit: z.string().optional(),\n  /** 数字前缀,例 `\"$\"`。渲染顺序是 符号 → 前缀 → 数字 → 单位(−$4.2M)。 */\n  prefix: z.string().optional(),\n  /**\n   * 数字往哪儿走:`up` / `down` 渲染 +/− 号与箭头;\n   * `flat` 表示这是一个绝对水平值(NPS 72、上线国家数),不带符号也不带箭头。\n   */\n  direction: z.enum([\"up\", \"down\", \"flat\"]),\n  baseline: caseStudyBaselineSchema,\n})\n\nexport const caseStudyQuoteSchema = z.object({\n  text: z.string().min(1),\n  /**\n   * 归属是全有或全无:姓名和职位必须同时给。半截归属(只有名字、或只有职位)\n   * 在营销上和没有归属一样弱,却看起来像真的。\n   * 公司名不在这里重复 —— 组件从卡片自己的 `company` 取,天然一致。\n   *\n   * 省略它不是「随便一点」的选项:组件会撤掉引语的强调样式,并显式写出\n   * “Unattributed — no named source”,让一句没人认领的话看起来就是没人认领。\n   */\n  attribution: z\n    .object({ name: z.string().min(1), title: z.string().min(1) })\n    .optional(),\n})\n\n/** 公司规模是闭集,于是筛选 chip 的顺序永远是小→大,而不是数据碰巧的出现顺序。 */\nexport const caseStudySizeSchema = z.enum([\"startup\", \"mid-market\", \"enterprise\"])\n\nexport const caseStudyLogoSchema = z.object({\n  /** 客户 logo 图源(SVG 最佳)。缺省时组件降级成公司首字母方块,不留破图。 */\n  src: z.string().min(1),\n  /** 暗色表面用的图源;给了就用纯 CSS 在 `.dark` 下换图。 */\n  darkSrc: z.string().optional(),\n  /**\n   * 画稿的固有宽高(px 或 SVG viewBox 数值均可)。**永不直接当渲染尺寸用** ——\n   * 它只提供宽高比,喂给组件的等面积光学对齐公式。\n   */\n  width: z.number().positive(),\n  height: z.number().positive(),\n})\n\nexport const caseStudyGridItemSchema = z.object({\n  id: z.string(),\n  /** 客户公司名(也是 logo 的 alt 与「阅读全文」的无障碍名字)。 */\n  company: z.string().min(1),\n  logo: caseStudyLogoSchema.optional(),\n  /** 行业标签,自由文本;筛选 chip 按数据里的首次出现顺序推导。 */\n  industry: z.string().min(1),\n  size: caseStudySizeSchema,\n  /** 一句话成果(卡片标题),例:\"Cut checkout drop-off in half in one quarter\"。 */\n  outcome: z.string().min(1),\n  /**\n   * 量化成果 —— 这个 block 的主角,所以**至少一条**。\n   * 一条没有数字的客户故事属于 testimonials / blog-card-grid,不属于这里。\n   * 条数不设上限,各卡条数可以不同(1 条和 3 条同排也对齐)。\n   */\n  metrics: z.array(caseStudyMetricSchema).min(1),\n  quote: caseStudyQuoteSchema.optional(),\n  /** 案例全文页地址。 */\n  href: hrefSchema,\n})\n\nexport const caseStudyGridSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  heading: z.string().optional(),\n  subheading: z.string().optional(),\n  items: z.array(caseStudyGridItemSchema),\n})\n\nexport type CaseStudyBaseline = z.infer<typeof caseStudyBaselineSchema>\nexport type CaseStudyMetric = z.infer<typeof caseStudyMetricSchema>\nexport type CaseStudyQuote = z.infer<typeof caseStudyQuoteSchema>\nexport type CaseStudySize = z.infer<typeof caseStudySizeSchema>\nexport type CaseStudyLogo = z.infer<typeof caseStudyLogoSchema>\nexport type CaseStudyGridItem = z.infer<typeof caseStudyGridItemSchema>\nexport type CaseStudyGridData = z.infer<typeof caseStudyGridSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:block"
}