{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "article-card",
  "type": "registry:ui",
  "title": "Article Card",
  "description": "Two article tiles that share one focusable-anchor shell: `ArticleCard`, the stacked grid tile with the cover on top, and `FeaturedArticleCard`, the full-bleed hero whose copy sits on a gradient scrim over the image. Both read the same props.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "pattern"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/badge.json",
    "https://ds.interlace.tools/r/card.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/article-card.tsx",
      "target": "components/ui/patterns/article-card.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/article-card v1.5.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/article-card\n// What changed since: https://ds.interlace.tools/c/article-card#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — ArticleCard + FeaturedArticleCard\n *\n * Two article tiles that share one focusable-anchor shell: `ArticleCard`, the\n * stacked grid tile with the cover on top, and `FeaturedArticleCard`, the\n * full-bleed hero whose copy sits on a gradient scrim over the image. Both\n * read the same props.\n *\n * They are two components rather than one `variant` prop because the rendered\n * tree differs entirely (R11). The old `variant` still type-checks for one\n * minor: `variant=\"overlay\"` delegates to `FeaturedArticleCard` and a\n * dev-only `console.warn` fires from `useDeprecatedVariantWarning`.\n *\n * ## Anatomy\n *\n *   CardShell                        (a[href] — data-slot=\"article-card\")\n *     └─ Card                        (primitives/card, py-0 gap-0)\n *         ├─ StackBody               (ArticleCard)\n *         │   ├─ cover aspect-[1000/420] + SourceChip\n *         │   ├─ CardHeader          (avatar + author name + date)\n *         │   ├─ CardContent         (title, description, tag Badges)\n *         │   └─ CardFooter          (MetaChips + ExternalLink on hover)\n *         └─ OverlayBody             (FeaturedArticleCard)\n *             ├─ absolute cover + scrim gradient\n *             ├─ FeaturedChip / SourceChip\n *             └─ tags, h2 title, description, author · date · MetaChips\n *\n * ## Dates are pinned to UTC\n *\n * `formatDate` passes `timeZone: 'UTC'`, matching `patterns/author-byline.tsx`.\n * A date-only ISO string (`\"2026-05-10\"`) parses as UTC midnight, so without\n * the pin `toLocaleDateString` re-projects it into the reader's zone and\n * everyone west of UTC is shown the previous day — on a grid of cards, next to\n * a post whose byline said otherwise.\n *\n * ## The cover box matches the cover's own aspect ratio\n *\n * The stacked tile reserves `aspect-[1000/420]`, the exact ratio of the\n * `width={1000} height={420}` the card already declares on the image. It used\n * to reserve `h-44` — a fixed 176px HEIGHT, which fixes nothing about the\n * ratio, because the ratio then floats with the card's width. At the 320px\n * viewport floor a tile is ~302px wide, so `h-44` is a 1.72:1 box holding a\n * 2.38:1 image: `object-cover` scales the image to 419px wide to cover 176px\n * of height and the box throws away 117 of them — 28% of the cover, gone,\n * and a different amount at every breakpoint.\n *\n * `aspect-[1000/420]` crops nothing at any width, and reserves space just as\n * well as a fixed height did (R23) — better, in fact: the reservation is\n * derived from the width the grid has already resolved, so it is correct\n * before the image has a byte.\n *\n * `object-center` stays. The right-edge crop that motivated moving it to\n * `object-left` downstream was a symptom of the box, not of the position:\n * with the box fixed there is no crop to bias. For a cover that is NOT\n * 1000×420, centre is the neutral default; a caller whose art is\n * left-weighted owns that through `renderImage`.\n *\n * ## `renderImage` — the framework seam\n *\n * The DS cannot import `next/image` and stay framework-agnostic, and every\n * Next.js consumer was re-patching the plain `<img>` by hand. `renderImage` is\n * a render prop handed the exact bag of props the card would have put on the\n * element — `src`, `alt`, `width`, `height`, `loading`, `fetchPriority`,\n * `decoding`, `className` — and it defaults to spreading them onto an `<img>`,\n * so the un-passed path is the old path byte for byte.\n *\n * The bag is the contract, not the element: `className` in it carries the\n * `motion-safe:group-hover:scale-105` gate and the `object-cover` fit, so an\n * adapter that forwards it keeps the hover zoom and the reduced-motion\n * contract for free. An adapter that drops it opts out of both, visibly.\n *\n * ## Motion\n *\n * CSS only — no JS-driven animation and no `useReducedMotion` call.\n *\n * | Effect                  | Class                                  | Under `reduce`          |\n * | ----------------------- | -------------------------------------- | ----------------------- |\n * | cover zoom              | `motion-safe:group-hover:scale-105`    | not applied at all      |\n * | card surface / border   | `transition-all duration-300`          | preflight duration clamp |\n * | hover `ExternalLink`    | `transition-opacity`                   | preflight duration clamp |\n *\n * The cover zoom is the one that carries its own gate. `motion-safe:` compiles\n * to `@media (prefers-reduced-motion: no-preference)` inside the utility\n * itself, so it holds on the à-la-carte import path — a consumer taking\n * `tokens.css` + `theme.css` and skipping `preflight.css` (the path\n * `styles/index.css` exists to prevent, and `motion-contract-lock` treats as\n * live). The remaining two are colour and opacity changes rather than\n * movement, so the preflight clamp is the appropriate layer for them.\n *\n * | Rule | Concept                     | Where in this file                                       |\n * | ---- | --------------------------- | -------------------------------------------------------- |\n * | R5   | testid required, no default | `'data-testid': string` → `{value}-title` / `-tags` / …  |\n * | R6   | data-slot on every part     | `article-card` / `-source` / `-scrim` / `-meta-views`    |\n * | R10  | Composition seam            | `CardShell` wraps both bodies; `Card*` primitives inside |\n * | R11  | No kind-prop                | two exports, `variant` deprecated                        |\n * | R19  | Tokens only                 | `bg-scrim/70`, `text-scrim-foreground`, `text-primary`   |\n * | R23  | CLS=0                       | stack cover reserves `aspect-[1000/420]`; hero pins `h-105 md:h-95` |\n * | R25  | Client component            | `React.useEffect` in the deprecation warning             |\n * | R26  | A11y                        | cover `alt=\"\"`; overlay title is `<h2>` for heading order |\n */\n\nimport { Heart, MessageCircle, Clock, ExternalLink, Eye, Sparkles } from 'lucide-react';\n\nfunction formatViews(count: number): string {\n  if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;\n  return String(count);\n}\n\nimport { cn } from '@/lib/utils';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card';\nimport { Badge } from '@/components/ui/badge';\n\nexport interface ArticleCardAuthor {\n  name: string;\n  imageUrl?: string;\n}\n\nexport interface ArticleCardMeta {\n  /** Reaction / like count. */\n  reactions?: number;\n  /** Comment count. */\n  comments?: number;\n  /** Reading time in minutes. */\n  readingTimeMinutes?: number;\n  /** Page-view count. Rendered abbreviated (e.g., `1.2k`) when ≥ 1000. */\n  views?: number;\n}\n\n/**\n * @deprecated The layout is no longer a prop — it is the component you pick.\n * Use `<ArticleCard>` for the stacked grid tile and `<FeaturedArticleCard>`\n * for the full-bleed hero tile. Kept only so `variant` keeps type-checking\n * during the deprecation window; removed in the next minor.\n */\nexport type ArticleCardVariant = 'stack' | 'overlay';\n\n/**\n * The exact props the card would have put on its `<img>`, handed to\n * {@link ArticleCardBaseProps.renderImage} so a framework-specific image\n * component can receive them instead.\n *\n * Every field is required: there is no \"the card might not send this\" case, so\n * an adapter destructuring the bag never has to guess a default. `alt` is\n * always `''` — the cover is decorative, the title beside it carries the text.\n */\nexport interface ArticleCardImageProps {\n  src: string;\n  /** Always `''`. The cover is decorative; the card title is the accessible name. */\n  alt: string;\n  /** Intrinsic cover dimensions — also the ratio the cover box reserves. */\n  width: number;\n  height: number;\n  loading: 'eager' | 'lazy';\n  fetchPriority: 'high' | 'auto';\n  decoding: 'async';\n  /**\n   * Carries `object-cover`, the object-position, and the\n   * `motion-safe:group-hover:scale-105` hover gate. Forward it to keep the\n   * card's motion and reduced-motion contract; drop it and you own both.\n   */\n  className: string;\n}\n\n/** Fields both card shapes read. Neither adds a prop the other ignores. */\nexport interface ArticleCardBaseProps {\n  /** Card title (article headline). Optional when `loading={true}`. */\n  title?: string;\n  /** Optional short description / excerpt. */\n  description?: string;\n  /** Destination URL. The whole card becomes a link to it. Optional when `loading={true}`. */\n  href?: string;\n  /** Cover image URL. If omitted, a gradient with the title is shown. */\n  imageUrl?: string;\n  /** Tags / topics — first 3 rendered as filled badges, the rest as a \"+N\" overflow chip. */\n  tags?: string[];\n  /** Author block. */\n  author?: ArticleCardAuthor;\n  /** Publication date (any value `Date` constructor accepts). Rendered short-form: `Mar 5, 2026`. */\n  publishedAt?: string | number | Date;\n  /** Reactions / comments / reading-time chips on the footer. */\n  meta?: ArticleCardMeta;\n  /** Small uppercase label shown over the cover (e.g., source attribution like \"Dev.to\"). */\n  sourceLabel?: string;\n  /** Open in a new tab. @default true */\n  external?: boolean;\n  /**\n   * Hint the cover image is the LCP element on this route. Eager-loads it\n   * with `fetchpriority=\"high\"`. Set on the single featured card above the\n   * fold; leave default on every grid tile. @default false\n   */\n  priority?: boolean;\n  /**\n   * Render the cover with something other than a plain `<img>` — typically\n   * `next/image`, which the DS cannot depend on and stay framework-agnostic.\n   *\n   * Receives the full {@link ArticleCardImageProps} bag the card would\n   * otherwise have spread onto the element. Defaults to exactly that spread,\n   * so omitting it is identical to the behaviour before the slot existed. A\n   * `next/image` adapter is a one-line function that forwards the bag; keep\n   * `className`, which carries the fit and the reduced-motion gate.\n   *\n   * The live example is Storybook's `Blocks/ArticleCard → renderImage` story.\n   * It is deliberately NOT a fenced code block in this comment: the registry\n   * build inlines these headers into `r/*.json`, and a JSDoc usage example has\n   * already made 131 of 132 items silently uninstallable once.\n   *\n   * Not called at all when `imageUrl` is absent — the gradient-and-title\n   * fallback is the card's own chrome, not an image.\n   */\n  renderImage?: (props: ArticleCardImageProps) => React.ReactNode;\n  /** Class on the outer anchor wrapper. */\n  className?: string;\n  /**\n   * When true, render a `<Skeleton variant=\"article-card\" />` composite\n   * (image + title lines + meta row silhouette) instead of the card.\n   * Shape-matched so card grids don't shift on data arrival. @default false\n   */\n  loading?: boolean;\n  /**\n   * Stable selector hook for E2E tests. Sub-parts derive from it\n   * (`{value}-title`, `{value}-tags`, `{value}-meta-views`, …). Required —\n   * no runtime default, so an omission surfaces at the call site (R5).\n   */\n  'data-testid': string;\n}\n\nexport interface ArticleCardProps extends ArticleCardBaseProps {\n  /**\n   * @deprecated Pass no `variant` for the stacked tile; render\n   * `<FeaturedArticleCard>` instead of `variant=\"overlay\"`. A kind-prop that\n   * switches the whole rendered tree hides two components inside one type\n   * (R11). Still honoured for one minor, with a dev-mode warning.\n   */\n  variant?: ArticleCardVariant;\n}\n\n/** `<FeaturedArticleCard>` takes the same data, minus the retired knob. */\nexport type FeaturedArticleCardProps = ArticleCardBaseProps;\n\n/**\n * `timeZone: 'UTC'` is load-bearing, not a default — same reasoning, same\n * value as `patterns/author-byline.tsx`. A date-only ISO string (`\"2026-05-10\"`)\n * parses as UTC midnight; without the pin, `toLocaleDateString` re-projects\n * that instant into the reader's zone and everyone west of UTC is shown the\n * previous day. The two components have to agree: the same article renders its\n * date through `ArticleCard` in a grid and through `AuthorByline` on the post.\n */\nfunction formatDate(value: ArticleCardProps['publishedAt']): string {\n  if (value === undefined) return '';\n  return new Date(value).toLocaleDateString('en-US', {\n    month: 'short',\n    day: 'numeric',\n    year: 'numeric',\n    timeZone: 'UTC',\n  });\n}\n\n/** Shared chrome: the focusable anchor + Card surface both shapes sit in. */\nfunction CardShell({\n  href,\n  external,\n  testId,\n  className,\n  surfaceClassName,\n  children,\n}: {\n  href?: string;\n  external: boolean;\n  testId: string;\n  className?: string;\n  surfaceClassName?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <a\n      href={href}\n      target={external ? '_blank' : undefined}\n      rel={external ? 'noopener noreferrer' : undefined}\n      data-slot=\"article-card\"\n      data-testid={testId}\n      className={cn(\n        'group focus-visible:ring-ring block h-full rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',\n        className,\n      )}\n    >\n      {/* `py-0 gap-0` overrides Card's default `py-6 gap-6`. */}\n      <Card\n        className={cn(\n          'flex h-full flex-col overflow-hidden gap-0 py-0 transition-all duration-300',\n          'hover:border-primary/50 hover:shadow-primary/5 hover:shadow-xl',\n          'group-focus-visible:border-primary/50',\n          surfaceClassName,\n        )}\n      >\n        {children}\n      </Card>\n    </a>\n  );\n}\n\n/**\n * Article grid tile: cover image on top, then author / title / description /\n * tags / meta stacked on the card surface. The default shape — use it for\n * \"from the blog\" grids, external content lists, feed aggregations.\n *\n * For the full-bleed hero tile that usually sits above such a grid, render\n * {@link FeaturedArticleCard} instead. They are two components rather than\n * one `variant` prop because the rendered tree differs entirely (R11).\n *\n * ## MIN_VIEWPORT — 320\n *\n * | Rule | Concept                     | Where in this file                                    |\n * | ---- | --------------------------- | ----------------------------------------------------- |\n * | R5   | testid required, no default | `'data-testid': string` → `{value}-title` etc.        |\n * | R6   | data-slot on every part     | `article-card` / `-title` / `-tags` / `-meta`         |\n * | R11  | No kind-prop                | `variant` deprecated; layouts are separate components |\n * | R19  | Tokens only                 | scrim + semantic status tokens, no palette escapes    |\n * | R23  | CLS=0                       | cover reserves `h-44`; `loading` paints a shape-match |\n * | R25  | Deprecation                 | `variant` warns in dev before it is removed           |\n */\nexport function ArticleCard({\n  variant,\n  className,\n  loading,\n  'data-testid': testId,\n  ...rest\n}: ArticleCardProps) {\n  useDeprecatedVariantWarning(variant);\n\n  // Honour the retired knob for one minor so consumers upgrade on their own\n  // schedule rather than on ours.\n  if (variant === 'overlay') {\n    return (\n      <FeaturedArticleCard\n        className={className}\n        loading={loading}\n        data-testid={testId}\n        {...rest}\n      />\n    );\n  }\n\n  if (loading) {\n    return (\n      <Skeleton\n        variant=\"article-card\"\n        data-slot=\"article-card\"\n        data-testid={testId}\n        className={className}\n      />\n    );\n  }\n\n  const { title, external = true, priority = false, tags, href } = rest;\n  const [visibleTags, overflowTags] = splitTags(tags);\n\n  return (\n    <CardShell\n      href={href}\n      external={external}\n      testId={testId}\n      className={className}\n      surfaceClassName=\"pb-sm\"\n    >\n      <StackBody\n        {...rest}\n        title={title ?? ''}\n        visibleTags={visibleTags}\n        overflowTags={overflowTags}\n        priority={priority}\n        testId={testId}\n      />\n    </CardShell>\n  );\n}\n\n/**\n * Full-bleed hero tile: the cover fills the card and the copy sits on a dark\n * gradient scrim, which is what guarantees legible contrast over an\n * arbitrary image. Carries a \"Featured\" chip and a fixed height so it reads\n * as a hero above a grid of {@link ArticleCard}s.\n */\nexport function FeaturedArticleCard({\n  className,\n  loading,\n  'data-testid': testId,\n  ...rest\n}: FeaturedArticleCardProps) {\n  if (loading) {\n    return (\n      <Skeleton\n        variant=\"article-card\"\n        data-slot=\"article-card\"\n        data-testid={testId}\n        className={className}\n      />\n    );\n  }\n\n  const { title, external = true, priority = false, tags, href } = rest;\n  const [visibleTags, overflowTags] = splitTags(tags);\n\n  return (\n    <CardShell\n      href={href}\n      external={external}\n      testId={testId}\n      className={className}\n      // Fixed height so the hero holds its shape before the cover decodes\n      // (R23). Numeric spacing-scale steps, not arbitrary px: h-105 = 420px,\n      // h-95 = 380px. The DESKTOP value is the shorter one — a hero that\n      // keeps its portrait height on a wide viewport crowds the grid below\n      // it. That is a viewport-aspect decision, not a density one, so it\n      // doesn't contradict the mobile-first ladder (R22).\n      surfaceClassName=\"relative h-105 md:h-95\"\n    >\n      <OverlayBody\n        {...rest}\n        title={title ?? ''}\n        visibleTags={visibleTags}\n        overflowTags={overflowTags}\n        priority={priority}\n        testId={testId}\n      />\n    </CardShell>\n  );\n}\n\n/** First three tags render as chips; the rest collapse into a `+N` pill. */\nfunction splitTags(tags: string[] | undefined): [string[], number] {\n  const visible = tags?.slice(0, 3) ?? [];\n  return [visible, tags && tags.length > 3 ? tags.length - 3 : 0];\n}\n\n/**\n * Dev-only nudge toward the split components (R25). Fires once per mount\n * with a `variant` present; silent in production builds because the bundler\n * strips the branch.\n */\nfunction useDeprecatedVariantWarning(variant: ArticleCardVariant | undefined) {\n  React.useEffect(() => {\n    if (variant === undefined) return;\n    const replacement =\n      variant === 'overlay' ? '<FeaturedArticleCard>' : '<ArticleCard> with no variant';\n    // eslint-disable-next-line no-console -- deprecation channel; dev only.\n    console.warn(\n      `[@interlace/ui] ArticleCard: the \\`variant\\` prop is deprecated and will be removed in the next minor. Render ${replacement} instead.`,\n    );\n  }, [variant]);\n}\n\ninterface BodyProps extends Omit<ArticleCardBaseProps, 'title' | 'data-testid'> {\n  title: string;\n  visibleTags: string[];\n  overflowTags: number;\n  /** Root test id — every part derives its own id from this. */\n  testId: string;\n}\n\n/**\n * Chip chrome shared by the source + featured pills. Both float over the\n * cover, so both ride the scrim tokens rather than `bg-black/70 text-white`\n * palette escapes (R19), and both sit at `text-caption` (13px) — the DS type\n * scale's floor. The old `text-[10px]` was below every readable minimum.\n */\nconst OVER_COVER_CHIP =\n  'absolute top-3 z-10 flex items-center gap-1 rounded-md bg-scrim/70 px-2 py-1 text-caption font-bold tracking-wider text-scrim-foreground uppercase backdrop-blur-sm';\n\n/** Top-right chip used to attribute the source of the article (e.g., \"Dev.to\"). */\nfunction SourceChip({ label, testId }: { label: string; testId: string }) {\n  return (\n    <div\n      data-slot=\"article-card-source\"\n      data-testid={`${testId}-source`}\n      className={cn(OVER_COVER_CHIP, 'right-3')}\n    >\n      {label}\n    </div>\n  );\n}\n\n/** Top-left chip shown only on the featured card. */\nfunction FeaturedChip({ testId }: { testId: string }) {\n  return (\n    <div\n      data-slot=\"article-card-featured-chip\"\n      data-testid={`${testId}-featured-chip`}\n      className={cn(OVER_COVER_CHIP, 'left-3')}\n    >\n      <Sparkles className=\"h-3 w-3\" aria-hidden />\n      Featured\n    </div>\n  );\n}\n\n/**\n * Intrinsic cover dimensions. These are the numbers the `<img>` declares AND\n * the ratio `StackBody` reserves — `aspect-[1000/420]`. They have to agree, or\n * `object-cover` starts cropping (see the file header). Tailwind scans source\n * as raw text, so the class cannot interpolate these; the coupling is asserted\n * in `decorative-contract-lock` instead.\n */\nconst COVER_WIDTH = 1000;\nconst COVER_HEIGHT = 420;\n\n/**\n * The plain-`<img>` default for the `renderImage` slot.\n *\n * `alt` is named rather than left inside the spread, so the attribute is\n * visible in the JSX rather than hidden behind `{...props}`. `react-a11y/alt-text`\n * still reports it: the rule wants a literal and flags any `alt={expr}`,\n * which is why it already fires twice in this file on the author avatars'\n * `alt={author.name}`. Left un-suppressed — a blanket disable here would also\n * hide a genuinely missing alt if this function is ever edited, and the rule's\n * dynamic-value blind spot is a defect to fix upstream, not to paper over at\n * three call sites.\n */\nconst renderPlainImage = ({ alt, ...rest }: ArticleCardImageProps) => (\n  <img alt={alt} {...rest} />\n);\n\nfunction CoverImage({\n  imageUrl,\n  title,\n  className,\n  fallbackTextClassName,\n  priority = false,\n  renderImage = renderPlainImage,\n}: {\n  imageUrl?: string;\n  title: string;\n  className?: string;\n  fallbackTextClassName?: string;\n  /**\n   * When true, eager-load the cover image and hint the browser to fetch\n   * it with high priority. Use on the LCP element of a route — typically\n   * the featured/overlay slot on the articles index. Default: false\n   * (lazy-loaded, fine for grid tiles below the fold).\n   */\n  priority?: boolean;\n  /** See {@link ArticleCardBaseProps.renderImage}. */\n  renderImage?: (props: ArticleCardImageProps) => React.ReactNode;\n}) {\n  if (imageUrl) {\n    // Built as a value, not spread inline, so the slot and the default `<img>`\n    // are provably given the same bag — there is no second call site that\n    // could drift.\n    const imageProps: ArticleCardImageProps = {\n      src: imageUrl,\n      alt: '',\n      width: COVER_WIDTH,\n      height: COVER_HEIGHT,\n      loading: priority ? 'eager' : 'lazy',\n      // `fetchpriority` is the lowercase DOM attr name; React 19 normalizes\n      // either casing, but lowercase is the canonical HTML form and avoids\n      // hydration mismatches across SSR/CSR.\n      fetchPriority: priority ? 'high' : 'auto',\n      decoding: 'async',\n      className: cn(\n        // `motion-safe:` on the SCALE, not on the transition. The transition\n        // is inert once nothing transforms, and gating the transform is what\n        // survives the à-la-carte import path — see the file header.\n        'h-full w-full object-cover object-center transition-transform duration-500 motion-safe:group-hover:scale-105',\n        className,\n      ),\n    };\n    return <>{renderImage(imageProps)}</>;\n  }\n  return (\n    <div className=\"flex h-full w-full items-center justify-center bg-linear-to-br from-primary/25 via-scrim to-chart-2/25 p-md\">\n      <span\n        className={cn(\n          'line-clamp-3 text-center leading-snug font-semibold text-scrim-foreground/80',\n          fallbackTextClassName ?? 'text-base',\n        )}\n      >\n        {title}\n      </span>\n    </div>\n  );\n}\n\n/**\n * Reactions / comments / reading-time / views row.\n *\n * `tone` is a styling switch, not a kind-prop: both tones render the exact\n * same tree and differ only in which foreground token the chips inherit\n * (R11 draws the line at the rendered tree, not at class names).\n *\n * The per-icon hover recolour this used to carry (red / blue / amber-400)\n * was four palette escapes AND a contrast hazard on the scrim tone, where a\n * mid-weight red lands on near-black. Chips now inherit one foreground per\n * tone; only the views chip stays accented, because it is the one number\n * the card is usually sorted by.\n */\nfunction MetaChips({\n  meta,\n  tone,\n  testId,\n}: {\n  meta: ArticleCardMeta;\n  tone: 'muted' | 'scrim';\n  testId: string;\n}) {\n  const baseChip = 'flex items-center gap-1.5 text-caption tabular-nums';\n  const onScrim = tone === 'scrim';\n  const colorClass = onScrim\n    ? 'text-scrim-foreground/90'\n    : 'text-muted-foreground';\n  const accentClass = onScrim ? 'text-scrim-foreground' : 'text-primary';\n\n  return (\n    <>\n      {meta.reactions !== undefined ? (\n        <span\n          data-slot=\"article-card-meta-reactions\"\n          data-testid={`${testId}-meta-reactions`}\n          className={cn(baseChip, colorClass)}\n          title=\"Reactions\"\n        >\n          <Heart className=\"h-3.5 w-3.5\" aria-hidden />\n          {meta.reactions}\n        </span>\n      ) : null}\n      {meta.comments !== undefined ? (\n        <span\n          data-slot=\"article-card-meta-comments\"\n          data-testid={`${testId}-meta-comments`}\n          className={cn(baseChip, colorClass)}\n          title=\"Comments\"\n        >\n          <MessageCircle className=\"h-3.5 w-3.5\" aria-hidden />\n          {meta.comments}\n        </span>\n      ) : null}\n      {meta.readingTimeMinutes !== undefined ? (\n        <span\n          data-slot=\"article-card-meta-reading-time\"\n          data-testid={`${testId}-meta-reading-time`}\n          className={cn(baseChip, colorClass)}\n          title=\"Reading time\"\n        >\n          <Clock className=\"h-3.5 w-3.5\" aria-hidden />\n          {meta.readingTimeMinutes} min\n        </span>\n      ) : null}\n      {meta.views !== undefined ? (\n        <span\n          data-slot=\"article-card-meta-views\"\n          data-testid={`${testId}-meta-views`}\n          className={cn(baseChip, 'font-medium', accentClass)}\n          title=\"Views\"\n        >\n          <Eye className=\"h-3.5 w-3.5\" aria-hidden />\n          {formatViews(meta.views)}\n        </span>\n      ) : null}\n    </>\n  );\n}\n\nfunction StackBody({\n  title,\n  description,\n  imageUrl,\n  visibleTags,\n  overflowTags,\n  author,\n  publishedAt,\n  meta,\n  sourceLabel,\n  priority = false,\n  renderImage,\n  testId,\n}: BodyProps) {\n  return (\n    <>\n      {/* Cover (or gradient title fallback) — edge-to-edge top of the card.\n          `aspect-[1000/420]` is the CLS reservation AND the anti-crop measure:\n          it is the ratio of the `width`/`height` the cover declares, so the box\n          holds its shape before the image decodes (R23) and `object-cover` has\n          nothing to trim. A fixed height cannot do the second job — see the\n          file header. */}\n      <div className=\"relative aspect-[1000/420] w-full shrink-0 overflow-hidden\">\n        <CoverImage\n          imageUrl={imageUrl}\n          title={title}\n          priority={priority}\n          renderImage={renderImage}\n        />\n        {sourceLabel ? <SourceChip label={sourceLabel} testId={testId} /> : null}\n      </div>\n\n      {(author || publishedAt) && (\n        <CardHeader data-slot=\"article-card-byline\" className=\"pt-4 pb-3\">\n          <div className=\"flex w-full items-center justify-between gap-2 min-w-0\">\n            {author ? (\n              <div className=\"flex items-center gap-2 min-w-0\">\n                {author.imageUrl ? (\n                  <img\n                    src={author.imageUrl}\n                    alt={author.name}\n                    width={24}\n                    height={24}\n                    loading=\"lazy\"\n                    decoding=\"async\"\n                    className=\"border-border h-6 w-6 shrink-0 rounded-full border\"\n                  />\n                ) : null}\n                <span className=\"text-foreground truncate text-sm font-medium\">\n                  {author.name}\n                </span>\n              </div>\n            ) : (\n              <span />\n            )}\n            {publishedAt ? (\n              <span className=\"text-muted-foreground text-xs whitespace-nowrap shrink-0\">\n                {formatDate(publishedAt)}\n              </span>\n            ) : null}\n          </div>\n        </CardHeader>\n      )}\n\n      {/* Content order (top → bottom): title, description, tags.\n          Tags after description per top-1% blog-card convention — the\n          reader's eye lands on the headline first, not on metadata. */}\n      <CardContent className=\"flex grow flex-col gap-2 pt-0\">\n        <CardTitle\n          data-slot=\"article-card-title\"\n          data-testid={`${testId}-title`}\n          className=\"group-hover:text-primary line-clamp-2 text-base font-semibold leading-snug transition-colors\"\n        >\n          {title}\n        </CardTitle>\n\n        {description ? (\n          <CardDescription\n            data-slot=\"article-card-description\"\n            data-testid={`${testId}-description`}\n            className=\"line-clamp-2 text-ui leading-relaxed\"\n          >\n            {description}\n          </CardDescription>\n        ) : null}\n\n        {visibleTags.length > 0 ? (\n          <div\n            data-slot=\"article-card-tags\"\n            data-testid={`${testId}-tags`}\n            className=\"mt-auto flex flex-wrap gap-1.5 pt-2\"\n          >\n            {visibleTags.map((tag) => (\n              <Badge\n                key={tag}\n                variant=\"secondary\"\n                className=\"px-2 py-0.5 text-caption font-medium tracking-normal whitespace-nowrap\"\n              >\n                #{tag}\n              </Badge>\n            ))}\n            {overflowTags > 0 ? (\n              <Badge\n                variant=\"outline\"\n                className=\"px-1.5 py-0.5 text-caption font-medium whitespace-nowrap\"\n              >\n                +{overflowTags}\n              </Badge>\n            ) : null}\n          </div>\n        ) : null}\n      </CardContent>\n\n      {meta ? (\n        <CardFooter\n          data-slot=\"article-card-meta\"\n          className=\"text-muted-foreground mt-2 gap-4 border-t border-border pt-3\"\n        >\n          <MetaChips meta={meta} tone=\"muted\" testId={testId} />\n          <span className=\"ml-auto opacity-0 transition-opacity group-hover:opacity-100\">\n            <ExternalLink className=\"text-primary h-4 w-4\" aria-hidden />\n          </span>\n        </CardFooter>\n      ) : null}\n    </>\n  );\n}\n\nfunction OverlayBody({\n  title,\n  description,\n  imageUrl,\n  visibleTags,\n  overflowTags,\n  author,\n  publishedAt,\n  meta,\n  sourceLabel,\n  priority = false,\n  renderImage,\n  testId,\n}: BodyProps) {\n  return (\n    <>\n      {/* Cover fills the entire card. Image lives in an absolute layer so a\n          dark gradient scrim can sit between it and the text — that scrim is\n          what guarantees WCAG-AA contrast over arbitrary covers.\n\n          The hero is the one place a crop is intended: it pins `h-105 md:h-95`\n          against a full-bleed width, so its box ratio is a viewport decision\n          and `object-cover` trimming the cover is the whole point. Only the\n          STACK tile reserves the cover's own ratio. */}\n      <div className=\"absolute inset-0 overflow-hidden\">\n        <CoverImage\n          imageUrl={imageUrl}\n          title={title}\n          fallbackTextClassName=\"text-2xl\"\n          priority={priority}\n          renderImage={renderImage}\n        />\n      </div>\n      {/* Scrim — opacity stack tuned so titles + meta on top read clean over\n          any cover, including light or busy images. Rides `--scrim` so the\n          wash is a brand-forkable token, not a `bg-black/85` escape. */}\n      <div\n        aria-hidden\n        data-slot=\"article-card-scrim\"\n        className=\"absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/55 to-scrim/15\"\n      />\n\n      <FeaturedChip testId={testId} />\n      {sourceLabel ? <SourceChip label={sourceLabel} testId={testId} /> : null}\n\n      <div className=\"absolute inset-x-0 bottom-0 z-10 p-md md:p-lg\">\n        {visibleTags.length > 0 ? (\n          <div\n            data-slot=\"article-card-tags\"\n            data-testid={`${testId}-tags`}\n            className=\"mb-4 flex flex-wrap gap-1.5\"\n          >\n            {visibleTags.map((tag) => (\n              <Badge\n                key={tag}\n                variant=\"secondary\"\n                className=\"bg-scrim-foreground/15 text-scrim-foreground border border-scrim-foreground/30 backdrop-blur-sm px-2.5 py-0.5 text-caption font-medium tracking-normal whitespace-nowrap hover:bg-scrim-foreground/25\"\n              >\n                #{tag}\n              </Badge>\n            ))}\n            {overflowTags > 0 ? (\n              <Badge\n                variant=\"outline\"\n                className=\"bg-scrim-foreground/10 text-scrim-foreground border-scrim-foreground/30 backdrop-blur-sm px-1.5 py-0.5 text-caption font-medium whitespace-nowrap\"\n              >\n                +{overflowTags}\n              </Badge>\n            ) : null}\n          </div>\n        ) : null}\n\n        {/* h2 (not h3) so the heading hierarchy from a page-level h1 →\n            article card title increases by exactly one level. axe's\n            `heading-order` rule flags h1→h3 jumps on the /articles route\n            and in isolated Storybook scans. The visual size (text-2xl/3xl)\n            is preserved via class names, decoupled from semantic level. */}\n        <h2\n          data-slot=\"article-card-title\"\n          data-testid={`${testId}-title`}\n          className=\"line-clamp-2 text-2xl md:text-3xl font-bold leading-tight text-scrim-foreground mb-2 drop-shadow\"\n        >\n          {title}\n        </h2>\n\n        {description ? (\n          <p\n            data-slot=\"article-card-description\"\n            data-testid={`${testId}-description`}\n            className=\"line-clamp-2 text-ui md:text-base text-scrim-foreground/90 mb-4 max-w-prose drop-shadow\"\n          >\n            {description}\n          </p>\n        ) : null}\n\n        <div\n          data-slot=\"article-card-meta\"\n          className=\"flex flex-wrap items-center gap-3 md:gap-4 text-scrim-foreground/90 text-ui\"\n        >\n          {author ? (\n            <div className=\"flex items-center gap-2 min-w-0\">\n              {author.imageUrl ? (\n                <img\n                  src={author.imageUrl}\n                  alt={author.name}\n                  width={32}\n                  height={32}\n                  loading=\"lazy\"\n                  decoding=\"async\"\n                  className=\"h-8 w-8 shrink-0 rounded-full border-2 border-scrim-foreground/60\"\n                />\n              ) : null}\n              <span className=\"truncate font-medium text-scrim-foreground\">\n                {author.name}\n              </span>\n            </div>\n          ) : null}\n          {publishedAt ? (\n            <>\n              <span aria-hidden className=\"hidden sm:inline text-scrim-foreground/40\">•</span>\n              <span className=\"hidden sm:inline whitespace-nowrap text-scrim-foreground/80\">\n                {formatDate(publishedAt)}\n              </span>\n            </>\n          ) : null}\n          {meta ? (\n            <>\n              <span aria-hidden className=\"hidden sm:inline text-scrim-foreground/40\">•</span>\n              <div className=\"hidden sm:flex items-center gap-3 md:gap-4\">\n                <MetaChips meta={meta} tone=\"scrim\" testId={testId} />\n              </div>\n            </>\n          ) : null}\n        </div>\n      </div>\n\n      <span className=\"absolute top-3 right-14 z-10 opacity-0 transition-opacity group-hover:opacity-100\">\n        <ExternalLink className=\"h-5 w-5 text-scrim-foreground drop-shadow\" aria-hidden />\n      </span>\n    </>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": true,
    "minViewport": null,
    "loading": true,
    "version": "1.5.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/article-card\n\nInstalled to `components/ui/patterns/article-card.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/article-card';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/article-card\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
