{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "link-preview",
  "title": "Link preview",
  "description": "The measured compact link card, plus a rich variant with an image, title and host.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/platform.json",
    "https://imessage.swerdlow.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/imessage/link-preview.tsx",
      "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/message-ui/platform\";\nimport { fontStack } from \"@/components/message-ui/tokens\";\n\n/**\n * The link card Messages shows for a URL without rich metadata: a gray rounded card with the hostname on\n * the left and a Safari compass disc on the right. With `media` (or an `image`) it becomes the rich card:\n * the image on top, then the title and hostname.\n *\n * macOS metrics are measured from the apple.com card in `references/macos/captures/conversation-pane-light.png`\n * and `conversation-pane-dark-2.png` (630x640 pt @2x, so every pixel pair is one point):\n *\n * | Part | Measurement |\n * |---|---|\n * | Card | 140 x 60 (x 470-610, y 179-239 in the light capture), radius 14 |\n * | Fill | #e9e9eb light / #3b3b3d dark (the incoming gray) |\n * | Hostname | \"apple.com\" ink 49.5 x 10, left ink edge 10 in from the card, baseline 35 below the card top |\n * | Host colour | #808084 light / #a6a6a9 dark |\n * | Disc | 24 x 24, right edge 16 in from the card's right edge (centre 28 in), #757576 light / #a7a7a7 dark |\n * | Content shift | hostname baseline and disc centre both sit 1 below the card's vertical centre |\n *\n * The corner is really one of Apple's continuous corners: a superellipse of radius 17.375 and exponent\n * 2.5 fits the light capture to 0.09 px, where the best circle (14.3) is off by up to 0.27 pt. Radius 14\n * keeps the card on the same circular geometry as the macOS bubble, which is within that error.\n *\n * There is **no native capture of the iOS card or of either rich variant**. iOS reuses what iOS itself\n * measures - the 19 bubble radius, the incoming gray (#e9e9eb / #262629), the secondary label\n * (#8a8a8e / #8d8d93), the 280.5 max bubble width and the 13.85/10 bubble padding - and its card box,\n * hostname size and disc size are the macOS ones re-proportioned for 17pt type. Treat those as a\n * considered guess, not a measurement.\n */\nexport type LinkPreviewMetrics = {\n  width: number;\n  height: number;\n  radius: number;\n  hostSize: number;\n  hostLineHeight: number;\n  padStart: number;\n  disc: number;\n  /** Distance from the card's right edge to the disc's right edge. */\n  discInsetEnd: number;\n  /** Native draws the hostname and disc this much below the card's vertical center. */\n  contentShift: number;\n  richWidth: number;\n  titleSize: number;\n  titleLineHeight: number;\n  richPadX: number;\n  richPadY: number;\n};\n\nexport const linkPreviewMetrics: Record<Platform, LinkPreviewMetrics> = {\n  macos: { width: 140, height: 60, radius: 14, hostSize: 10, hostLineHeight: 12, padStart: 10, disc: 24, discInsetEnd: 16, contentShift: 1, richWidth: 280, titleSize: 12, titleLineHeight: 15, richPadX: 12.5, richPadY: 8 },\n  ios: { width: 198, height: 85, radius: 19, hostSize: 14, hostLineHeight: 17, padStart: 14, disc: 34, discInsetEnd: 23, contentShift: 1.4, richWidth: 280.5, titleSize: 17, titleLineHeight: 20, richPadX: 13.85, richPadY: 10 },\n};\n\nexport type LinkPreviewProps = Omit<ComponentProps<\"a\">, \"title\" | \"children\" | \"href\" | \"media\"> & {\n  href: string;\n  title?: string;\n  description?: string;\n  /** Hostname label; defaults to the URL's host without \"www.\". */\n  host?: string;\n  /** Rich card image slot (any node, e.g. an <img>). */\n  media?: ReactNode;\n  /** Convenience for the rich card: an image URL rendered in the media slot. */\n  image?: string;\n  /** Description of the preview image for assistive technology. */\n  imageAlt?: string;\n  platform?: Platform;\n};\n\nfunction SafariDisc({ size }: { size: number }) {\n  return (\n    <svg aria-hidden=\"true\" data-slot=\"safari-icon\" viewBox=\"0 0 24 24\" width={size} height={size} style={{ display: \"block\", color: \"var(--im-card-icon)\" }}>\n      <circle cx=\"12\" cy=\"12\" r=\"12\" fill=\"currentColor\" />\n      <g transform=\"rotate(45 12 12)\">\n        <path d=\"M12 3.6 L14.2 12 L12 20.4 L9.8 12 Z\" fill=\"var(--im-card)\" stroke=\"var(--im-card)\" strokeWidth=\"0.9\" strokeLinejoin=\"round\" />\n        <circle cx=\"12\" cy=\"12\" r=\"1.5\" fill=\"currentColor\" />\n      </g>\n    </svg>\n  );\n}\n\n/**\n * macOS values are measured off the apple.com card. iOS swaps in its own measured grays - the incoming\n * bubble (#e9e9eb / #262629) and the secondary label (#8a8a8e / #8d8d93) - because #3b3b3d is the macOS\n * incoming gray and reads wrong next to iOS bubbles. The Safari disc keeps the macOS gray: nothing on\n * iOS measures it.\n */\nconst themeVars: Record<Platform, string> = {\n  macos: \"[--im-card:#e9e9eb] [--im-card-fg:#808084] [--im-card-icon:#757576] [--im-card-title:#000000] dark:[--im-card:#3b3b3d] dark:[--im-card-fg:#a6a6a9] dark:[--im-card-icon:#a7a7a7] dark:[--im-card-title:#ffffff]\",\n  ios: \"[--im-card:#e9e9eb] [--im-card-fg:#8a8a8e] [--im-card-icon:#757576] [--im-card-title:#000000] dark:[--im-card:#262629] dark:[--im-card-fg:#8d8d93] dark:[--im-card-icon:#a7a7a7] dark:[--im-card-title:#ffffff]\",\n};\n\nexport function LinkPreview({ href, title, description, host, media, image, imageAlt = \"\", platform: platformProp, className, style, target, rel, ...props }: LinkPreviewProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = linkPreviewMetrics[platform];\n  let url: URL;\n  try { url = new URL(href); } catch { throw new Error(\"LinkPreview requires an absolute HTTP(S) URL.\"); }\n  if (![\"https:\", \"http:\"].includes(url.protocol)) throw new Error(\"LinkPreview requires an absolute HTTP(S) URL.\");\n  const hostname = host ?? url.hostname.replace(/^www\\./, \"\");\n  // A plain <img>: registry components stay framework-agnostic (pass `media` to use next/image).\n  // eslint-disable-next-line @next/next/no-img-element\n  const mediaNode = media ?? (image ? <img src={image} alt={imageAlt} style={{ display: \"block\", width: \"100%\", height: \"100%\", objectFit: \"cover\" }} /> : null);\n  const rich = Boolean(mediaNode || title || description);\n  const shared = { fontFamily: fontStack, letterSpacing: 0, color: \"var(--im-card-fg)\", background: \"var(--im-card)\", borderRadius: m.radius, textDecoration: \"none\", ...style };\n  if (!rich) {\n    return (\n      <a href={url.href} target={target} rel={rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined)} data-slot=\"link-preview\" data-variant=\"compact\" data-platform={platform}\n        className={cn(\"relative block overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500\", themeVars[platform], className)}\n        style={{ ...shared, width: m.width, height: m.height, boxSizing: \"border-box\" }} aria-label={title ? `${title}, ${hostname}` : hostname} {...props}>\n        <span data-slot=\"host\" className=\"absolute truncate\" style={{ left: m.padStart, right: m.discInsetEnd + m.disc + m.padStart / 2, top: (m.height - m.hostLineHeight) / 2 + m.contentShift, fontSize: m.hostSize, lineHeight: `${m.hostLineHeight}px` }}>{hostname}</span>\n        <span className=\"absolute\" style={{ right: m.discInsetEnd, top: (m.height - m.disc) / 2 + m.contentShift, width: m.disc, height: m.disc }}><SafariDisc size={m.disc} /></span>\n      </a>\n    );\n  }\n  return (\n    <a href={url.href} target={target} rel={rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined)} data-slot=\"link-preview\" data-variant=\"rich\" data-platform={platform}\n      className={cn(\"block max-w-full overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500\", themeVars[platform], className)}\n      style={{ ...shared, width: m.richWidth }} {...props}>\n      {mediaNode && <div data-slot=\"media\" className=\"overflow-hidden\" style={{ aspectRatio: \"1.91 / 1\", background: \"color-mix(in srgb, var(--im-card-fg) 20%, var(--im-card))\" }}>{mediaNode}</div>}\n      <div data-slot=\"meta\" style={{ padding: `${m.richPadY}px ${m.richPadX}px` }}>\n        {title && <p data-slot=\"title\" className=\"m-0 line-clamp-2\" style={{ fontSize: m.titleSize, lineHeight: `${m.titleLineHeight}px`, fontWeight: 600, color: \"var(--im-card-title)\" }}>{title}</p>}\n        {description && <p data-slot=\"description\" className=\"m-0 line-clamp-2\" style={{ fontSize: m.hostSize, lineHeight: `${m.hostLineHeight}px`, marginTop: 2 }}>{description}</p>}\n        <p data-slot=\"host\" className=\"m-0 truncate\" style={{ fontSize: m.hostSize, lineHeight: `${m.hostLineHeight}px`, marginTop: title || description ? 2 : 0 }}>{hostname}</p>\n      </div>\n    </a>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/link-preview.tsx"
    }
  ],
  "type": "registry:ui"
}
