{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feature-grid",
  "title": "Feature Grid",
  "description": "A four-state feature section: equal-weight icon cards with optional real \"learn more\" links, and no cap on how many render.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/feature-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Sparkles } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type { FeatureGridData, FeatureGridItem } from \"./feature-grid.contract\"\n\ntype IconComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>\n\n/**\n * Column count → static Tailwind classes. Must be literal strings: Tailwind\n * scans source text, so `lg:grid-cols-${n}` would compile to nothing.\n * Unknown values fall back to 3 rather than rendering an unstyled column.\n */\nconst COLUMN_CLASS: Record<number, string> = {\n  1: \"grid-cols-1\",\n  2: \"grid-cols-1 sm:grid-cols-2\",\n  3: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\",\n  4: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\",\n}\n\n/** Rendered when an item has no icon key, or the key isn't in the `icons` map. */\nconst FallbackIcon: IconComponent = Sparkles\n\nexport interface FeatureGridProps extends FeatureGridData {\n  /**\n   * Maps `item.icon` keys to icon components (lucide or your own SVG wrappers).\n   * Kept out of the data contract so the data stays JSON-serialisable, and out\n   * of the component so you only bundle the icons you actually use.\n   */\n  icons?: Record<string, IconComponent>\n  /** Columns at the widest breakpoint (1–4, default 3). One column on phones regardless. */\n  columns?: 1 | 2 | 3 | 4\n  onRetry?: () => void\n  className?: string\n}\n\nfunction FeatureCard({ icons, item }: { icons: Record<string, IconComponent>; item: FeatureGridItem }) {\n  const Icon = (item.icon ? icons[item.icon] : undefined) ?? FallbackIcon\n\n  return (\n    <li className=\"flex h-full flex-col items-start gap-3 rounded-xl border bg-card p-6 text-card-foreground\">\n      <span\n        aria-hidden=\"true\"\n        className=\"flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary\"\n      >\n        <Icon className=\"size-5\" />\n      </span>\n\n      <h3 className=\"text-base font-semibold text-balance\">{item.title}</h3>\n      <p className=\"text-sm text-muted-foreground\">{item.description}</p>\n\n      {item.link && (\n        <a\n          className={cn(\n            \"mt-auto inline-flex items-center gap-1 rounded-sm pt-1 text-sm font-medium text-primary\",\n            \"hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          )}\n          href={item.link.href}\n        >\n          {item.link.label}\n          <ArrowRight aria-hidden=\"true\" className=\"size-4\" />\n        </a>\n      )}\n    </li>\n  )\n}\n\nexport function FeatureGrid({\n  status,\n  eyebrow,\n  heading,\n  description,\n  items,\n  icons,\n  columns = 3,\n  onRetry,\n  className,\n}: FeatureGridProps) {\n  const headingId = React.useId()\n  const iconMap = icons ?? {}\n  // Clamp first, then derive both the grid class and the skeleton count from the\n  // same number — a junk value (0, NaN, 9) can't produce an unstyled grid or an\n  // absurd skeleton row.\n  const cols = COLUMN_CLASS[columns] ? columns : 3\n  const gridClass = COLUMN_CLASS[cols]\n\n  return (\n    <section\n      aria-labelledby={status === \"ready\" && heading ? headingId : undefined}\n      className={cn(\"flex w-full flex-col gap-10\", className)}\n    >\n      {status === \"loading\" && (\n        <div aria-hidden=\"true\" className=\"flex flex-col gap-10\">\n          <div className=\"mx-auto flex w-full max-w-2xl flex-col items-center gap-3\">\n            <div className=\"h-8 w-2/3 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n            <div className=\"h-4 w-full animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n          </div>\n          <div className={cn(\"grid gap-4\", gridClass)}>\n            {Array.from({ length: cols }, (_, i) => (\n              <div className=\"flex flex-col gap-3 rounded-xl border bg-card p-6\" key={i}>\n                <div className=\"size-10 animate-pulse rounded-lg bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-4 w-1/2 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-3 w-full animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                <div className=\"h-3 w-4/5 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n              </div>\n            ))}\n          </div>\n        </div>\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 features to show</p>\n          <p className=\"text-sm text-muted-foreground\">Features appear here once they are published.</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 features</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:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={onRetry}\n              type=\"button\"\n            >\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {status === \"ready\" && (\n        <>\n          {(eyebrow || heading || description) && (\n            <div className=\"mx-auto flex w-full max-w-2xl flex-col gap-3 text-center\">\n              {eyebrow && <p className=\"text-sm font-medium text-primary\">{eyebrow}</p>}\n              {heading && (\n                <h2 className=\"text-3xl font-semibold tracking-tight text-balance\" id={headingId}>\n                  {heading}\n                </h2>\n              )}\n              {description && <p className=\"text-muted-foreground text-pretty\">{description}</p>}\n            </div>\n          )}\n\n          {/* Every item renders: no cap, no fixed card height, no overflow clip. */}\n          <ul className={cn(\"grid gap-4\", gridClass)} role=\"list\">\n            {items.map(item => (\n              <FeatureCard icons={iconMap} item={item} key={item.id} />\n            ))}\n          </ul>\n        </>\n      )}\n    </section>\n  )\n}\n\nexport default FeatureGrid\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/feature-grid.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Contract for a marketing \"what this product does\" feature section.\n *\n * One item = one selling point of equal weight (icon + title + copy + an\n * optional \"learn more\" destination). This file is the single source of truth:\n * the mock factory and the demo build against it, the component only imports\n * the inferred types. Everything here is JSON-serialisable on purpose — the\n * copy usually arrives from a CMS, so icons are referenced by *key*, never as\n * React elements.\n */\n\nexport const featureGridItemSchema = z.object({\n  /** Stable id — the React key. */\n  id: z.string(),\n  title: z.string(),\n  /** One or two sentences. No length cap: the card grows, it never truncates. */\n  description: z.string(),\n  /**\n   * Key into the `icons` map handed to the component (e.g. \"shield\").\n   * Unknown or missing keys render a neutral fallback glyph so the card\n   * anatomy stays identical instead of collapsing.\n   */\n  icon: z.string().optional(),\n  /**\n   * Optional \"learn more\" affordance. Omit it and no link is rendered — the\n   * card is then plain text with no click affordance at all.\n   * `href` must be a real destination in the host app (never \"#\").\n   */\n  link: z\n    .object({\n      label: z.string(),\n      href: z.string(),\n    })\n    .optional(),\n})\n\nexport const featureGridSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  /** Small label above the heading, e.g. \"Why teams switch\". */\n  eyebrow: z.string().optional(),\n  /** Section heading; when present it labels the section landmark. */\n  heading: z.string().optional(),\n  /** Supporting paragraph under the heading. */\n  description: z.string().optional(),\n  /** Every item renders — the grid wraps, there is no maximum. */\n  items: z.array(featureGridItemSchema),\n})\n\nexport type FeatureGridItem = z.infer<typeof featureGridItemSchema>\nexport type FeatureGridData = z.infer<typeof featureGridSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}