{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-attachment",
  "title": "Attachment",
  "description": "The document card Messages draws instead of a text bubble, with the file icon, name and size, in the bubble's own shape.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/bubble-shape.json",
    "https://imessage.swerdlow.dev/r/platform.json"
  ],
  "files": [
    {
      "path": "registry/imessage/message-attachment.tsx",
      "content": "\"use client\";\n\nimport { useId, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { bubblePath, tailBox } from \"@/components/message-ui/bubble-shape\";\nimport { usePlatform, type Platform } from \"@/components/message-ui/platform\";\n\n/**\n * A file attachment message: the document card Messages draws instead of a text bubble.\n *\n * macOS numbers are measured from `references/macos/captures/attachment-not-delivered-dark-2x.png`\n * (960×640 @2x, so every pixel pair is one point):\n *\n * | Part | Measurement |\n * |---|---|\n * | Card | body 275 × 88.75 (x 17–292, y 5.5–94.4 in the crop), plus the 0.7-scale tail hanging 4.6 |\n * | Corner | best circle 14.5 (rmse 0.28 px on both top corners); the earlier 18 was off by 2.7 px |\n * | Fill | #3b3b3d dark (the incoming gray — file cards are gray in both directions) |\n * | Icon | 38.6 × 51.6 page at (22.7, 18.6) inside the card, 14 fold, 2 corner radius |\n * | Filename | 12pt semibold #f7f7f7 tracked −0.5 (ink 90.0 × 11.0, x-height 6.6), ink left 84.5, baseline 43.5 |\n * | Subtitle | 11pt #a6a6a9 tracked −0.45 (ink 128.5 × 10.0), ink left 84.0, baseline 58.5 |\n * | Failed | #eb534e ring, outer Ø 16.5, stroke 1.2; left edge 9.5 past the card, centre at 50.25 (body centre + 0.4) |\n * | Not Delivered | 10pt semibold #eb534e, ink 60.0 × 7.0, right ink edge 11.0 past the card's trailing edge |\n *\n * The corner is really a continuous one (superellipse radius 17.4, exponent 2.5, rmse 0.08 px), the same\n * shape the compact link card shows. `bubblePath` draws circular arcs so the card takes the best circle.\n * The badge and the label live in `ios-notices.tsx`; the numbers above are what they have to hit.\n *\n * `titleTop` and `subtitleTop` are the two baselines minus Chrome's baseline offset inside their line\n * boxes (12 for 12/15, 11 for 11/13), so the ink lands on the native baseline whenever the card sits on\n * whole points. A host that offsets the card by half a point (`/lab/ios-screens?scene=attachment` does,\n * through a `translateY(0.5px)`) puts the whole subtree in a composited layer whose text snaps a point at\n * a time, so every glyph and the icon land half a point off; that is the host's, not the card's.\n *\n * The iOS variant is that layout re-proportioned for the 17/13pt type world. **It is not verified\n * against a capture** — no iOS attachment frame exists in `references/` — so treat its numbers as a\n * considered guess, not a measurement.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * macOS is measured off the dark crop. iOS keeps the same shape but takes its own measured grays, the\n * incoming bubble (#e9e9eb / #262629) and the secondary label (#8a8a8e / #8d8d93): #3b3b3d is the macOS\n * incoming gray and reads wrong beside iOS bubbles.\n */\nconst vars: Record<Platform, string> = {\n  macos:\n    \"[--im-att-fill:#e9e9eb] [--im-att-title:#000000] [--im-att-subtitle:#6e6e6d] \" +\n    \"dark:[--im-att-fill:#3b3b3d] dark:[--im-att-title:#f7f7f7] dark:[--im-att-subtitle:#a6a6a9]\",\n  ios:\n    \"[--im-att-fill:#e9e9eb] [--im-att-title:#000000] [--im-att-subtitle:#8a8a8e] \" +\n    \"dark:[--im-att-fill:#262629] dark:[--im-att-title:#ffffff] dark:[--im-att-subtitle:#8d8d93]\",\n};\n\ntype AttachmentMetrics = {\n  width: number; height: number; radius: number; tailScale: number;\n  iconWidth: number; iconHeight: number; iconLeft: number; iconTop: number;\n  textLeft: number; textRight: number;\n  titleSize: number; titleLine: number; titleTop: number; titleTracking: number;\n  subtitleSize: number; subtitleLine: number; subtitleTop: number; subtitleTracking: number;\n};\n\n/** macOS is measured; iOS is the same layout scaled to 17/13pt type and is unverified. */\nexport const attachmentMetrics: Record<Platform, AttachmentMetrics> = {\n  macos: {\n    width: 275, height: 88.75, radius: 14.5, tailScale: 0.7,\n    iconWidth: 38.6, iconHeight: 51.6, iconLeft: 22.7, iconTop: 18.6,\n    textLeft: 83.75, textRight: 12,\n    titleSize: 12, titleLine: 15, titleTop: 31.5, titleTracking: -0.5,\n    subtitleSize: 11, subtitleLine: 13, subtitleTop: 47.5, subtitleTracking: -0.45,\n  },\n  ios: {\n    width: 280.5, height: 100, radius: 19, tailScale: 1,\n    iconWidth: 44, iconHeight: 58, iconLeft: 18, iconTop: 21,\n    textLeft: 74, textRight: 14,\n    titleSize: 17, titleLine: 22, titleTop: 31, titleTracking: 0,\n    subtitleSize: 13, subtitleLine: 16, subtitleTop: 53, subtitleTracking: 0,\n  },\n};\n\n/**\n * The page with a folded corner that macOS draws for a generic document, traced off the same crop.\n *\n * The user space is the page's own 38.6 × 51.6 box. Its top-right corner is cut at 45° over the last 14,\n * and the flap folds back onto the page as the triangle (24.6,0)–(38.6,14)–(24.6,14). Sampling the crop on\n * a grid gives a flat #f5f5f5 body shading one level per point of distance along the (1,−1) diagonal, down\n * to about #e0e0e0 at the cut; the flap runs the other way, #e7e7e7 at the cut up to #ffffff about 4.6 in,\n * and drops a short shadow along its two straight edges. The page sits on a soft shadow of its own (peak\n * alpha ≈ 0.3 at the bottom edge, gone by 3 out).\n *\n * `preserveAspectRatio=\"none\"` because the drawing is the box: any letterboxing would offset the whole\n * page by a fraction of a point against the card.\n */\nfunction DocumentIcon({ width, height }: { width: number; height: number }) {\n  const id = useId();\n  return (\n    <svg aria-hidden=\"true\" width={width} height={height} viewBox=\"0 0 38.6 51.6\" preserveAspectRatio=\"none\"\n      style={{ display: \"block\", overflow: \"visible\", filter: \"drop-shadow(0 0.45px 1.2px rgba(0,0,0,0.42))\" }}>\n      <defs>\n        {/* Both ramps run along the page's (1,−1) diagonal, in the page's own coordinates. */}\n        <linearGradient id={`${id}-page`} gradientUnits=\"userSpaceOnUse\" x1=\"10.74\" y1=\"14.28\" x2=\"25.02\" y2=\"0\">\n          <stop offset=\"0\" stopColor=\"#f5f5f5\" />\n          <stop offset=\"1\" stopColor=\"#e0e0e0\" />\n        </linearGradient>\n        <linearGradient id={`${id}-flap`} gradientUnits=\"userSpaceOnUse\" x1=\"31.6\" y1=\"7\" x2=\"28.35\" y2=\"10.25\">\n          <stop offset=\"0\" stopColor=\"#e7e7e7\" />\n          <stop offset=\"1\" stopColor=\"#ffffff\" />\n        </linearGradient>\n        <linearGradient id={`${id}-crease-x`} gradientUnits=\"userSpaceOnUse\" x1=\"21.6\" y1=\"0\" x2=\"24.6\" y2=\"0\">\n          <stop offset=\"0\" stopColor=\"#000000\" stopOpacity=\"0\" />\n          <stop offset=\"1\" stopColor=\"#000000\" stopOpacity=\"0.09\" />\n        </linearGradient>\n        <linearGradient id={`${id}-crease-y`} gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1=\"14\" x2=\"0\" y2=\"16\">\n          <stop offset=\"0\" stopColor=\"#000000\" stopOpacity=\"0.06\" />\n          <stop offset=\"1\" stopColor=\"#000000\" stopOpacity=\"0\" />\n        </linearGradient>\n      </defs>\n      <path d=\"M2 0h22.6l14 14v35.6a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2z\" fill={`url(#${id}-page)`} />\n      <rect x=\"21.6\" y=\"0\" width=\"3\" height=\"14\" fill={`url(#${id}-crease-x)`} />\n      <rect x=\"24.6\" y=\"14\" width=\"14\" height=\"2\" fill={`url(#${id}-crease-y)`} />\n      <path d=\"M24.6 0l14 14H24.6z\" fill={`url(#${id}-flap)`} />\n    </svg>\n  );\n}\n\nexport type MessageAttachmentProps = Omit<ComponentProps<\"div\">, \"children\" | \"title\"> & {\n  /** File name, e.g. \"design-notes.txt\". */\n  name: string;\n  /** Kind, e.g. \"Text Document\". Joined with `size` by a middle dot. */\n  kind?: string;\n  /** Human size, e.g. \"275 bytes\". Used alone when `kind` is absent. */\n  size?: string;\n  /** Overrides the whole second line. */\n  subtitle?: ReactNode;\n  /** Turns the card into a link. */\n  href?: string;\n  download?: boolean;\n  onOpen?: () => void;\n  direction?: \"incoming\" | \"outgoing\";\n  /** The card only tails when it ends a cluster, exactly like a text bubble. */\n  tail?: boolean;\n  platform?: Platform;\n  /** Custom artwork in place of the document page (a thumbnail, say). */\n  icon?: ReactNode;\n};\n\nexport function MessageAttachment({\n  name, kind, size, subtitle, href, download, onOpen,\n  direction = \"outgoing\", tail = false, platform: platformProp, icon, className, style, ...props\n}: MessageAttachmentProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = attachmentMetrics[platform];\n  const side = direction === \"outgoing\" ? \"right\" : \"left\";\n  const hang = tailBox.hang * m.tailScale;\n  const secondLine = subtitle ?? (kind && size ? `${kind} · ${size}` : (kind ?? size));\n\n  const surface: CSSProperties = { background: \"var(--im-att-fill)\" };\n  const Inner = href ? \"a\" : onOpen ? \"button\" : \"div\";\n  const innerProps = href\n    ? { href, download, target: href.startsWith(\"http\") ? \"_blank\" : undefined, rel: href.startsWith(\"http\") ? \"noreferrer\" : undefined }\n    : onOpen ? { type: \"button\" as const, onClick: onOpen } : {};\n\n  return (\n    <div data-slot=\"message-attachment\" data-direction={direction} data-platform={platform}\n      className={cn(\"relative select-none\", vars[platform], className)}\n      style={{ width: m.width, height: m.height, fontFamily: font, ...style }} {...props}>\n      {/*\n        Body and tail are one clipped box, not two adjacent ones. Chrome rasterises two abutting\n        clip paths independently and snaps the second to a whole device pixel, which left a 1px\n        hairline of the page showing along the tail's leading and top edges at 2x (the card's\n        88.75 height puts that join on a half device pixel).\n      */}\n      <div aria-hidden=\"true\" data-slot=\"fill\" className=\"pointer-events-none absolute left-0 top-0 w-full\"\n        style={tail\n          ? { height: m.height + hang, clipPath: `path(\"${bubblePath(m.width, m.height, side, m.radius, m.tailScale)}\")`, ...surface }\n          : { height: m.height, borderRadius: m.radius, ...surface }} />\n      <Inner data-slot=\"card\" {...innerProps}\n        className=\"absolute inset-0 block text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n        style={{ borderRadius: m.radius }}>\n        <span aria-hidden=\"true\" data-slot=\"icon\" className=\"absolute flex items-center justify-center\"\n          style={{ left: m.iconLeft, top: m.iconTop, width: m.iconWidth, height: m.iconHeight }}>\n          {icon ?? <DocumentIcon width={m.iconWidth} height={m.iconHeight} />}\n        </span>\n        <span data-slot=\"name\" className=\"absolute block overflow-hidden text-ellipsis whitespace-nowrap\"\n          style={{ left: m.textLeft, right: m.textRight, top: m.titleTop, fontSize: m.titleSize, lineHeight: `${m.titleLine}px`, fontWeight: 600, letterSpacing: m.titleTracking, color: \"var(--im-att-title)\" }}>\n          {name}\n        </span>\n        {secondLine !== undefined && (\n          <span data-slot=\"subtitle\" className=\"absolute block overflow-hidden text-ellipsis whitespace-nowrap\"\n            style={{ left: m.textLeft, right: m.textRight, top: m.subtitleTop, fontSize: m.subtitleSize, lineHeight: `${m.subtitleLine}px`, letterSpacing: m.subtitleTracking, color: \"var(--im-att-subtitle)\" }}>\n            {secondLine}\n          </span>\n        )}\n      </Inner>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/message-attachment.tsx"
    }
  ],
  "type": "registry:ui"
}
