{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blog-card-grid",
  "title": "Blog Card Grid",
  "description": "A four-state article grid: one stretched link per card (category and author stay independently clickable), a featured post that spans two columns, and deliberate, configurable line clamping so mismatched title lengths still line up.",
  "dependencies": [
    "zod",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/blog-card-grid.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ImageOff } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type { BlogAuthor, BlogCardGridData, BlogCardGridItem } from \"./blog-card-grid.contract\"\n\n/**\n * The column count is breakpoint-driven because \"the featured post takes double width\" needs a\n * known count: at 1 column it never spans (a span would push out an implicit column and overflow\n * horizontally at 375px), at 2 it fills the row, at 3 it takes 2/3 — every step stays in the container.\n */\nconst GRID = \"grid gap-6 sm:grid-cols-2 lg:grid-cols-3\"\n\n/** The skeleton count only sets how dense loading looks, not a cap on rows — ready renders every item */\nconst SKELETON_CARDS = 6\n\nconst pulse = \"animate-pulse rounded bg-muted motion-reduce:animate-none\"\n\n/**\n * Clamping here is **deliberate and configurable**, not the accidental cut of a fixed height plus\n * `overflow:hidden`: `lines` is how many lines may show, and 0 (or a negative / NaN / Infinity\n * value) means no clamp — the whole thing renders. `reserve` then holds the box open to that same\n * line count, so a one-line title and a four-line title in the same row still leave the excerpt and\n * footer aligned across cards instead of drifting apart.\n *\n * Inline style rather than `line-clamp-N` because the count comes from a prop: Tailwind's JIT never\n * sees a class name assembled at runtime, so `line-clamp-[3]` silently vanishes and the clamp dies\n * with it.\n */\nfunction clampStyle(lines: number, reserve = false): React.CSSProperties | undefined {\n  const n = Math.floor(lines)\n  if (!Number.isFinite(n) || n <= 0) return undefined\n  return {\n    display: \"-webkit-box\",\n    WebkitBoxOrient: \"vertical\",\n    WebkitLineClamp: n,\n    overflow: \"hidden\",\n    ...(reserve ? { minHeight: `${n}lh` } : null),\n  }\n}\n\nconst firstChar = (word: string) => Array.from(word)[0] ?? \"\"\n\n/** Initials fallback: two word-initials for Latin names; the first two characters for names that are not space-separated (CJK and the like) */\nfunction initialsOf(author: BlogAuthor) {\n  if (author.initials) return author.initials\n  const words = author.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\n/** An invalid date string is neither swallowed nor thrown on: it prints as-is, and `dateTime` still carries exactly what the data said */\nfunction formatDate(formatter: Intl.DateTimeFormat, iso: string) {\n  const date = new Date(iso)\n  return Number.isNaN(date.getTime()) ? iso : formatter.format(date)\n}\n\nfunction CoverImage({ item, featured }: { item: BlogCardGridItem; featured: boolean }) {\n  const [failed, setFailed] = React.useState(false)\n  const showImage = Boolean(item.coverUrl) && !failed\n\n  /**\n   * On a prerendered page a cached image can already have failed before hydration attaches\n   * onError — the event never comes and the broken image stays. The ref callback probes once,\n   * synchronously.\n   */\n  const probe = React.useCallback((node: HTMLImageElement | null) => {\n    if (node?.complete && node.naturalWidth === 0) setFailed(true)\n  }, [])\n\n  return (\n    <div\n      className={cn(\n        \"relative shrink-0 overflow-hidden bg-muted\",\n        // fixed ratio: the placeholder is already at its final height, so the row does not jump when the image lands\n        \"aspect-video w-full\",\n        // in the wide layout it becomes a half-width image as tall as the text side; min-h stops a short excerpt from flattening it\n        featured && \"sm:aspect-auto sm:min-h-56 sm:w-1/2 sm:self-stretch\",\n      )}\n    >\n      {showImage ? (\n        // eslint-disable-next-line @next/next/no-img-element -- portable registry source, not bound to next/image\n        <img\n          alt={item.coverAlt ?? \"\"}\n          className={cn(\n            // Tailwind v4's scale-* writes the CSS `scale` property, so the transition list has to name it\n            \"size-full object-cover transition-[scale] duration-500 group-hover:scale-105\",\n            \"motion-reduce:transition-none motion-reduce:group-hover:scale-100\",\n          )}\n          decoding=\"async\"\n          loading=\"lazy\"\n          onError={() => setFailed(true)}\n          ref={probe}\n          src={item.coverUrl}\n        />\n      ) : (\n        // a neutral icon block instead of the browser's broken image: title, excerpt and author are all still there\n        <div className=\"flex size-full items-center justify-center\">\n          <ImageOff aria-hidden=\"true\" className=\"size-8 text-muted-foreground\" />\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction AuthorByline({ author }: { author: BlogAuthor }) {\n  const [failed, setFailed] = React.useState(false)\n  const showImage = Boolean(author.avatarUrl) && !failed\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 min-w-0 items-center gap-2\">\n      <span className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-[0.625rem] font-medium\">\n        {showImage ? (\n          // eslint-disable-next-line @next/next/no-img-element -- portable registry source, not bound to next/image\n          <img\n            alt=\"\"\n            className=\"size-full object-cover\"\n            decoding=\"async\"\n            loading=\"lazy\"\n            onError={() => setFailed(true)}\n            ref={probe}\n            src={author.avatarUrl}\n          />\n        ) : (\n          // the name is right beside it, so the initials are decoration — reading them out just repeats it\n          <span aria-hidden=\"true\">{initialsOf(author)}</span>\n        )}\n      </span>\n      {author.href ? (\n        // z-10 lifts the secondary link above the card-wide pseudo-element: the author name goes to the author, anywhere else goes to the post\n        <a\n          className={cn(\n            \"relative z-10 min-w-0 truncate font-medium text-foreground hover:underline\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n          )}\n          href={author.href}\n        >\n          {author.name}\n        </a>\n      ) : (\n        <span className=\"min-w-0 truncate font-medium text-foreground\">{author.name}</span>\n      )}\n    </span>\n  )\n}\n\nfunction PostCard({\n  item,\n  titleLines,\n  excerptLines,\n  dateFormatter,\n  headingTag: Heading,\n}: {\n  item: BlogCardGridItem\n  titleLines: number\n  excerptLines: number\n  dateFormatter: Intl.DateTimeFormat\n  headingTag: \"h2\" | \"h3\"\n}) {\n  const featured = item.featured === true\n  const minutes = item.readingMinutes === undefined ? undefined : Math.max(1, Math.round(item.readingMinutes))\n\n  return (\n    <li\n      className={cn(\n        // relative: the stretched link's pseudo-element positions against this card\n        \"group relative flex flex-col overflow-hidden rounded-xl border bg-card text-card-foreground\",\n        \"transition-shadow hover:shadow-md motion-reduce:transition-none\",\n        // double width only kicks in once there really are ≥2 columns\n        featured && \"sm:col-span-2 sm:flex-row\",\n      )}\n    >\n      <CoverImage featured={featured} item={item} />\n\n      <div className={cn(\"flex min-w-0 flex-1 flex-col gap-3 p-5\", featured && \"sm:p-6\")}>\n        {item.category &&\n          (item.category.href ? (\n            <a\n              className={cn(\n                \"relative z-10 w-fit rounded-full border px-2.5 py-0.5 text-xs font-medium\",\n                \"transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                \"motion-reduce:transition-none\",\n              )}\n              href={item.category.href}\n            >\n              {item.category.label}\n            </a>\n          ) : (\n            // no destination, no link affordance: a plain tag\n            <span className=\"w-fit rounded-full border px-2.5 py-0.5 text-xs font-medium text-muted-foreground\">\n              {item.category.label}\n            </span>\n          ))}\n\n        <Heading\n          className={cn(\"font-semibold tracking-tight\", featured ? \"text-xl\" : \"text-base\")}\n        >\n          {/*\n            The legal way to make a whole card clickable: exactly one \"primary\" link in the card,\n            with its ::after covering the card. Not an <a> wrapped around the card (that would turn\n            the category / author links into nested anchors — invalid HTML), and not a <button>\n            inside an <a>. The clamp sits on the inner span so the anchor itself carries no overflow.\n          */}\n          <a\n            className={cn(\n              \"rounded-sm 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            <span style={clampStyle(titleLines, true)}>{item.title}</span>\n          </a>\n        </Heading>\n\n        {item.excerpt && (\n          <p\n            className=\"text-sm leading-relaxed text-muted-foreground\"\n            style={clampStyle(excerptLines)}\n          >\n            {item.excerpt}\n          </p>\n        )}\n\n        {(item.author || item.publishedAt || minutes !== undefined) && (\n          // mt-auto: once the grid stretches the cards to equal height, this row stays pinned to the bottom\n          <div className=\"mt-auto flex flex-wrap items-center gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground\">\n            {item.author && <AuthorByline author={item.author} />}\n            {(item.publishedAt || minutes !== undefined) && (\n              <span className=\"flex items-center gap-1.5\">\n                {item.publishedAt && (\n                  // machines read dateTime (the raw ISO string), people read the formatted one\n                  <time dateTime={item.publishedAt}>{formatDate(dateFormatter, item.publishedAt)}</time>\n                )}\n                {item.publishedAt && minutes !== undefined && <span aria-hidden=\"true\">·</span>}\n                {minutes !== undefined && <span>{minutes} min read</span>}\n              </span>\n            )}\n          </div>\n        )}\n      </div>\n    </li>\n  )\n}\n\nexport interface BlogCardGridProps extends BlogCardGridData {\n  /** Title clamp, in lines; the same height is reserved so cards in a row align internally. 0 = no clamp */\n  titleLines?: number\n  /** Excerpt clamp, in lines. 0 = no clamp (card height is then set by the longest excerpt) */\n  excerptLines?: number\n  /** BCP-47 locale for Intl; pass it explicitly so server and client do not each fall back to their own default */\n  locale?: string\n  onRetry?: () => void\n  className?: string\n}\n\nexport function BlogCardGrid({\n  status,\n  heading,\n  subheading,\n  items,\n  titleLines = 2,\n  excerptLines = 3,\n  locale = \"en-US\",\n  onRetry,\n  className,\n}: BlogCardGridProps) {\n  const baseId = React.useId()\n\n  /**\n   * timeZone is pinned to \"UTC\": a date-only ISO string (\"2026-03-04\") parses as UTC midnight, and\n   * formatting that in the reader's local zone slips back a day anywhere west of UTC. A publication\n   * date should not move with where the reader happens to be.\n   */\n  const dateFormatter = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\", year: \"numeric\" }),\n    [locale],\n  )\n\n  // with no section heading the card titles are the top level in this block — do not skip a level\n  const headingTag = heading ? \"h3\" : \"h2\"\n\n  return (\n    <section\n      aria-labelledby={heading ? `${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 posts\n          </span>\n          <div aria-hidden=\"true\" className={GRID}>\n            {Array.from({ length: SKELETON_CARDS }, (_, i) => (\n              <div\n                className={cn(\n                  \"flex flex-col overflow-hidden rounded-xl border bg-card\",\n                  // the first skeleton takes the featured outline, so the layout does not reflow when the real data lands\n                  i === 0 && \"sm:col-span-2 sm:flex-row\",\n                )}\n                key={i}\n              >\n                <div className={cn(\"aspect-video w-full bg-muted\", i === 0 && \"sm:aspect-auto sm:min-h-56 sm:w-1/2\")} />\n                <div className=\"flex flex-1 flex-col gap-3 p-5\">\n                  <div className={cn(\"h-5 w-20 rounded-full\", pulse)} />\n                  <div className={cn(\"h-4 w-11/12\", pulse)} />\n                  <div className={cn(\"h-4 w-2/3\", pulse)} />\n                  <div className={cn(\"h-3 w-full\", pulse)} />\n                  <div className={cn(\"h-3 w-4/5\", pulse)} />\n                  <div className=\"mt-auto flex items-center gap-2 pt-1\">\n                    <div className={cn(\"size-6 shrink-0 rounded-full\", pulse)} />\n                    <div className={cn(\"h-3 w-24\", pulse)} />\n                  </div>\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 posts published yet</p>\n          <p className=\"text-sm text-muted-foreground\">Articles appear here as soon as the first one ships.</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 posts</p>\n          <p className=\"text-sm text-muted-foreground\">The content 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        <ul className={GRID}>\n          {items.map(item => (\n            <PostCard\n              dateFormatter={dateFormatter}\n              excerptLines={excerptLines}\n              headingTag={headingTag}\n              item={item}\n              key={item.id}\n              titleLines={titleLines}\n            />\n          ))}\n        </ul>\n      )}\n    </section>\n  )\n}\n\nexport default BlogCardGrid\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/blog-card-grid.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Interaction-honesty line: the card, category and author links all have to point somewhere real.\n * The contract rejects dead anchors like \"#\" outright — \"render something clickable for now\" is the\n * fake interaction this kind of card grid attracts most, and catching it in the data layer is\n * cheaper than catching it in review.\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\nexport const blogCategorySchema = z.object({\n  label: z.string().min(1),\n  /** With an href it renders as a link, without one it is a plain tag — nothing gets a clickable look it cannot honour */\n  href: hrefSchema.optional(),\n})\n\nexport const blogAuthorSchema = z.object({\n  name: z.string().min(1),\n  /** Avatar URL; missing **or failing to load** falls back to initials, never a broken image */\n  avatarUrl: z.string().optional(),\n  /** Overrides the derived initials (mononyms, stage names, non-Latin names) */\n  initials: z.string().optional(),\n  /** Author page; only rendered as a link when given (a secondary link in the card, never nested inside the stretched one) */\n  href: hrefSchema.optional(),\n})\n\nexport const blogCardGridItemSchema = z.object({\n  id: z.string(),\n  title: z.string().min(1),\n  /** Post URL. Every click on the card lands on this one anchor (its pseudo-element covers the card) */\n  href: hrefSchema,\n  /** Excerpt; clamped to `excerptLines` lines, and omitted entirely when absent */\n  excerpt: z.string().optional(),\n  coverUrl: z.string().optional(),\n  /**\n   * Cover alt text. An empty string declares the image decorative (the title already says what it\n   * shows) — deliberately so: letting a screen reader skip it beats hearing the title paraphrased.\n   */\n  coverAlt: z.string().optional(),\n  category: blogCategorySchema.optional(),\n  author: blogAuthorSchema.optional(),\n  /** ISO 8601 date or timestamp. Goes into `<time dateTime>` verbatim; the display string is formatted in UTC so it never slips a day with the reader's zone */\n  publishedAt: z.string().optional(),\n  /** Reading time in minutes **comes from the data**: the component neither guesses it from word count nor hard-codes it */\n  readingMinutes: z.number().positive().optional(),\n  /**\n   * Featured post: spans two columns and switches to the wide image-beside-text layout.\n   * Usually one. Flagging several does not overflow, but measured at 1440px / 3 columns the second\n   * spanning card is pushed to the next row and leaves a hole at the end of the previous one — CSS\n   * grid does not backfill without `grid-auto-flow: dense`, and that makes the visual order diverge\n   * from the data order. Put featured posts at the head of the data if you want them adjacent.\n   */\n  featured: z.boolean().optional(),\n})\n\nexport const blogCardGridSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  heading: z.string().optional(),\n  subheading: z.string().optional(),\n  items: z.array(blogCardGridItemSchema),\n})\n\nexport type BlogCategory = z.infer<typeof blogCategorySchema>\nexport type BlogAuthor = z.infer<typeof blogAuthorSchema>\nexport type BlogCardGridItem = z.infer<typeof blogCardGridItemSchema>\nexport type BlogCardGridData = z.infer<typeof blogCardGridSchema>\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}