{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"blog-grid","type":"registry:ui","title":"Blog grid","description":"A grid of article cards with date, tag and summary.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/avatar.tsx","type":"registry:ui","target":"components/ui/avatar.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useState, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./avatar.css\";\n\nexport type AvatarProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  name: string;\n  src?: string;\n  alt?: string;\n  /** \"square\" for a company, workspace or integration: a coloured mark instead of a person's orb or photo. */\n  shape?: \"circle\" | \"square\";\n  /** Shown as a shape at the corner: filled, half, dash or ring. */\n  presence?: \"online\" | \"away\" | \"busy\" | \"offline\";\n};\n\n/** Resolves a person's photo from their name when an Avatar has no `src`: your user directory, or sample faces in a demo.\n    Return undefined for anyone without one and they keep their orb. */\nexport const AvatarPhotos = createContext<((name: string) => string | undefined) | null>(null);\n\n/** The same name always gets the same orb (six glass orbs; the first follows the accent). */\nexport function avatarTone(name: string) {\n  let hash = 7;\n  for (const char of name.trim().toLowerCase()) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;\n  return hash % 6;\n}\n\nfunction AvatarContent({ src, initials }: { src?: string; initials: string }) {\n  const [failed, setFailed] = useState(false);\n  return (\n    <>\n      <span aria-hidden=\"true\">{initials}</span>\n      {src && !failed && (\n        <img\n          src={src}\n          alt=\"\"\n          aria-hidden=\"true\"\n          width={32}\n          height={32}\n          decoding=\"async\"\n          className=\"absolute inset-0 size-full object-cover\"\n          ref={(image) => {\n            // A cached or pre-hydration failure may precede the error listener.\n            if (image?.complete && image.naturalWidth === 0) setFailed(true);\n          }}\n          onError={() => setFailed(true)}\n        />\n      )}\n    </>\n  );\n}\n\nexport function Avatar({\n  name,\n  src,\n  alt = name,\n  shape = \"circle\",\n  presence,\n  className,\n  ...props\n}: AvatarProps) {\n  const resolve = useContext(AvatarPhotos);\n  const photo = src ?? (shape === \"circle\" ? resolve?.(name) : undefined);\n  const words = name.trim().normalize(\"NFC\").split(/\\s+/u);\n  const initials =\n    (Array.from(words[0] ?? \"\")[0] ?? \"?\") +\n    (words.length > 1 ? (Array.from(words[words.length - 1]!)[0] ?? \"\") : \"\");\n\n  return (\n    <span\n      data-slot=\"avatar\"\n      data-tone={avatarTone(name)}\n      data-shape={shape === \"square\" ? \"square\" : undefined}\n      className={cn(\n        \"relative inline-flex size-8 shrink-0 items-center justify-center text-xs font-semibold\",\n        shape === \"square\" ? \"rounded-[8px]\" : \"rounded-full\",\n        className,\n      )}\n      {...props}\n      role={alt ? \"img\" : undefined}\n      aria-label={alt || undefined}\n      aria-hidden={!alt || undefined}\n    >\n      <AvatarContent\n        key={photo ?? \"\"}\n        src={photo}\n        initials={initials.toUpperCase()}\n      />\n      {presence && <span data-slot=\"avatar-presence\" data-presence={presence} aria-hidden=\"true\" />}\n    </span>\n  );\n}\n"},{"path":"ui/badge.tsx","type":"registry:ui","target":"components/ui/badge.tsx","content":"import type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"./lib/cn\";\n\nconst variants = {\n  neutral: \"bg-muted text-muted-foreground\",\n  info: \"bg-brand-subtle text-[color-mix(in_srgb,var(--accent-text)_82%,var(--foreground))]\",\n  success: \"bg-success-subtle text-success\",\n  warning: \"bg-warning-subtle text-warning\",\n  error: \"bg-destructive-subtle text-destructive\",\n};\n\n/* Status survives without colour: each variant has its own shape. */\nconst shapes = {\n  neutral: <path d=\"M2 5h6\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />,\n  info: <circle cx=\"5\" cy=\"5\" r=\"3.6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />,\n  success: <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"currentColor\" />,\n  warning: (\n    <>\n      <path d=\"M5 1a4 4 0 0 1 0 8z\" fill=\"currentColor\" />\n      <circle cx=\"5\" cy=\"5\" r=\"4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />\n    </>\n  ),\n  error: <path d=\"M2 2l6 6M8 2 2 8\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />,\n};\n\n/* Categorical hues for labels that name a kind, not a status (a deal stage, a team, a type): OKLCH hue angles.\n   Lightness and chroma come per theme from tokens.css (--tone-*), so the text holds 4.5:1 on its wash in both. */\nexport const toneHues = { blue: 255, violet: 295, rose: 2, orange: 50, amber: 82, green: 150, teal: 195, slate: 260 } as const;\nexport type BadgeTone = keyof typeof toneHues;\n\nexport type BadgeProps = ComponentProps<\"span\"> & {\n  variant?: keyof typeof variants;\n  /** A categorical hue with a dot, for kinds rather than statuses. Overrides the variant's colour. */\n  tone?: BadgeTone;\n  /** The status shape before the word. On for status variants, off for neutral labels and counts. */\n  shape?: boolean;\n};\n\nexport function Badge({\n  className,\n  variant = \"neutral\",\n  tone,\n  shape = variant !== \"neutral\" && !tone,\n  children,\n  style,\n  ...props\n}: BadgeProps) {\n  return (\n    <span\n      data-slot=\"badge\"\n      data-variant={variant}\n      data-tone={tone}\n      className={cn(\n        \"inline-flex h-[22px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full px-2 text-xs leading-4 font-medium [&_svg]:size-3\",\n        (shape || tone) && \"pl-[7px]\",\n        tone\n          ? \"bg-[oklch(var(--tone-bg-l)_var(--tone-bg-c)_var(--tone-h))] text-[oklch(var(--tone-fg-l)_var(--tone-fg-c)_var(--tone-h))]\"\n          : variants[variant],\n        className,\n      )}\n      style={tone ? ({ \"--tone-h\": toneHues[tone], ...style } as CSSProperties) : style}\n      {...props}\n    >\n      {tone && <span data-slot=\"badge-dot\" aria-hidden=\"true\" className=\"size-1.5 shrink-0 rounded-full bg-current\" />}\n      {shape && (\n        <svg data-slot=\"badge-shape\" viewBox=\"0 0 10 10\" aria-hidden=\"true\" className=\"!size-2.5\">\n          {shapes[variant]}\n        </svg>\n      )}\n      {children}\n    </span>\n  );\n}\n"},{"path":"ui/blog-grid.tsx","type":"registry:ui","target":"components/ui/blog-grid.tsx","content":"\"use client\";\nimport { useMemo, useState } from \"react\";\nimport { Badge, type BadgeTone } from \"./badge\";\nimport { Avatar } from \"./avatar\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/table-skeleton.css\";\nimport \"./shared/prompt-suggestion.css\";\nimport \"./blog-grid.css\";\n\nexport type BlogPost = {\n  id: string;\n  title: string;\n  excerpt: string;\n  href: string;\n  /** ISO date. */\n  date: string;\n  readTime?: string;\n  category?: string;\n  author?: { name: string; avatar?: string };\n  image?: { src: string; alt: string };\n};\n\nconst dateFormat = new Intl.DateTimeFormat(\"en-US\", { month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" });\n\nfunction PostCard({ post, featured, tone }: { post: BlogPost; featured?: boolean; tone?: BadgeTone }) {\n  return (\n    <article data-slot=\"blog-card\" data-featured={featured || undefined} className=\"group relative grid content-start gap-3\">\n      {post.image && (\n        <div className={cn(\"overflow-hidden rounded-overlay bg-muted\", featured ? \"aspect-[16/9]\" : \"aspect-[3/2]\")}>\n          <img src={post.image.src} alt={post.image.alt} loading=\"lazy\" draggable={false} data-slot=\"blog-image\" className=\"size-full object-cover\" />\n        </div>\n      )}\n      <div className=\"grid gap-2\">\n        <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground\">\n          {post.category && <Badge tone={tone}>{post.category}</Badge>}\n          <time dateTime={post.date}>{dateFormat.format(new Date(post.date))}</time>\n          {post.readTime && <span aria-hidden=\"true\">·</span>}\n          {post.readTime && <span>{post.readTime}</span>}\n        </div>\n        <h3 className={cn(\"font-semibold tracking-tight\", featured ? \"text-xl sm:text-2xl\" : \"text-base\")}>\n          <a href={post.href} data-slot=\"blog-link\">\n            {post.title}\n          </a>\n        </h3>\n        <p className={cn(\"text-muted-foreground\", featured ? \"line-clamp-3 text-base\" : \"line-clamp-3 text-sm\")}>{post.excerpt}</p>\n        {post.author && (\n          <p className=\"mt-1 flex items-center gap-2 text-xs text-muted-foreground\">\n            <Avatar name={post.author.name} src={post.author.avatar} className=\"size-6 text-[10px]\" alt=\"\" />\n            {post.author.name}\n          </p>\n        )}\n      </div>\n    </article>\n  );\n}\n\n/** Articles in a grid, the newest one larger, with an optional category filter. */\nexport function BlogGrid({\n  posts,\n  title = \"From the blog\",\n  description,\n  featured = true,\n  tones,\n  loading,\n  className,\n}: {\n  posts: readonly BlogPost[];\n  title?: string;\n  description?: string;\n  /** Show the first post larger when there are at least three. */\n  featured?: boolean;\n  /** One hue per category, e.g. { Engineering: \"blue\" }. Categories without one stay neutral. */\n  tones?: Partial<Record<string, BadgeTone>>;\n  loading?: boolean;\n  className?: string;\n}) {\n  const [category, setCategory] = useState<string | null>(null);\n  const categories = useMemo(() => [...new Set(posts.map((p) => p.category).filter((c): c is string => !!c))], [posts]);\n  const shown = category ? posts.filter((p) => p.category === category) : posts;\n  const lead = featured && shown.length >= 3 ? shown[0] : null;\n  const rest = lead ? shown.slice(1) : shown;\n  return (\n    <section data-slot=\"blog-grid\" aria-label={title} className={cn(\"@container mx-auto grid w-full max-w-6xl gap-8 px-4 py-12 sm:px-6\", className)}>\n      <div className=\"flex flex-wrap items-end justify-between gap-4\">\n        <div className=\"grid gap-1.5\">\n          <h2 className=\"text-2xl font-semibold tracking-tight\">{title}</h2>\n          {description && <p className=\"text-sm text-muted-foreground\">{description}</p>}\n        </div>\n        {categories.length > 1 && (\n          <div role=\"group\" aria-label=\"Filter by category\" className=\"flex flex-wrap gap-1.5\">\n            {[null, ...categories].map((c) => (\n              <button key={c ?? \"all\"} type=\"button\" data-slot=\"prompt-suggestion\" aria-pressed={category === c} onClick={() => setCategory(c)}>\n                {c ?? \"All\"}\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n      {loading ? (\n        <div aria-busy=\"true\" className=\"grid gap-8 @xl:grid-cols-2 @4xl:grid-cols-3\">\n          {Array.from({ length: 3 }, (_, i) => (\n            <div key={i} className=\"grid gap-3\" aria-hidden=\"true\">\n              <span data-slot=\"table-skeleton\" style={{ height: \"10rem\" }} />\n              <span data-slot=\"table-skeleton\" style={{ width: \"40%\" }} />\n              <span data-slot=\"table-skeleton\" />\n            </div>\n          ))}\n        </div>\n      ) : shown.length ? (\n        <div className=\"grid gap-x-6 gap-y-10 @xl:grid-cols-2 @4xl:grid-cols-3\">\n          {lead && (\n            <div className=\"@xl:col-span-2\">\n              <PostCard post={lead} featured tone={lead.category ? tones?.[lead.category] : undefined} />\n            </div>\n          )}\n          {rest.map((post) => (\n            <PostCard key={post.id} post={post} tone={post.category ? tones?.[post.category] : undefined} />\n          ))}\n        </div>\n      ) : (\n        <p className=\"rounded-overlay border border-dashed border-border p-8 text-center text-sm text-muted-foreground\">No posts in this category yet.</p>\n      )}\n      <p className=\"sr-only\" role=\"status\">\n        {category ? `${shown.length} ${shown.length === 1 ? \"post\" : \"posts\"} in ${category}` : \"\"}\n      </p>\n    </section>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/avatar.css","type":"registry:file","target":"components/ui/avatar.css","content":"/* avatar.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Avatars: a light glass orb, two soft hues with a specular highlight and a deeper lower edge. Six tones, assigned per\n     person, so a row of people reads as a calm spectrum; dark initials read on every tone at 9:1 or better. */\n  [data-slot=\"avatar\"] {\n    --av-a: #9ec5ff;\n    --av-b: #c4b2ff;\n    color: #161616;\n    background:\n      radial-gradient(circle at 30% 24%, rgb(255 255 255 / 80%), rgb(255 255 255 / 0%) 36%),\n      radial-gradient(circle at 76% 84%, color-mix(in oklab, var(--av-b) 78%, #3b2a6b), transparent 58%),\n      linear-gradient(140deg, var(--av-a) 12%, var(--av-b) 88%);\n    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 34%), inset 0 -4px 8px rgb(0 0 0 / 8%);\n  }\n  [data-slot=\"avatar\"] > span:first-child {\n    position: relative;\n    text-shadow: 0 1px 0 rgb(255 255 255 / 35%);\n  }\n  [data-slot=\"avatar\"] img {\n    border-radius: inherit;\n  }\n  [data-slot=\"avatar\"][data-tone=\"1\"] { --av-a: #a3f0c8; --av-b: #7fd3e6; }\n  [data-slot=\"avatar\"][data-tone=\"2\"] { --av-a: #c9adff; --av-b: #ffb2d8; }\n  [data-slot=\"avatar\"][data-tone=\"3\"] { --av-a: #ffd6a0; --av-b: #ffab86; }\n  [data-slot=\"avatar\"][data-tone=\"4\"] { --av-a: #ffb4a6; --av-b: #ff9ec6; }\n  [data-slot=\"avatar\"][data-tone=\"5\"] { --av-a: #b8c6ff; --av-b: #9fe1f0; }\n  /* Company marks (shape=\"square\"): a solid hue from the name with white initials, 4.7:1 or better on every hue. */\n  [data-slot=\"avatar\"][data-shape=\"square\"] {\n    --mark-h: 255;\n    color: #ffffff;\n    background: oklch(0.52 0.15 var(--mark-h));\n    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 12%);\n  }\n  [data-slot=\"avatar\"][data-shape=\"square\"] > span:first-child { text-shadow: none; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"1\"] { --mark-h: 295; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"2\"] { --mark-h: 2; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"3\"] { --mark-h: 50; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"4\"] { --mark-h: 150; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"5\"] { --mark-h: 195; }\n  [data-slot=\"avatar-presence\"] {\n    position: absolute;\n    right: -3px;\n    bottom: -3px;\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    box-shadow: 0 0 0 2px var(--background);\n  }\n  [data-slot=\"avatar-presence\"][data-presence=\"online\"] { background: var(--success); }\n  [data-slot=\"avatar-presence\"][data-presence=\"away\"] { background: linear-gradient(90deg, var(--warning) 50%, var(--background) 50%); box-shadow: 0 0 0 2px var(--background), inset 0 0 0 1.5px var(--warning); }\n  [data-slot=\"avatar-presence\"][data-presence=\"busy\"] { background: radial-gradient(var(--background) 0 0) center / 5px 1.5px no-repeat, var(--danger); }\n  [data-slot=\"avatar-presence\"][data-presence=\"offline\"] { background: var(--background); box-shadow: 0 0 0 2px var(--background), inset 0 0 0 1.5px var(--muted-foreground); }\n}\n"},{"path":"ui/shared/table-skeleton.css","type":"registry:file","target":"components/ui/shared/table-skeleton.css","content":"/* Shared by blog-grid.tsx, chat-thread.tsx, link-unfurl.tsx, multi-chain-swap.tsx, sortable-table.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"table-skeleton\"] {\n    display: block;\n    height: 0.75rem;\n    border-radius: 4px;\n    background: var(--muted);\n    animation: caliper-skeleton-pulse 1.4s ease-in-out infinite;\n  }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"table-skeleton\"] {\n      animation: none;\n    }\n  }\n}\n"},{"path":"ui/shared/prompt-suggestion.css","type":"registry:file","target":"components/ui/shared/prompt-suggestion.css","content":"/* Shared by blog-grid.tsx, changelog-feed.tsx, prediction-market.tsx, prompt-input.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"prompt-suggestion\"] {\n    padding: 0.375rem 0.75rem;\n    border: 1px solid var(--border);\n    border-radius: 999px;\n    font-size: 0.8125rem;\n    color: var(--muted-foreground);\n    outline: none;\n    transition:\n      background-color var(--duration-fast) var(--ease-out),\n      color var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"prompt-suggestion\"]:hover:not(:disabled) {\n    background: var(--muted);\n    color: var(--foreground);\n  }\n  [data-slot=\"prompt-suggestion\"]:focus-visible {\n    outline: 2px solid var(--ring);\n    outline-offset: 2px;\n  }\n}\n"},{"path":"ui/blog-grid.css","type":"registry:file","target":"components/ui/blog-grid.css","content":"/* blog-grid.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"blog-link\"] {\n    color: inherit;\n    text-decoration: none;\n    outline: none;\n  }\n  [data-slot=\"blog-link\"]::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    border-radius: var(--radius-overlay, 12px);\n  }\n  [data-slot=\"blog-link\"]:focus-visible::after {\n    outline: 2px solid var(--ring);\n    outline-offset: 4px;\n  }\n  [data-slot=\"blog-card\"]:hover [data-slot=\"blog-link\"] {\n    text-decoration: underline;\n    text-underline-offset: 4px;\n  }\n  [data-slot=\"blog-image\"] {\n    transition: transform var(--duration-travel) var(--ease-showcase);\n  }\n  [data-slot=\"blog-card\"]:hover [data-slot=\"blog-image\"] {\n    transform: scale(1.03);\n  }\n  [data-slot=\"blog-grid\"] [data-slot=\"prompt-suggestion\"][aria-pressed=\"true\"] {\n    background: var(--accent);\n    border-color: var(--accent);\n    color: var(--accent-foreground);\n  }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"blog-image\"] {\n      transition: none;\n    }\n  }\n}\n"}],"categories":["free-marketing"],"meta":{"tier":"free","license":"MIT","family":"reading-proof","docs":"/docs/components/blog-grid"}}