{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "imessage",
  "homepage": "https://imessage.swerdlow.dev",
  "items": [
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "platform",
      "title": "Platform context",
      "description": "Tells every iMessage component whether to render iOS 26 or macOS 26 metrics.",
      "files": [
        {
          "path": "registry/imessage/platform.tsx",
          "content": "\"use client\";\n\nimport { createContext, useContext, type ReactNode } from \"react\";\n\n/** Which native Messages app to replicate. Sizes, spacing, and chrome all key off this. */\nexport type Platform = \"ios\" | \"macos\";\n\nconst PlatformContext = createContext<Platform>(\"ios\");\n\nexport function PlatformProvider({ platform, children }: { platform: Platform; children: ReactNode }) {\n  return <PlatformContext.Provider value={platform}>{children}</PlatformContext.Provider>;\n}\n\nexport function usePlatform(): Platform {\n  return useContext(PlatformContext);\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/platform.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "tokens",
      "title": "Design tokens",
      "description": "Measured colors, screen-space bubble gradients, type scale, and spacing for iOS and macOS.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json"
      ],
      "files": [
        {
          "path": "registry/imessage/tokens.ts",
          "content": "import type { Platform } from \"@/components/imessage/platform\";\n\n/**\n * Measured design tokens (see references/SPEC.md). Points equal CSS px.\n * Colors that vary with vertical screen position are expressed as top/bottom pairs of a\n * screen-space linear gradient; `screenHeight` is the reference height they were measured on.\n */\nexport type Service = \"imessage\" | \"sms\";\nexport type Direction = \"incoming\" | \"outgoing\";\n\nexport type BubbleMetrics = {\n  fontSize: number;\n  lineHeight: number;\n  paddingX: number;\n  paddingY: number;\n  radius: number;\n  minWidth: number;\n  /** Widest bubble in px (iOS uses a fixed width at 402pt screens). */\n  maxWidth: number;\n  /** Fraction of the message pane width a bubble may occupy (macOS). */\n  maxWidthRatio: number;\n  /** Scale applied to the traced iOS tail (1 on iOS, 0.7 on macOS). */\n  tailScale: number;\n  /** Gap between consecutive bubbles of the same group, and between groups. */\n  gapInGroup: number;\n  gapBetweenGroups: number;\n  /** Inset from the pane edge on the bubble's own side. */\n  edgeInset: number;\n  /** Whether every bubble gets a tail. Both platforms only tail the last bubble of a cluster. */\n  tailOnEveryBubble: boolean;\n  /** Status label (\"Delivered\") typography and offsets. Always semibold. */\n  statusFontSize: number;\n  statusLineHeight: number;\n  statusLetterSpacing: number;\n  statusGap: number;\n  statusInset: number;\n  /** Large glyph size, line box, and edge inset for emoji-only messages. */\n  emojiOnlySize: number;\n  emojiOnlyLineHeight: number;\n  emojiOnlyInset: number;\n  /** Tracking applied to bubble text so the web's SF matches native widths. */\n  letterSpacing: number;\n};\n\nexport const bubbleMetrics: Record<Platform, BubbleMetrics> = {\n  ios: {\n    fontSize: 17,\n    lineHeight: 20,\n    paddingX: 13.85,\n    paddingY: 10,\n    radius: 19,\n    minWidth: 48,\n    maxWidth: 280.5,\n    /** Unverified: 0.17 under `maxWidth`. `message-list` uses the ratio on iOS too, so a bubble is\n     * 280.33 wide in a list and 280.5 standalone. The widest body in `conv3-light.png` measures 280.67. */\n    maxWidthRatio: 280.33 / 402,\n    tailScale: 1,\n    gapInGroup: 4.3333,\n    // 10.33 cannot reproduce the 50.000 pt step native puts between the third \"V\" and the long\n    // bubble: solving all seven row origins at once pins this to 10.168-10.25.\n    gapBetweenGroups: 10.2,\n    edgeInset: 16,\n    tailOnEveryBubble: false,\n    statusFontSize: 11,\n    statusLineHeight: 13,\n    statusLetterSpacing: -0.25,\n    statusGap: 4.65,\n    statusInset: 20.3,\n    /** Unverified: no iOS capture holds an emoji-only message. Scaled from the measured macOS 72/87.3. */\n    emojiOnlySize: 58,\n    emojiOnlyLineHeight: 70,\n    emojiOnlyInset: 4,\n    letterSpacing: 0,\n  },\n  macos: {\n    fontSize: 13,\n    lineHeight: 14.7,\n    paddingX: 12.5,\n    paddingY: 7.03,\n    // 14.5, not 14. An arc anchored to the four-line bubble's own measured straight edges (left\n    // 227.477, top 493.976 in conversation-pane-light.png) fits 25 sub-pixel rows of the top\n    // corners at 0.119 rmse; r = 14 fits at 0.194 with all 25 residuals on the same side. The same\n    // routine run against our own 14.5 render returns 14.42, so native's 14.39 reads as 14.46 once\n    // that bias is removed. What is left at 14.5 is a symmetric S of +-0.15, twice our own render's,\n    // which is the shape difference of a slightly continuous corner, not a radius error.\n    radius: 14.5,\n    minWidth: 40,\n    maxWidth: 382.5,\n    maxWidthRatio: 0.6068,\n    tailScale: 0.7,\n    // Native lands every row's top on the device grid (0.5 at 2x), so a single gap reads anywhere\n    // from 2.75 to 3.25. The run of six one-line bubbles in conversation-pane-dark.png settles it:\n    // tops 379.5 / 411.5 / 443.0 / 475.0 / 506.5 / 538.5, a mean pitch of 31.7997 over five gaps\n    // against a body height of 28.7545, so the gap is 3.045 +- 0.1. Do not re-derive it from one\n    // pair of bubbles in a pane capture; those read 3.24 or 2.75 depending on the phase.\n    gapInGroup: 3,\n    gapBetweenGroups: 11.5,\n    edgeInset: 20,\n    tailOnEveryBubble: false,\n    statusFontSize: 9,\n    statusLineHeight: 11,\n    statusLetterSpacing: 0,\n    statusGap: 4,\n    statusInset: 15.9,\n    emojiOnlySize: 72,\n    emojiOnlyLineHeight: 87.3,\n    emojiOnlyInset: 4,\n    letterSpacing: -0.4,\n  },\n};\n\nexport type ScreenGradient = { top: string; bottom: string; screenHeight: number };\n\nexport type Palette = {\n  background: string;\n  /** Outgoing iMessage blue, screen-space gradient. */\n  imessage: ScreenGradient;\n  /** Outgoing SMS green, screen-space gradient. */\n  sms: ScreenGradient;\n  /** Incoming gray, screen-space gradient (nearly flat). */\n  incoming: ScreenGradient;\n  incomingText: string;\n  outgoingText: string;\n  secondaryLabel: string;\n  edited: string;\n  /** Tapback balloon fills. */\n  tapbackMine: string;\n  tapbackTheirs: string;\n  separator: string;\n};\n\n/**\n * iOS 26 measured: SMS green from the simulator. The blue and gray gradients are taken from macOS 26,\n * which shares Messages' palette; they are marked as such in SPEC.md until an iOS capture confirms them.\n *\n * Not measured, and not in SPEC.md either: `macos.*.sms` (no macOS capture holds a green bubble; the\n * only green in any of them is the plus-menu app icons), `macos.light.edited`, and both `ios.*.separator`\n * values. Treat them as placeholders, not as measurements.\n */\nexport const palettes: Record<Platform, { light: Palette; dark: Palette }> = {\n  ios: {\n    light: {\n      background: \"#ffffff\",\n      imessage: { top: \"#77c7f5\", bottom: \"#3682f7\", screenHeight: 874 },\n      sms: { top: \"#53e678\", bottom: \"#31c355\", screenHeight: 874 },\n      incoming: { top: \"#e9e9eb\", bottom: \"#e9e9eb\", screenHeight: 874 },\n      incomingText: \"#000000\",\n      outgoingText: \"#ffffff\",\n      secondaryLabel: \"#8a8a8e\",\n      edited: \"#3f8ff7\",\n      tapbackMine: \"#0088ff\",\n      tapbackTheirs: \"#e9e9eb\",\n      separator: \"#c6c6c8\",\n    },\n    dark: {\n      background: \"#000000\",\n      imessage: { top: \"#589af7\", bottom: \"#3d8ef7\", screenHeight: 874 },\n      sms: { top: \"#53e678\", bottom: \"#31c355\", screenHeight: 874 },\n      incoming: { top: \"#262629\", bottom: \"#262629\", screenHeight: 874 },\n      incomingText: \"#ffffff\",\n      outgoingText: \"#ffffff\",\n      secondaryLabel: \"#8d8d93\",\n      edited: \"#3f8ff7\",\n      tapbackMine: \"#0088ff\",\n      tapbackTheirs: \"#262629\",\n      separator: \"#38383a\",\n    },\n  },\n  macos: {\n    light: {\n      background: \"#ffffff\",\n      imessage: { top: \"#77c7f5\", bottom: \"#3682f7\", screenHeight: 640 },\n      sms: { top: \"#5fe083\", bottom: \"#35c65b\", screenHeight: 640 },\n      incoming: { top: \"#e9e9eb\", bottom: \"#e9e9eb\", screenHeight: 640 },\n      incomingText: \"#000000\",\n      outgoingText: \"#ffffff\",\n      secondaryLabel: \"#808080\",\n      edited: \"#1f7cf5\",\n      tapbackMine: \"#5498f8\",\n      tapbackTheirs: \"#e9e9eb\",\n      separator: \"#e1e1e1\",\n    },\n    dark: {\n      background: \"#1e1e1e\",\n      imessage: { top: \"#589af7\", bottom: \"#3d8ef7\", screenHeight: 640 },\n      sms: { top: \"#4fd772\", bottom: \"#33bf56\", screenHeight: 640 },\n      incoming: { top: \"#38383a\", bottom: \"#3c3c3e\", screenHeight: 640 },\n      incomingText: \"#ffffff\",\n      outgoingText: \"#ffffff\",\n      secondaryLabel: \"#9a9a9a\",\n      edited: \"#3f8ff7\",\n      tapbackMine: \"#5498f8\",\n      tapbackTheirs: \"#3b3b3d\",\n      separator: \"#3a3a3a\",\n    },\n  },\n};\n\n/** CSS custom properties that carry a palette, so components stay theme-aware through `.dark`. */\nexport function paletteVars(p: Palette): Record<string, string> {\n  return {\n    \"--im-bg\": p.background,\n    \"--im-blue-top\": p.imessage.top,\n    \"--im-blue-bottom\": p.imessage.bottom,\n    \"--im-green-top\": p.sms.top,\n    \"--im-green-bottom\": p.sms.bottom,\n    \"--im-gray-top\": p.incoming.top,\n    \"--im-gray-bottom\": p.incoming.bottom,\n    \"--im-incoming-text\": p.incomingText,\n    \"--im-outgoing-text\": p.outgoingText,\n    \"--im-secondary\": p.secondaryLabel,\n    \"--im-edited\": p.edited,\n    \"--im-tapback-mine\": p.tapbackMine,\n    \"--im-tapback-theirs\": p.tapbackTheirs,\n    \"--im-separator\": p.separator,\n    \"--im-screen-h\": `${p.imessage.screenHeight}px`,\n  };\n}\n\n/** System font stacks. `-apple-system` resolves to SF Pro on Apple platforms, which is what native renders. */\nexport const fontStack = '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\nexport const emojiFontStack = '\"Apple Color Emoji\", \"Segoe UI Emoji\", \"Noto Color Emoji\", sans-serif';\n",
          "type": "registry:ui",
          "target": "components/imessage/tokens.ts"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "bubble-shape",
      "title": "Bubble shape",
      "description": "The traced native bubble outline and tail as SVG path and clip-path generators.",
      "files": [
        {
          "path": "registry/imessage/bubble-shape.ts",
          "content": "/**\n * Native iMessage bubble geometry, traced from iOS 26 Messages (see references/SPEC.md and\n * references/ios/bubble-tail-beziers.json). All values are points (CSS px).\n *\n * The bubble body is a rounded rectangle with circular corners. On the sender's side the bottom\n * corner is replaced by the tail: the edge sweeps inward over the last ~19pt of height into a\n * neck, bulges back out, and ends in a point that hangs ~6.8pt below the body. The tail region is\n * a fixed 22×28pt box anchored at the body's bottom corner, so it never scales with the bubble.\n */\nexport type TailSide = \"left\" | \"right\" | \"none\";\n\n/** Cubic Béziers relative to the body's bottom-right corner (x grows right, y grows down). */\nconst TAIL_SEGMENTS: Array<[number, number, number, number, number, number]> = [\n  // S-curve from the straight right edge down to the neck\n  [-0.442, -13.682, -3.003, -8.238, -7.457, -4.87],\n  [-9.101, -3.553, -10.598, -1.75, -10.513, 0.463],\n  // bulge from the neck down to the tip\n  [-10.766, 2.71, -8.409, 4.088, -8.319, 6.13],\n  // rounded tip\n  [-8.33, 6.55, -8.6, 6.75, -9.0, 6.698],\n  // underside from the tip back to the body's bottom edge\n  [-14.001, 6.333, -17.413, 1.539, -22.0, 0],\n];\nconst TAIL_START_Y = -19.203; // where the straight edge ends, relative to the body bottom\nconst TAIL_EDGE_START = -21.2; // clip box top: a little above so the arc/straight edge is untouched\n\nexport const tailBox = { width: 22, height: 21.2, hang: 6.8 } as const;\n\nfunction fmt(n: number) {\n  return Number(n.toFixed(3)).toString();\n}\n\n/**\n * SVG path for the fixed tail region. The path is in a `tailBox.width × (tailBox.height + tailBox.hang)`\n * coordinate system whose top-left is (bodyRight − 22, bodyBottom − 21.2). For a left tail it is mirrored.\n */\nexport function tailPath(side: Exclude<TailSide, \"none\">, scale = 1): string {\n  const w = tailBox.width * scale;\n  const h = tailBox.height * scale;\n  const mx = (x: number) => (side === \"right\" ? w + x * scale : -x * scale);\n  const my = (y: number) => h + y * scale;\n  const parts = [`M${fmt(mx(-22))},${fmt(my(TAIL_EDGE_START))}`, `L${fmt(mx(0))},${fmt(my(TAIL_EDGE_START))}`, `L${fmt(mx(0))},${fmt(my(TAIL_START_Y))}`];\n  for (const [x1, y1, x2, y2, x, y] of TAIL_SEGMENTS) {\n    parts.push(`C${fmt(mx(x1))},${fmt(my(y1))} ${fmt(mx(x2))},${fmt(my(y2))} ${fmt(mx(x))},${fmt(my(y))}`);\n  }\n  parts.push(\"Z\");\n  return parts.join(\" \");\n}\n\n/**\n * How far the body's clip has to reach into the tail box so the two do not leave a hairline where\n * they meet. Both platforms need it, and the reason is Chrome, not the geometry: a `clip-path`\n * reference box is snapped to whole CSS px before it rasterises, and the body's box and the tail's\n * box snap independently. Their shared edge then lands on two different device rows and the\n * background shows through the row between them - a white line running out of the tail across the\n * bubble, which is exactly what it looks like.\n *\n * 0.75 was measured, not guessed: `scripts/measure/hairline.ts` over `/lab?scene=ios-conv3` at\n * twelve sub-pixel offsets of the message column still finds the seam at 0.25 and never finds it\n * from 0.5 up, so 0.75 keeps a quarter point of margin. The ceiling is the tail's own straight top\n * segment, `(tailBox.height - 19.203) * scale` = 1.997 on iOS and 1.398 on macOS; past that the\n * body would paint outside the tail's outline.\n */\nexport const tailSeamOverlap: Record<\"ios\" | \"macos\", number> = { ios: 0.75, macos: 0.75 };\n\n/**\n * clip-path polygon that removes the tail box from a plain rounded-rectangle body so the tail SVG\n * can draw that region exactly. Use with `border-radius` for the three untouched corners.\n *\n * `overlap` shrinks the removed box along its two interior edges, so the body keeps painting that\n * far *into* the tail box and covers the seam described on `tailSeamOverlap`. Both edges are\n * interior to the tail's own outline, so the body can only become visible there if it reaches past\n * the tail's straight top segment.\n */\nexport function bodyClipPath(side: Exclude<TailSide, \"none\">, scale = 1, overlap = 0): string {\n  const w = tailBox.width * scale - overlap;\n  const h = tailBox.height * scale - overlap;\n  return side === \"right\"\n    ? `polygon(0 0, 100% 0, 100% calc(100% - ${fmt(h)}px), calc(100% - ${fmt(w)}px) calc(100% - ${fmt(h)}px), calc(100% - ${fmt(w)}px) 100%, 0 100%)`\n    : `polygon(0 0, 100% 0, 100% 100%, ${fmt(w)}px 100%, ${fmt(w)}px calc(100% - ${fmt(h)}px), 0 calc(100% - ${fmt(h)}px))`;\n}\n\n/** Full bubble outline as an SVG path (for masks, effects, and tests). */\nexport function bubblePath(width: number, height: number, side: TailSide, radius = 19, scale = 1): string {\n  const r = Math.min(radius, height / 2, width / 2);\n  if (side === \"none\") {\n    return `M${fmt(r)},0 H${fmt(width - r)} A${fmt(r)},${fmt(r)} 0 0 1 ${fmt(width)},${fmt(r)} V${fmt(height - r)} A${fmt(r)},${fmt(r)} 0 0 1 ${fmt(width - r)},${fmt(height)} H${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 1 0,${fmt(height - r)} V${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 1 ${fmt(r)},0 Z`;\n  }\n  const mx = (x: number) => (side === \"right\" ? width + x * scale : -x * scale);\n  const my = (y: number) => height + y * scale;\n  const tail: string[] = [`L${fmt(mx(0))},${fmt(my(TAIL_START_Y))}`];\n  for (const [x1, y1, x2, y2, x, y] of TAIL_SEGMENTS) tail.push(`C${fmt(mx(x1))},${fmt(my(y1))} ${fmt(mx(x2))},${fmt(my(y2))} ${fmt(mx(x))},${fmt(my(y))}`);\n  if (side === \"right\") {\n    return `M${fmt(r)},0 H${fmt(width - r)} A${fmt(r)},${fmt(r)} 0 0 1 ${fmt(width)},${fmt(r)} ${tail.join(\" \")} H${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 1 0,${fmt(height - r)} V${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 1 ${fmt(r)},0 Z`;\n  }\n  // left tail: walk the outline counter-clockwise so the mirrored tail segments read in order\n  return `M${fmt(width - r)},0 H${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 0 0,${fmt(r)} ${tail.join(\" \")} H${fmt(width - r)} A${fmt(r)},${fmt(r)} 0 0 0 ${fmt(width)},${fmt(height - r)} V${fmt(r)} A${fmt(r)},${fmt(r)} 0 0 0 ${fmt(width - r)},0 Z`;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/bubble-shape.ts"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "use-screen-space",
      "title": "Screen-space fill hook",
      "description": "Keeps bubble fills aligned to the screen-space gradient while a list scrolls.",
      "files": [
        {
          "path": "registry/imessage/use-screen-space.ts",
          "content": "\"use client\";\n\nimport { useEffect, type RefObject } from \"react\";\n\n/**\n * Native bubbles are filled with one gradient fixed to the screen, so a bubble's color depends on\n * where it currently sits. This keeps every `[data-slot=\"message-bubble\"]` and every\n * `[data-slot=\"typing-indicator\"]` inside `container` in sync\n * by writing `--bubble-bottom` (the body's bottom edge relative to the screen frame) on scroll and\n * resize. It touches the DOM directly, so scrolling never re-renders React.\n *\n * `frame` is the element that represents the device screen (defaults to the container).\n */\nexport function useBubbleScreenSpace(container: RefObject<HTMLElement | null>, frame?: RefObject<HTMLElement | null>) {\n  useEffect(() => {\n    const root = container.current;\n    if (!root) return;\n    let raf = 0;\n    const update = () => {\n      raf = 0;\n      const screen = (frame?.current ?? root).getBoundingClientRect();\n      // The typing indicator is an incoming bubble too, so it needs the same screen-space fill.\n      root.querySelectorAll<HTMLElement>('[data-slot=\"message-bubble\"], [data-slot=\"typing-indicator\"]').forEach(bubble => {\n        // The typing indicator is its own body; a message bubble keeps its body in a child.\n        const body = bubble.querySelector<HTMLElement>('[data-slot=\"bubble\"], [data-slot=\"emoji\"]') ?? (bubble.dataset.slot === \"typing-indicator\" ? bubble : null);\n        if (!body) return;\n        const bottom = body.getBoundingClientRect().bottom - screen.top;\n        bubble.style.setProperty(\"--bubble-bottom\", `${bottom.toFixed(2)}px`);\n      });\n    };\n    const schedule = () => { if (!raf) raf = requestAnimationFrame(update); };\n    update();\n    root.addEventListener(\"scroll\", schedule, { passive: true });\n    const observer = new ResizeObserver(schedule);\n    observer.observe(root);\n    const mutations = new MutationObserver(schedule);\n    mutations.observe(root, { childList: true, subtree: true, characterData: true });\n    return () => { cancelAnimationFrame(raf); root.removeEventListener(\"scroll\", schedule); observer.disconnect(); mutations.disconnect(); };\n  }, [container, frame]);\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/use-screen-space.ts"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-bubble",
      "title": "Message bubble",
      "description": "Native iOS 26 and macOS 26 bubbles: traced outline and tail, screen-space gradient fill, status, edited, reactions, and emoji-only messages.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-bubble.tsx",
          "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/imessage/bubble-shape\";\nimport { bubbleMetrics, emojiFontStack, fontStack, type Direction, type Service } from \"@/components/imessage/tokens\";\n\nexport type MessageBubbleProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  direction?: Direction;\n  service?: Service;\n  /** Draw the tail. Both platforms only tail the last bubble of a cluster; the list decides. */\n  tail?: boolean;\n  /** Group-chat sender name shown above an incoming bubble. */\n  sender?: string;\n  /** \"Delivered\", \"Read 9:41 AM\", \"Sent as Text Message\"… */\n  status?: ReactNode;\n  edited?: boolean;\n  /** Tapback balloons; positioned on the bubble's top corner away from the screen edge. */\n  reactions?: ReactNode;\n  /** Render a big glyph with no bubble (auto-detected for 1–3 emoji when omitted). */\n  emojiOnly?: boolean;\n  /**\n   * Clicked once, so the balloon carries its selection overlay. macOS only; the message list drives\n   * this from `selectedIds` and the macOS pane owns the click. See `selectionOverlayClass`.\n   */\n  selected?: boolean;\n  /**\n   * Screen-space y of the bubble body's bottom edge, in px, for the native position-dependent fill.\n   * The message list keeps this updated while scrolling; leave it unset for a mid-screen color.\n   */\n  screenBottom?: number;\n  /** Widest the bubble may grow. Defaults to the native rule: 280.5px on iOS, 60.7% of the pane on macOS. */\n  maxWidth?: number | string;\n  platform?: Platform;\n  children?: ReactNode;\n};\n\nconst EMOJI_ONLY = /^(?:\\p{Extended_Pictographic}(?:️|‍\\p{Extended_Pictographic}|[\\u{1F3FB}-\\u{1F3FF}])*\\s?){1,3}$/u;\n\nexport function isEmojiOnly(text: ReactNode): boolean {\n  return typeof text === \"string\" && EMOJI_ONLY.test(text.trim());\n}\n\n/**\n * Where a reaction balloon hangs off a message's own box, per platform. It lives here because every\n * message kind needs it, not only a text bubble: a photo, a link card, an audio row and a bare emoji\n * all take reactions too, and each draws its own container.\n */\nexport const reactionOffsets: Record<Platform, { marginTop: number; top: number; side: number }> = {\n  ios: { marginTop: 28, top: -27.25, side: -14.1 },\n  macos: { marginTop: 19.6, top: -19.1, side: -9.9 },\n};\n\nfunction fillVars(direction: Direction, service: Service): CSSProperties {\n  const key = direction === \"incoming\" ? \"gray\" : service === \"sms\" ? \"green\" : \"blue\";\n  return { \"--im-fill-top\": `var(--im-${key}-top)`, \"--im-fill-bottom\": `var(--im-${key}-bottom)`, \"--im-sel\": `var(--im-sel-${key})` } as CSSProperties;\n}\n\n/**\n * The overlay a selected balloon carries, read out of macOS Messages itself rather than a screenshot:\n * `-[CKTextBalloonView setSelected:withSelectionState:]` turns on a highlight overlay layer whose\n * colour is `-[CKBalloonView highlightOverlayColor]`, which for a coloured balloon is\n * `-[CKUIThemeMac balloonOverlayColorForColorType:]`. Blue and gray are opaque there, so a selected\n * bubble drops its screen-space gradient for a flat fill; green is a wash left over it. Both themes\n * share the green. Values from ChatKit 26.5 (macOS 26.5).\n */\nexport const selectionOverlayClass =\n  \"[--im-sel-blue:#1b60d8] [--im-sel-gray:#c6c6c7] [--im-sel-green:#0a0a7833] \" +\n  \"dark:[--im-sel-blue:#0b50c8] dark:[--im-sel-gray:#55555c]\";\n\n/**\n * Native bubbles hug their longest wrapped line instead of stretching to the maximum width, and\n * center text that is narrower than the minimum bubble width. CSS shrink-to-fit cannot express either,\n * so measure the laid-out line boxes and set the frame width explicitly.\n */\nfunction useNativeTextFit(enabled: boolean, paddingX: number, minWidth: number, deps: unknown[]) {\n  // The ref is created here rather than passed in, so the DOM writes below are on a value this hook\n  // owns.\n  const frame = useRef<HTMLDivElement>(null);\n  useLayoutEffect(() => {\n    const frameEl = frame.current;\n    const textEl = frameEl?.querySelector<HTMLElement>('[data-slot=\"text\"]');\n    if (!enabled || !frameEl || !textEl) return;\n    const container = frameEl.parentElement;\n    let raf = 0;\n    const measure = () => {\n      frameEl.style.width = \"\";\n      const range = document.createRange();\n      range.selectNodeContents(textEl);\n      const lines: Array<{ top: number; left: number; right: number }> = [];\n      for (const rect of Array.from(range.getClientRects())) {\n        if (rect.width === 0 && rect.height === 0) continue;\n        const line = lines.find(l => Math.abs(l.top - rect.top) < 1);\n        if (line) { line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right); }\n        else lines.push({ top: rect.top, left: rect.left, right: rect.right });\n      }\n      if (!lines.length) return;\n      const longest = Math.max(...lines.map(l => l.right - l.left));\n      const bubble = textEl.parentElement as HTMLElement;\n      bubble.style.textAlign = lines.length === 1 && longest < minWidth - 2 * paddingX ? \"center\" : \"\";\n      const hug = Math.ceil((longest + 2 * paddingX) * 100) / 100 + 0.05;\n      if (lines.length > 1 && hug < frameEl.getBoundingClientRect().width - 0.1) frameEl.style.width = `${hug}px`;\n    };\n    measure();\n    const observer = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); });\n    if (container) observer.observe(container);\n    document.fonts?.ready.then(() => measure()).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [enabled, paddingX, minWidth, ...deps]);\n  return frame;\n}\n\nexport function MessageBubble({\n  direction = \"incoming\", service = \"imessage\", tail = false, sender, status, edited = false, reactions, emojiOnly, selected = false,\n  screenBottom, maxWidth, platform: platformProp, className, style, children, ...props\n}: MessageBubbleProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const outgoing = direction === \"outgoing\";\n  const side = outgoing ? \"right\" : \"left\";\n  const big = emojiOnly ?? isEmojiOnly(children);\n  const tailW = tailBox.width * m.tailScale;\n  const tailH = tailBox.height * m.tailScale;\n  const hang = tailBox.hang * m.tailScale;\n  // macOS's tail box is 15.4 × 14.84, so its left edge lands on a fraction (594.609 in the 630pt\n  // pane) and Chrome paints the tail's clip up to half a point off the body's clip edge, leaving a\n  // white hairline down the bubble (one 75%-white device pixel at 2x, measured at x 594.5 on the\n  // four-line bubble). Overlapping the body into the box closes it. iOS's box is 22 × 21.2 on whole\n  // pixels and shows no seam, so it keeps a flush join. See bodyClipPath.\n  const tailOverlap = tailSeamOverlap[platform];\n  const frame = useNativeTextFit(!big, m.paddingX, m.minWidth, [children, platform, maxWidth]);\n\n  // The fill is one gradient in screen coordinates. Anchor it to the bubble's bottom so the body and\n  // the tail (which hangs `hang` px lower) share the same image without knowing the body height.\n  // `--bubble-bottom` may also be set on the element directly (see use-screen-space.ts) so scrolling\n  // never needs a React render.\n  const bottomVar = screenBottom === undefined ? \"var(--bubble-bottom, calc(var(--im-screen-h) * 0.55))\" : `${screenBottom}px`;\n  // The selection overlay is one more background layer over the fill, so the body's clip and the\n  // tail's clip carry it for free and no extra element is needed.\n  const fill: CSSProperties = {\n    backgroundImage: `${selected ? \"linear-gradient(var(--im-sel), var(--im-sel)),\" : \"\"}linear-gradient(var(--im-fill-top), var(--im-fill-bottom))`,\n    backgroundSize: selected ? \"100% 100%, 100% var(--im-screen-h)\" : \"100% var(--im-screen-h)\",\n    backgroundRepeat: \"no-repeat\",\n    backgroundColor: \"var(--im-fill-bottom)\",\n  };\n  // background-position-y = 100% + K puts the image's bottom K below the element's bottom.\n  const at = (position: string) => (selected ? `0 0, ${position}` : position);\n  const bodyFill: CSSProperties = { ...fill, backgroundPosition: at(`0 calc(100% + (var(--im-screen-h) - ${bottomVar}))`) };\n  const tailFill: CSSProperties = { ...fill, backgroundPosition: at(`0 calc(100% + (var(--im-screen-h) - ${bottomVar} - ${hang}px))`) };\n  const vars = { ...(screenBottom === undefined ? {} : { \"--bubble-bottom\": `${screenBottom}px` }), ...fillVars(direction, service) } as CSSProperties;\n  const reactionOffset = reactionOffsets[platform];\n\n  return (\n    <div data-slot=\"message-bubble\" data-direction={direction} data-service={service} data-platform={platform} data-selected={selected ? \"true\" : undefined}\n      className={cn(\"flex min-w-0 flex-col\", selectionOverlayClass, outgoing ? \"items-end\" : \"items-start\", className)}\n      style={{ fontFamily: fontStack, ...vars, ...style }} {...props}>\n      {sender && <span data-slot=\"sender\" className=\"mb-[2px] px-[14px] text-[12px] leading-[14px]\" style={{ color: \"var(--im-secondary)\" }}>{sender}</span>}\n      <div ref={frame} data-slot=\"bubble-frame\" className=\"relative max-w-full\" style={{ maxWidth: maxWidth ?? (platform === \"ios\" ? m.maxWidth : `${m.maxWidthRatio * 100}%`), marginTop: reactions ? reactionOffset.marginTop : undefined }}>\n        {big ? (\n          <div data-slot=\"emoji\" style={{ fontSize: m.emojiOnlySize, lineHeight: `${m.emojiOnlyLineHeight}px`, fontFamily: emojiFontStack, padding: `0 ${m.emojiOnlyInset}px` }}>\n            <span className=\"sr-only\">{outgoing ? \"You: \" : `${sender ?? \"Contact\"}: `}</span>{children}\n          </div>\n        ) : (\n          <div data-slot=\"bubble\" className=\"relative whitespace-pre-wrap [overflow-wrap:anywhere]\" style={{\n            fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, letterSpacing: m.letterSpacing,\n            padding: `${m.paddingY}px ${m.paddingX}px`, minWidth: m.minWidth, textAlign: \"start\",\n            color: outgoing ? \"var(--im-outgoing-text)\" : \"var(--im-incoming-text)\",\n          }}>\n            {/* The fill lives behind the text so clipping the tail corner never clips glyphs.\n                Chrome rounds a painted background box to whole CSS px, so the widest macOS bubble,\n                whose layout box is 227.71875 to 610, paints 228.00 to 610.00: 0.29 narrower and\n                0.24 to the right of native's 227.477 to 609.763. No token can move that. A layout\n                left edge of 227.5 rounds to the same 228, and `will-change` does not opt out;\n                only a fractional `transform` escapes the rounding, and the send and receive\n                animations already own this element's transform, so it stays as it is. */}\n            {/* The tail-less case still carries a clip-path, and that is not cosmetic: Chrome snaps a\n                plain rounded-rect background to whole CSS pixels, so a body laid out at 100.333 paints\n                at 100.0, while native's bodies sit on thirds. A clip-path opts the fill out of that\n                snapping and it paints where the layout put it. Verified at 3x: 100.333 painted at\n                100.0 without one and at exactly device row 301 with one. */}\n            <div aria-hidden=\"true\" data-slot=\"fill\" className=\"pointer-events-none absolute inset-0\" style={{ borderRadius: m.radius, clipPath: tail ? bodyClipPath(side, m.tailScale, tailOverlap) : `inset(0 round ${m.radius}px)`, ...bodyFill }} />\n            {tail && <div aria-hidden=\"true\" data-slot=\"tail\" className=\"pointer-events-none absolute\" style={{\n              [side]: 0, bottom: -hang, width: tailW, height: tailH + hang, clipPath: `path(\"${tailPath(side, m.tailScale)}\")`, ...tailFill,\n            }} />}\n            <span className=\"sr-only\">{outgoing ? \"You: \" : `${sender ?? \"Contact\"}: `}</span>\n            <span data-slot=\"text\" className=\"relative\">{children}</span>\n          </div>\n        )}\n        {reactions && <div data-slot=\"reactions\" className=\"absolute z-10\" style={{ top: reactionOffset.top, [outgoing ? \"left\" : \"right\"]: reactionOffset.side }}>{reactions}</div>}\n      </div>\n      {edited && <span data-slot=\"edited\" className=\"mt-[3px] px-[14px] text-[11px] font-medium leading-[13px]\" style={{ color: \"var(--im-edited)\" }}>Edited</span>}\n      {status && <div data-slot=\"status\" style={{ fontSize: m.statusFontSize, lineHeight: `${m.statusLineHeight}px`, fontWeight: 600, letterSpacing: m.statusLetterSpacing, marginTop: m.statusGap, paddingInlineEnd: outgoing ? m.statusInset : 0, paddingInlineStart: outgoing ? 0 : m.statusInset, color: \"var(--im-secondary)\" }}>{status}</div>}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-bubble.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "tapback",
      "title": "Tapback",
      "description": "The native reaction balloon: six classic Tapbacks and custom emoji, own and others' colors, counts, and the selected state.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/tapback.tsx",
          "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, type ComponentProps, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { emojiFontStack, fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * Tapback balloon, measured from iOS 26 (`references/ios/captures/tapback-love-light.png`, 3x):\n * a Ø34 circle with two trailing circles (Ø10.4 at (−10.82, +15.20) and Ø5 at (−17.49, +23.46) from\n * the balloon's center) pointing away from the bubble. Own reactions are #0088ff (light and dark), the\n * heart is an 18.34×16.33 glossy pink shape centered 0.8 below the circle's center. Others' balloons are\n * #e9e9eb light / #262629 dark (`incoming-light.png`, `incoming-dark.png`). macOS (circle-fitted on\n * `references/macos/captures/tapback-love-light-2x.png` and `tapback-love-dark-2x.png`, both 2x, rmse\n * 0.01–0.03 pt): Ø27.98 centred (136.72, 57.45) pt, trail Ø8.04 at (−9.04, +12.42) and Ø4.00 at\n * (−14.22, +19.05), heart ink 14.64 × 12.96 centred 0.53 below the circle's centre. Both captures give\n * the same numbers to 0.01 pt.\n *\n * The macOS fill is one theme-independent blue, #5498f8, laid over the pane with a vertical opacity\n * ramp: solving the light/dark pair row by row gives a constant colour (85, 152, 248) and an alpha of\n * 0.912 at the top edge rising to 1.00 at ≈83% down (light row y=89 reads (100,161,249), dark\n * (80,141,229); by row y=139 both read (84,152,248)). The trailing circles are fully opaque.\n * The artwork is also knocked out of whatever it covers by a 0.5 pt rim in the pane colour: invisible\n * over the empty pane, it shows as a ring where the balloon laps the bubble (dark 2x, radial cut at\n * −50°: (84,151,247) → (55,88,133) → (84,152,248); light: (85,152,248) → (182,218,251) → (108,186,245)).\n */\nexport const tapbackLabels = { love: \"Love\", like: \"Like\", dislike: \"Dislike\", laugh: \"Laugh\", emphasize: \"Emphasize\", question: \"Question\" } as const;\nexport type TapbackType = keyof typeof tapbackLabels;\nexport const tapbackTypes = Object.keys(tapbackLabels) as TapbackType[];\n\n/** Local color tokens (not in tokens.ts). Measured own-reaction blue; the rest are system values. */\nexport const tapbackColors = {\n  own: \"#0088ff\",\n  ownDark: \"#0088ff\",\n  /** Highlight ring behind the chosen tapback inside the bar (Ø44.2; measured on both selected captures). */\n  selectedRing: \"#26aeff\",\n  selectedRingDark: \"#0064d2\",\n} as const;\n\n/**\n * The macOS own-reaction blue and the opacity ramp the main circle carries over it. Solved row by row\n * from the light/dark pair of `tapback-love-*-2x.png` at the balloon's own rows y 89–139 (2x): one\n * colour in both themes, alpha 0.907 at the top edge, 0.951 a quarter down, 0.982 at the centre and\n * opaque from ≈83% down. Reproducing it as alpha (not as two baked colours) keeps both themes right.\n */\nexport const macosBalloonFill = \"#5498f8\";\nconst macosBalloonSurface = \"linear-gradient(to bottom, rgba(84,152,248,0.907) 0%, rgba(84,152,248,0.951) 25%, rgba(84,152,248,0.982) 50%, rgba(84,152,248,1) 100%)\";\n/** The macOS balloon is knocked out of what it covers by this much pane colour (measured 0.51 pt). */\nexport const macosBalloonRim = 0.5;\n\n/**\n * CSS variables the tapback UI reads (with fallbacks to the light iOS values). Spread on a frame\n * next to `paletteVars`. Glass fills were solved from the captures: the pill over the dimmed white\n * list is #ededef and over the dimmed green bubble ≈#b4efc6 (brighter than the dimmed content, hence\n * the brightness term); over the dimmed black list it is #1f1e21. The dim itself is rgba(22,18,44,0.21).\n */\nexport function tapbackVars(theme: \"light\" | \"dark\", platform: Platform = \"ios\"): Record<string, string> {\n  const ios: Record<string, string> = theme === \"light\" ? {\n    \"--im-dim\": \"rgba(22,18,44,0.21)\", \"--im-glass\": \"rgba(229,229,231,0.69)\", \"--im-glass-filter\": \"blur(9px) brightness(1.32) saturate(1.35)\", \"--im-glass-solid\": \"#ededef\", \"--im-glass-rim\": \"rgba(255,255,255,0.55)\",\n    \"--im-glass-shadow\": \"0 6px 24px rgba(0,0,0,0.10)\", \"--im-picker-icon\": \"#aeaeb2\", \"--im-menu-glass\": \"rgba(229,229,231,0.69)\", \"--im-menu-glass-filter\": \"blur(9px) brightness(1.32) saturate(1.35)\", \"--im-menu-bg\": \"#edeff1\", \"--im-menu-text\": \"#000000\",\n    \"--im-menu-separator\": \"rgba(0,0,0,0.12)\", \"--im-menu-destructive\": \"#ff3b30\", \"--im-tapback-own\": tapbackColors.own, \"--im-tapback-ring\": tapbackColors.selectedRing,\n  } : {\n    \"--im-dim\": \"rgba(22,18,44,0.21)\", \"--im-glass\": \"rgba(38,37,39,0.8)\", \"--im-glass-filter\": \"blur(9px) saturate(1.6)\", \"--im-glass-solid\": \"#1f1e21\", \"--im-glass-rim\": \"rgba(255,255,255,0.10)\",\n    \"--im-glass-shadow\": \"0 6px 24px rgba(0,0,0,0.5)\", \"--im-picker-icon\": \"#8e8e93\", \"--im-menu-glass\": \"rgba(20,25,25,0.8)\", \"--im-menu-glass-filter\": \"blur(9px) saturate(2)\", \"--im-menu-bg\": \"#121316\", \"--im-menu-text\": \"#ffffff\",\n    \"--im-menu-separator\": \"rgba(255,255,255,0.15)\", \"--im-menu-destructive\": \"#ff453a\", \"--im-tapback-own\": tapbackColors.ownDark, \"--im-tapback-theirs\": \"#262629\", \"--im-tapback-ring\": tapbackColors.selectedRingDark,\n  };\n  if (platform === \"ios\") return ios;\n  const macos = { \"--im-tapback-own\": macosBalloonFill, \"--im-tapback-own-surface\": macosBalloonSurface };\n  return theme === \"light\"\n    ? { ...ios, ...macos, \"--im-menu-bg\": \"rgba(247,248,251,0.92)\", \"--im-menu-text\": \"#242526\", \"--im-menu-separator\": \"#dfe0e2\", \"--im-menu-border\": \"#b1b1b1\", \"--im-menu-rim\": \"rgba(255,255,255,0.7)\" }\n    : { ...ios, ...macos, \"--im-menu-bg\": \"rgba(30,34,39,0.92)\", \"--im-menu-text\": \"#dcddde\", \"--im-menu-separator\": \"#3e4145\", \"--im-menu-border\": \"#050506\", \"--im-menu-rim\": \"rgba(255,255,255,0.28)\", \"--im-tapback-theirs\": \"#3b3b3d\" };\n}\n\nexport type BalloonGeometry = { main: number; medium: number; small: number; mediumOffset: [number, number]; smallOffset: [number, number]; glyph: number; /** The glyph sits this far below the circle's centre. */ glyphOffsetY?: number };\n\n/**\n * Balloon geometry per platform. Offsets are the trailing circles' centers relative to the main\n * center, pointing left. iOS from `incoming-light.png` (Ø34.0, trail Ø10.4 at (−10.82, +15.20) and\n * Ø5.0 at (−17.49, +23.46), heart ink 18.34).\n *\n * macOS is circle-fitted on both `tapback-love-light-2x.png` and `tapback-love-dark-2x.png`, which\n * agree to 0.01 pt: main centre (273.44, 114.90) px Ø55.97 px, medium (255.37, 139.73) Ø16.07, small\n * (245.00, 153.00) Ø7.99. It is close to the iOS artwork at 28/34 but not exactly: that scaling would\n * put the trail at Ø8.57 and Ø4.12, 6% and 3% larger. The heart ink (rows y 103–129, columns x 259–288)\n * measures 14.64 × 12.96 with its centre 0.53 below the circle's, of which 0.07 comes from the glyph\n * box itself, so the box is nudged 0.46.\n */\nexport const balloonGeometry: Record<Platform, BalloonGeometry> = {\n  ios: { main: 34, medium: 10.4, small: 5, mediumOffset: [-10.82, 15.2], smallOffset: [-17.49, 23.46], glyph: 18.34, glyphOffsetY: 0.8 },\n  macos: { main: 28, medium: 8.04, small: 4, mediumOffset: [-9.04, 12.42], smallOffset: [-14.22, 19.05], glyph: 14.64, glyphOffsetY: 0.46 },\n};\n\n/**\n * Where a balloon sits relative to the bubble body it belongs to, measured on the settled captures.\n * `marginTop` is the extra space the list opens above the bubble, `top`/`side` place the main circle\n * against the body's top corner on the side away from the screen edge. Consumed by `message-bubble`.\n *\n * iOS: `conv3-light.png` → `tapback-love-light.png` is the same fixture with and without the balloon,\n * and the cluster gap grows from 10.313 to 38.313, i.e. exactly 28. `top`/`side` average the outgoing\n * capture (−27.47 / −14.02) and the mirrored incoming one (`incoming-light.png`, −27.31 / +13.68).\n * macOS (`tapback-love-dark-2x.png`, cross-checked light): body bottom 34.93 → next body top 65.51 is\n * a 30.58 gap where the cluster gap below it is 3.18, so the slot opens 27.40; the Ø28 circle's top\n * (86.92 px) sits 22.05 above the body top (131.01 px) and its leading edge (245.46 px) 11.79 outside\n * the body's leading edge (269.03 px).\n *\n * `message-bubble` carries its own copy of these numbers and is the one that actually places a\n * balloon; its macOS row still reads { 19.6, −19.1, −9.9 } and is 7.8 / 3.0 / 1.9 pt off.\n */\nexport const balloonSlot: Record<Platform, { marginTop: number; top: number; side: number }> = {\n  ios: { marginTop: 28, top: -27.39, side: -13.85 },\n  macos: { marginTop: 27.4, top: -22.05, side: -11.79 },\n};\n\n/** The emoji-picker \"thought bubble\" beside a long-pressed message is the same shape at ~1.3x. */\nexport const pickerBalloonGeometry: BalloonGeometry = { main: 44, medium: 14.6, small: 8, mediumOffset: [-13.2, 20.3], smallOffset: [-22.2, 30.7], glyph: 24 };\n\n/** Renders the trailing circles of a balloon; the parent is the main circle (position: relative). */\nexport function BalloonTrail({ geometry, side, color }: { geometry: BalloonGeometry; side: \"left\" | \"right\"; color: string }) {\n  const half = geometry.main / 2;\n  const place = (d: number, [dx, dy]: [number, number]): CSSProperties => ({\n    position: \"absolute\", width: d, height: d, borderRadius: \"50%\", background: color, top: half + dy - d / 2,\n    [side === \"left\" ? \"left\" : \"right\"]: half + dx - d / 2,\n  });\n  return (\n    <>\n      <span aria-hidden=\"true\" data-slot=\"balloon-medium\" style={place(geometry.medium, geometry.mediumOffset)} />\n      <span aria-hidden=\"true\" data-slot=\"balloon-small\" style={place(geometry.small, geometry.smallOffset)} />\n    </>\n  );\n}\n\nconst glossyText = (gradient: string): CSSProperties => ({ backgroundImage: gradient, WebkitBackgroundClip: \"text\", backgroundClip: \"text\", color: \"transparent\", WebkitTextFillColor: \"transparent\" });\n\n/**\n * Heart ramps, sampled at matching heights on every capture. On a gray or white balloon Apple's heart\n * runs #ffc1d8 → #ff4796; on the blue own-reaction balloon (and on the blue selected disc in the bar)\n * the same artwork reads a constant 21/255 lighter in green and 20/255 in blue, so it keeps its\n * contrast. Positions are the radial offsets fitted to the Ø49.8 balloon in the details popover.\n */\nconst heartRamp = {\n  plain: [[\"0\", \"#ffd9ea\"], [\"0.12\", \"#ffc1d8\"], [\"0.38\", \"#ff99ca\"], [\"0.51\", \"#ff81bd\"], [\"0.79\", \"#ff6cb0\"], [\"1\", \"#ff4796\"]],\n  onAccent: [[\"0\", \"#ffeefe\"], [\"0.12\", \"#ffd6ec\"], [\"0.38\", \"#ffaede\"], [\"0.51\", \"#ff96d1\"], [\"0.79\", \"#ff81c4\"], [\"1\", \"#ff5caa\"]],\n} as const;\n\n/**\n * The six classic tapback glyphs and custom emoji, drawn to match Apple's artwork.\n * `size` is the glyph box: 25.33 in the iOS tapback bar, 18.34 in a Ø34 balloon.\n * `onAccent` picks the lighter artwork Apple uses on the blue balloon and the blue selected disc.\n */\nexport function TapbackGlyph({ type, emoji, size, onAccent = false, className, style }: { type?: TapbackType; emoji?: string; size: number; onAccent?: boolean; className?: string; style?: CSSProperties }) {\n  const id = useId().replace(/:/g, \"\");\n  const base: CSSProperties = { display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", lineHeight: 1, userSelect: \"none\", ...style };\n  if (emoji || !type) {\n    return <span data-slot=\"tapback-glyph\" data-glyph=\"emoji\" className={className} style={{ ...base, fontSize: size, fontFamily: emojiFontStack, width: size, height: size }}>{emoji}</span>;\n  }\n  if (type === \"love\") {\n    // Outline traced from `tapback-love-light.png` at 3x: ink 18.34 × 16.33 (ratio 0.8906), the two\n    // lobes are r 4.75 circles centred (4.75, 4.91) and (13.25, 4.91) meeting in a notch at (9, 2.79),\n    // and each flank is one cubic to the tip (rmse 0.03 against 15 sampled rows).\n    const w = size, h = size * 0.8906;\n    return (\n      <svg data-slot=\"tapback-glyph\" data-glyph=\"love\" className={className} style={base} width={w} height={h} viewBox=\"0 0 18 16.03\" aria-hidden=\"true\">\n        <defs>\n          <radialGradient id={`${id}-h`} cx=\"0.5\" cy=\"0.18\" r=\"0.8\">\n            {(onAccent ? heartRamp.onAccent : heartRamp.plain).map(([offset, color]) => <stop key={offset} offset={offset} stopColor={color} />)}\n          </radialGradient>\n        </defs>\n        <path d=\"M9,16.03 C7.6,15.9 0,10.51 0,4.91 A4.75,4.75 0 0 1 9,2.79 A4.75,4.75 0 0 1 18,4.91 C18,10.51 10.4,15.9 9,16.03 Z\" fill={`url(#${id}-h)`} />\n      </svg>\n    );\n  }\n  if (type === \"like\" || type === \"dislike\") {\n    // The 👎 face sits 1.84 higher than native in a 25.33 slot; 👍 lands right. Push it back down.\n    const drop = type === \"dislike\" ? size * 0.145 : 0;\n    return <span data-slot=\"tapback-glyph\" data-glyph={type} className={className} style={{ ...base, marginTop: (typeof base.marginTop === \"number\" ? base.marginTop : 0) + drop, fontSize: size * 0.987, fontFamily: emojiFontStack, width: size, height: size }}>{type === \"like\" ? \"👍\" : \"👎\"}</span>;\n  }\n  if (type === \"laugh\") {\n    return (\n      <span data-slot=\"tapback-glyph\" data-glyph=\"laugh\" className={className} style={{ ...base, flexDirection: \"column\", width: size, height: size, fontFamily: fontStack, fontWeight: 800, fontSize: size * 0.68, lineHeight: `${size * 0.5}px`, letterSpacing: -size * 0.02, ...glossyText(\"linear-gradient(#5fc9ff, #0aa6ff 55%, #0090f5)\") }}>\n        <span style={{ WebkitTextStroke: `${size * 0.02}px rgba(255,255,255,0.6)` }}>HA</span><span style={{ WebkitTextStroke: `${size * 0.02}px rgba(255,255,255,0.6)` }}>HA</span>\n      </span>\n    );\n  }\n  if (type === \"emphasize\") {\n    const w = size * 0.71, h = size * 1.053;\n    return (\n      <svg data-slot=\"tapback-glyph\" data-glyph=\"emphasize\" className={className} style={base} width={w} height={h} viewBox=\"0 0 18 26.67\" aria-hidden=\"true\">\n        <defs><linearGradient id={`${id}-e`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stopColor=\"#ff8c6e\" /><stop offset=\"0.6\" stopColor=\"#ff4f3f\" /><stop offset=\"1\" stopColor=\"#ff2a34\" /></linearGradient></defs>\n        {[0, 10].map(x => (\n          <g key={x} transform={`translate(${x} 0)`}>\n            <path d=\"M0.2,3.6 A3.8,3.8 0 0 1 7.8,3.6 L6.3,16.3 A2.3,2.3 0 0 1 1.7,16.3 Z\" fill={`url(#${id}-e)`} />\n            <circle cx=\"4\" cy=\"23.1\" r=\"3.5\" fill={`url(#${id}-e)`} />\n            <ellipse cx=\"3\" cy=\"3.8\" rx=\"1.3\" ry=\"2\" fill=\"#fff\" opacity=\"0.45\" />\n          </g>\n        ))}\n      </svg>\n    );\n  }\n  if (type === \"question\") {\n    const w = size * 0.566, h = size * 0.987;\n    return (\n      <svg data-slot=\"tapback-glyph\" data-glyph=\"question\" className={className} style={base} width={w} height={h} viewBox=\"0 0 14.33 25\" aria-hidden=\"true\">\n        <defs><linearGradient id={`${id}-q`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stopColor=\"#c4a8ff\" /><stop offset=\"0.6\" stopColor=\"#9b7cf5\" /><stop offset=\"1\" stopColor=\"#7a5ee0\" /></linearGradient></defs>\n        <path d=\"M2.4,7.4 A4.9,4.9 0 1 1 8.1,12.1 C7.3,12.6 7.2,13.4 7.2,15.4\" fill=\"none\" stroke={`url(#${id}-q)`} strokeWidth=\"4.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        <circle cx=\"7.2\" cy=\"22.4\" r=\"2.5\" fill={`url(#${id}-q)`} />\n        <path d=\"M4.4,5.4 A3.2,3.2 0 0 1 7.6,3.6\" fill=\"none\" stroke=\"#fff\" strokeWidth=\"1.2\" strokeLinecap=\"round\" opacity=\"0.5\" />\n      </svg>\n    );\n  }\n  return null;\n}\n\nexport type TapbackProps = Omit<ComponentProps<\"button\">, \"children\" | \"type\"> & {\n  reaction?: TapbackType;\n  /** A custom emoji reaction instead of one of the six classics. */\n  emoji?: string;\n  /** Blue balloon (your own reaction) or the gray one for other people's. */\n  own?: boolean;\n  /** Which side of the bubble the balloon sits on; the trail points that way. Outgoing bubbles use \"left\". */\n  side?: \"left\" | \"right\";\n  selected?: boolean;\n  count?: number;\n  platform?: Platform;\n  /** Extra content, e.g. a screen-reader description. */\n  children?: ReactNode;\n  /**\n   * Pop the balloon in the way applying a Tapback does natively: it scales up past its size and\n   * settles, the trailing circles riding the same scale. Off by default, because reactions that were\n   * already on a message when the conversation opened do not animate; turn it on for one the person\n   * just applied.\n   */\n  animateIn?: boolean;\n  /** Scrub the entrance (0..1) instead of playing it. */\n  appearProgress?: number;\n};\n\n/**\n * Timed on `references/macos/captures/tapback-apply-frames-100-123.png`, 24 consecutive 60 fps frames\n * (f100 = 1667 ms, 16.67 ms apart). The menu is still whole at f101 and has gone at f112, a 183 ms\n * dissolve (95%→5% in 134 ms). The balloon then appears at f118 = 1967 ms, 100 ms after the menu is\n * gone, in the same frame the list starts opening the slot. Its fill diameter over the settled Ø28\n * measures 0.19 (f118), 0.28 (f119), ≈0.52 (f120), ≈0.65 (f121), ≈0.75 (f122) and ≥0.80 (f123), so it\n * needs ≈110 ms to reach full size counting from the last empty frame. The small trailing circle\n * measures 0.49 / 0.64 / 0.71 / 0.81 across f120–f123, i.e. it rides the balloon's own scale with no\n * delay of its own — hence `trailDelay: 0` and no separate animation for the trail.\n *\n * The strip ends at f123 = 2050 ms with the balloon still growing, so the overshoot past 1 and the\n * settle after it are NOT in the capture: `duration` and the peak below are unverified.\n */\nexport const tapbackAppear = { duration: 420, growth: 110, trailDelay: 0 } as const;\n\nexport function Tapback({ reaction = \"love\", emoji, own = true, side = \"left\", selected = false, count, platform: platformProp, className, onClick, style, children, animateIn = false, appearProgress, ...props }: TapbackProps) {\n  const contextPlatform = usePlatform();\n  const host = useRef<HTMLSpanElement & HTMLButtonElement>(null);\n  useLayoutEffect(() => {\n    const element = host.current;\n    if (!element || !animateIn) return;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n    if (reduced) return;\n    // The trailing circles are children, so the one scale below carries them: the capture shows them\n    // at the balloon's own scale in every frame, with no delay to animate separately.\n    const grow = tapbackAppear.growth / tapbackAppear.duration;\n    const balloon = element.animate(\n      [\n        { transform: \"scale(0)\", opacity: 0 },\n        { offset: grow * 0.08, transform: \"scale(0.12)\", opacity: 1 },\n        { offset: grow, transform: \"scale(1)\" },\n        { offset: grow * 1.26, transform: \"scale(1.14)\" },\n        { offset: 0.62, transform: \"scale(0.94)\" },\n        { offset: 0.84, transform: \"scale(1.03)\" },\n        { transform: \"scale(1)\" },\n      ],\n      { duration: tapbackAppear.duration, easing: \"linear\", fill: \"both\" },\n    );\n    if (appearProgress === undefined) balloon.play();\n    else { balloon.pause(); balloon.currentTime = Math.max(0, Math.min(1, appearProgress)) * tapbackAppear.duration; }\n    return () => balloon.cancel();\n  }, [animateIn, appearProgress]);\n  const platform = platformProp ?? contextPlatform;\n  const g = balloonGeometry[platform];\n  const fill = own ? \"var(--im-tapback-own, #0088ff)\" : \"var(--im-tapback-theirs, #e9e9eb)\";\n  // macOS paints the main circle with the measured opacity ramp and knocks the artwork out of\n  // whatever it laps with a 0.5 pt pane-coloured rim; the trailing circles stay flat and rimless.\n  const surface = platform === \"macos\" && own ? `var(--im-tapback-own-surface, ${fill})` : fill;\n  const rim = platform === \"macos\" ? `0 0 0 ${macosBalloonRim}px var(--im-bg, #fff)` : null;\n  const ring = selected ? `0 0 0 ${platform === \"ios\" ? 2 : 1.5}px var(--im-bg, #fff), 0 0 0 ${platform === \"ios\" ? 4 : 3}px var(--im-tapback-ring, ${tapbackColors.selectedRing})` : null;\n  // Reads on its own: \"Love tapback, 2, from you\" rather than a bare glyph name and a bracket.\n  const label = `${emoji ?? tapbackLabels[reaction]} tapback${count !== undefined && count > 1 ? `, ${count}` : \"\"}${own ? \", from you\" : \"\"}`;\n  const pill = count !== undefined && count > 1;\n  const rootStyle: CSSProperties = {\n    position: \"relative\", display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", boxSizing: \"border-box\",\n    width: pill ? undefined : g.main, minWidth: g.main, height: g.main, borderRadius: g.main / 2, background: surface,\n    paddingInline: pill ? g.main * 0.26 : 0, gap: g.main * 0.12, fontFamily: fontStack,\n    color: own ? \"#fff\" : \"var(--im-incoming-text, #000)\",\n    // The selected ring uses the same token as the picker, so dark gets #0064d2 rather than #26aeff.\n    boxShadow: [rim, ring].filter(Boolean).join(\", \") || undefined,\n    ...style,\n  };\n  const content = (\n    <>\n      <TapbackGlyph type={emoji ? undefined : reaction} emoji={emoji} size={g.glyph} onAccent={own} style={{ marginTop: emoji ? 0 : 2 * (g.glyphOffsetY ?? 0) }} />\n      {pill && <span data-slot=\"tapback-count\" style={{ fontSize: g.main * 0.38, fontWeight: 600, lineHeight: 1 }}>{count}</span>}\n      <BalloonTrail geometry={g} side={side} color={fill} />\n      {children}\n    </>\n  );\n  const shared = { \"data-slot\": \"tapback\", \"data-reaction\": emoji ? \"emoji\" : reaction, \"data-own\": own, \"data-side\": side, \"data-platform\": platform } as const;\n  if (!onClick) return <span ref={host as RefObject<HTMLSpanElement>} role=\"img\" aria-label={label} className={cn(\"select-none\", className)} style={{ ...rootStyle, transformOrigin: side === \"left\" ? \"85% 85%\" : \"15% 85%\" }} {...shared}>{content}</span>;\n  return (\n    <button ref={host as RefObject<HTMLButtonElement>} type=\"button\" aria-label={label} aria-pressed={selected} className={cn(\"cursor-pointer select-none border-0 outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\", className)} style={{ ...rootStyle, transformOrigin: side === \"left\" ? \"85% 85%\" : \"15% 85%\" }} onClick={onClick} {...shared} {...props}>\n      {content}\n    </button>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/tapback.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "tapback-details",
      "title": "Tapback details",
      "description": "The surface \"Tapback Details…\" opens: who reacted and with what, as a sheet on iOS and an anchored popover on macOS, with your own reaction removable.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/tapback-details.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar } from \"@/components/imessage/avatar\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { BalloonTrail, TapbackGlyph, balloonGeometry, macosBalloonRim, tapbackLabels, type BalloonGeometry, type TapbackType } from \"@/components/imessage/tapback\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * \"Tapback Details…\", the surface both context menus open: who reacted, with what, and a way to take\n * your own reaction back.\n *\n * **NO CAPTURE EXISTS.** Nothing in `references/` records this surface on either platform, so not one\n * number below was read off a frame. They come from ChatKit 26.5 instead, the framework macOS\n * Messages is built on, read out of the live runtime on 2026-09-08 with a Mac Catalyst probe\n * (`clang -target arm64-apple-ios26.0-macabi`, `dlopen` of\n * `/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework/ChatKit`, then `objc_msgSend`\n * on the class and instance getters named below). Every value states its selector.\n *\n * ChatKit calls this the **voting view**: `-[CKUIBehavior messageAcknowledgementVotingViewHeight]`\n * and its neighbours size the platter, and the Swift type `ChatKit.StyleSupport` carries the interior\n * as class constants (`+votingViewCellWidth` and the rest). The strings are ChatKit's own, from\n * `ChatKit.framework/Resources/ChatKit.loctable` (`en`): `TAPBACK_DETAILS_ELLIPSIS` = \"Tapback\n * Details…\" is the menu row, `TAPBACK_DETAILS` = \"Tapback Details\" the title, and\n * `ACCESSIBILITY_TAPBACK_LABEL` = \"%@ reacted with %@\" plus\n * `ACCESSIBILITY_EXPANDED_TAPBACK_FORMAT` = \"%lu %@ reactions from %@\" are the accessibility texts\n * this file reproduces.\n *\n * **The native layout is a horizontal platter, not a table.** `votingViewPlatterCornerRadius` = 34\n * round, `messageAcknowledgementVotingViewHeight` = 72 tall on iOS and 80 on Mac, holding one\n * `votingViewCellWidth` = 64 wide cell per reactor: a `votingViewAvatarDiameter` = 44 avatar badged\n * with the reaction in a `votingViewAvatarViewGlyphFrame` 20 square, then\n * `votingViewAvatarToTextSpacing` = 4 and a `votingViewAvatarViewLabelHeight` = 18 name. The count\n * leads it as a `votingViewExpandedTally` 27 square per reaction kind with its total beside it\n * (`votingViewTallyLabelSpacing` = 0). This file reproduces that shape rather than a vertical list.\n *\n * **Mac numbers go through Catalyst's 0.77.** ChatKit is a UIKit framework and Messages a Catalyst\n * app, so one Mac-idiom point is 0.77 of an AppKit point. Two of ChatKit's own constants pin that\n * factor against measurements already in SPEC.md:\n * `+[ChatKit.StyleSupport transcriptTitleViewAvatarButtonDiameter]` = 52 lands on the macOS header's\n * measured Ø40 avatar (52 × 0.77 = 40.04), and `+transcriptTitleViewHeight` = 87 lands on the 67 that\n * capture's avatar and name pill span together (87 × 0.77 = 66.99). Neither matches iOS, whose nav\n * bar avatar is Ø60. Every macOS number here is therefore its ChatKit value times `catalystScale`,\n * and is unverified against a capture.\n *\n * **What is JUDGEMENT, and is marked so below:** the presentation's durations, the close button's\n * size, using the macOS knockout rim on the iOS badge, and presenting the platter as a bottom sheet\n * on iOS and an anchored popover on macOS (ChatKit has one platter and does not say how either host\n * presents it). The platter's fill, rim and shadow are not judgement: they reuse the glass solved\n * from `references/ios/captures/longpress-*.png` in `tapback.tsx`'s `tapbackVars`, and the balloon\n * artwork is the traced geometry from the same file, scaled.\n *\n * Not to be confused with `message-actions.tsx`'s `TapbackDetails`, which is a different ChatKit\n * surface: `CKTapbackAttributionView`, the card that floats above a long-pressed message you have\n * already reacted to (`-[CKUIBehavior attributionViewHeight]` = 132, `attributionViewMaxWidth` = 400\n * on iOS and 500 on Mac).\n */\n\n/** One Mac-idiom UIKit point is this much of an AppKit point. See the header for the two constants that fix it. */\nexport const catalystScale = 0.77;\n\nexport type TapbackDetailsMetrics = {\n  /** `-[CKUIBehavior messageAcknowledgementVotingViewHeight]`: 72 iOS, 80 Mac. */\n  height: number;\n  /** `+[ChatKit.StyleSupport votingViewPlatterCornerRadius]` = 34. */\n  radius: number;\n  /** `+votingViewHorizontalPadding` = 24. */\n  paddingX: number;\n  /** `+votingViewItemSpacing` = 24, between the tallies and between the cells. */\n  itemSpacing: number;\n  /** `+votingViewAdditionalTopInset` = 4. */\n  topInset: number;\n  /** `-[CKUIBehavior messageAcknowledgementVotingViewMaxWidth]`: 400 iOS, 500 Mac. */\n  maxWidth: number;\n  /** `-messageAcknowledgementVotingViewMinPadding`: 8 iOS, 6 Mac, from the presenting edge. */\n  minPadding: number;\n  /** `+votingViewCellWidth` = 64. */\n  cellWidth: number;\n  /** `+votingViewAvatarDiameter` = 44. */\n  avatar: number;\n  /** `+votingViewAvatarViewGlyphFrameWidth` and `…Height`, both 20: the reaction badged on the avatar. */\n  glyphFrame: number;\n  /** `+votingViewAvatarToTextSpacing` = 4. */\n  avatarToText: number;\n  /** `+votingViewAvatarViewLabelHeight` = 18. */\n  labelHeight: number;\n  /** `-[CKUIBehavior avatarNameFont]`: SFNS Regular 12 iOS, 16 Mac. */\n  nameFontSize: number;\n  /** `-[CKUIBehavior messageAcknowledgmentVoteCountFont]`: SFNS Regular 12 iOS, 16 Mac. */\n  countFontSize: number;\n  /** `+votingViewExpandedTallyWidth` and `…Height`, both 27. */\n  tally: number;\n  /** `+votingViewTallyLabelSpacing` = 0, between a tally and its count. */\n  tallyLabelSpacing: number;\n  /** `+votingViewBlurWidth` = 88: how far the platter fades its scrolling content at each end. */\n  blurWidth: number;\n  /** `+votingViewCloseButtonLeftPadding` = 22. The button's own size is judgement; see `closeSize`. */\n  closeLeftPadding: number;\n  /** JUDGEMENT: ChatKit gives the close button a left padding but no size, so it takes the tally's box. */\n  closeSize: number;\n};\n\nconst mac = (points: number) => Number((points * catalystScale).toFixed(4));\n\nexport const tapbackDetailsMetrics: Record<Platform, TapbackDetailsMetrics> = {\n  ios: {\n    height: 72, radius: 34, paddingX: 24, itemSpacing: 24, topInset: 4, maxWidth: 400, minPadding: 8,\n    cellWidth: 64, avatar: 44, glyphFrame: 20, avatarToText: 4, labelHeight: 18,\n    nameFontSize: 12, countFontSize: 12, tally: 27, tallyLabelSpacing: 0,\n    blurWidth: 88, closeLeftPadding: 22, closeSize: 27,\n  },\n  macos: {\n    height: mac(80), radius: mac(34), paddingX: mac(24), itemSpacing: mac(24), topInset: mac(4), maxWidth: mac(500), minPadding: mac(6),\n    cellWidth: mac(64), avatar: mac(44), glyphFrame: mac(20), avatarToText: mac(4), labelHeight: mac(18),\n    nameFontSize: mac(16), countFontSize: mac(16), tally: mac(27), tallyLabelSpacing: 0,\n    blurWidth: mac(88), closeLeftPadding: mac(22), closeSize: mac(27),\n  },\n};\n\n/**\n * The presentation. `scale` is ChatKit's own (`+[ChatKit.StyleSupport tapbackStartingScaleX]` and\n * `…ScaleY`, both 0.3). Everything else is JUDGEMENT, borrowed from motion this kit has already\n * measured rather than invented: `enter` and `ease` are the sheet duration and curve `ios-details.tsx`\n * uses for its own rise, and `exit`/`exitEase` are the long-press overlay's measured 220 ms dismissal\n * (`messageActionsTiming.exit`). ChatKit does carry `-[CKUIBehavior tapbackDismissalDuration]` = 0.5 s,\n * but it belongs to the picker rather than to this platter, so it is recorded and not used.\n */\nexport const tapbackDetailsMotion = {\n  enter: 320,\n  exit: 220,\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n  exitEase: \"cubic-bezier(0.4, 0, 1, 1)\",\n  scale: 0.3,\n  /** ChatKit's `-[CKUIBehavior tapbackDismissalDuration]` in ms. Recorded, not used; see above. */\n  frameworkDismissal: 500,\n} as const;\n\n/**\n * Light values ride the root's inline style so the platter renders correctly with no Tailwind at all;\n * the dark set rides a `dark:` class, the way `avatar.tsx` splits its own gradient.\n *\n * The platter's fill, rim and shadow are the glass `tapback.tsx` solved from the long-press captures\n * (`--im-glass-solid` #ededef light and #1f1e21 dark, with its rim and shadow). `--im-td-label` is\n * ChatKit's `-[CKUITheme messageAcknowledgmentVotingTextColor]`, which resolves to\n * `secondaryLabelColor`: rgba(0,0,0,0.498) light, rgba(255,255,255,0.549) dark, the same colour\n * `-attributionCountViewFontColor` returns for the count. The dim behind the iOS sheet is the 20%\n * black measured on `references/ios/captures/newmsg-light.png` (`ios-new-message-sheet.tsx`), and the\n * close button's fill is the segmented-control track measured on `effects-picker-light.png`.\n */\nconst lightVars = {\n  \"--im-td-platter\": \"#ededef\",\n  \"--im-td-rim\": \"rgba(255,255,255,0.55)\",\n  \"--im-td-shadow\": \"0 6px 24px rgba(0,0,0,0.10)\",\n  \"--im-td-label\": \"rgba(0,0,0,0.498)\",\n  \"--im-td-dim\": \"rgba(0,0,0,0.2)\",\n  \"--im-td-fill\": \"rgba(120,120,128,0.16)\",\n  \"--im-td-theirs\": \"#e9e9eb\",\n} as const;\n\nconst darkVars =\n  \"dark:[--im-td-platter:#1f1e21] dark:[--im-td-rim:rgba(255,255,255,0.10)] dark:[--im-td-shadow:0_6px_24px_rgba(0,0,0,0.5)] \" +\n  \"dark:[--im-td-label:rgba(255,255,255,0.549)] dark:[--im-td-dim:rgba(0,0,0,0.5)] dark:[--im-td-fill:rgba(120,120,128,0.24)] \" +\n  \"dark:[--im-td-theirs:#262629]\";\n\n/** Which corner of the popover its entrance grows out of. */\nconst transformOrigins: Record<\"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\", string> = {\n  \"top-left\": \"0% 0%\",\n  \"top-right\": \"100% 0%\",\n  \"bottom-left\": \"0% 100%\",\n  \"bottom-right\": \"100% 100%\",\n};\n\nexport type TapbackReactor = {\n  id: string;\n  /** Full name, used for the accessible text. The cell truncates it to one line. */\n  name: string;\n  /** What the 64 wide cell shows under the avatar; the first word of `name` when absent. */\n  shortName?: string;\n  /** One or two letters for the avatar; derived from `name` when absent. */\n  initials?: string;\n  /** A photo instead of initials. */\n  avatar?: string;\n  /** One of the six classics. Ignored when `emoji` is set. */\n  reaction?: TapbackType;\n  /** A custom emoji reaction. */\n  emoji?: string;\n  /** Your own reaction: the one cell that can be activated to take it back. */\n  own?: boolean;\n};\n\nexport type TapbackDetailsProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  reactors: TapbackReactor[];\n  platform?: Platform;\n  /** Called when the own cell is activated. ChatKit's own name for this action is \"Remove Tapback\". */\n  onRemove?: (reactor: TapbackReactor) => void;\n  /** Dismiss: Escape, the close button, the iOS dim, or a click outside the macOS popover. */\n  onClose?: () => void;\n  /** False plays the dismissal; the platter stays mounted until it is over, then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  /**\n   * Seek the presentation to this fraction (0..1) instead of playing it, which is what the harness\n   * does. It seeks whichever direction `open` selects, and a seeked dismissal never fires `onExited`:\n   * scrubbing a timeline is not a dismissal, and a checkpoint has to land on the same frame every run.\n   */\n  progress?: number;\n  /** macOS: the popover's top-left corner in the containing block, and the corner it grows out of. */\n  left?: number;\n  top?: number;\n  origin?: keyof typeof transformOrigins;\n  /** Move focus into the platter when it opens (keyboard users). */\n  autoFocus?: boolean;\n};\n\nfunction clamp01(value: number) {\n  return Math.max(0, Math.min(1, value));\n}\n\nconst reducedMotionQuery = () => (typeof window === \"undefined\" ? null : window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") ?? null);\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = reducedMotionQuery();\n  query?.addEventListener(\"change\", onChange);\n  return () => query?.removeEventListener(\"change\", onChange);\n}\n/** `macos-plus-menu.tsx` has the same hook; this file keeps its own copy so it installs on its own. */\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReducedMotion, () => reducedMotionQuery()?.matches ?? false, () => false);\n}\n\nfunction initialsOf(reactor: TapbackReactor) {\n  if (reactor.initials) return reactor.initials;\n  return reactor.name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\nfunction reactionKey(reactor: TapbackReactor) {\n  return reactor.emoji ? `emoji:${reactor.emoji}` : `type:${reactor.reaction ?? \"love\"}`;\n}\n\nfunction reactionName(reactor: TapbackReactor) {\n  return reactor.emoji ?? tapbackLabels[reactor.reaction ?? \"love\"];\n}\n\n/**\n * The balloon geometry from `tapback.tsx`, measured on `tapback-love-light.png` (iOS, Ø34) and on\n * both `tapback-love-*-2x.png` (macOS, Ø27.98), scaled so the whole balloon fills the ChatKit frame\n * it is given. Scaling the traced artwork keeps the glyph, the neck and both trailing circles in the\n * proportions the captures fix; only the overall size comes from the framework.\n */\nexport function scaledBalloonGeometry(platform: Platform, frame: number): BalloonGeometry {\n  const g = balloonGeometry[platform];\n  const k = frame / g.main;\n  return {\n    main: frame,\n    medium: g.medium * k,\n    small: g.small * k,\n    mediumOffset: [g.mediumOffset[0] * k, g.mediumOffset[1] * k],\n    smallOffset: [g.smallOffset[0] * k, g.smallOffset[1] * k],\n    glyph: g.glyph * k,\n    glyphOffsetY: (g.glyphOffsetY ?? 0) * k,\n  };\n}\n\n/**\n * One reaction balloon at an arbitrary size, built from the scaled measured geometry. `Tapback`\n * itself is fixed at the balloon's own measured diameter, so the badge and the tally rebuild the\n * artwork here rather than scaling that component with a transform, which would scale its rim too.\n */\nfunction Balloon({ geometry, own, side, rim = false, children, style }: { geometry: BalloonGeometry; own: boolean; side: \"left\" | \"right\"; rim?: boolean; children?: ReactNode; style?: CSSProperties }) {\n  const fill = own ? \"var(--im-tapback-own, #0088ff)\" : \"var(--im-td-theirs, #e9e9eb)\";\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"tapback-details-balloon\"\n      data-own={own}\n      style={{\n        position: \"relative\", display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", boxSizing: \"border-box\",\n        width: geometry.main, height: geometry.main, borderRadius: \"50%\", background: fill,\n        boxShadow: rim ? `0 0 0 ${macosBalloonRim}px var(--im-td-platter, #ededef)` : undefined,\n        ...style,\n      }}\n    >\n      {children}\n      <BalloonTrail geometry={geometry} side={side} color={fill} />\n    </span>\n  );\n}\n\n/**\n * JUDGEMENT: the cell shows a first name. ChatKit sizes the label at `votingViewCellWidth` 64 with\n * `avatarNameFont` at 12, which fits a first name and truncates almost every full one; native avatar\n * stacks label themselves the same way. The whole name stays in the accessible text, and a caller\n * with a better short form passes `shortName`.\n */\nfunction shortNameOf(reactor: TapbackReactor) {\n  return reactor.shortName ?? reactor.name.trim().split(/\\s+/)[0] ?? reactor.name;\n}\n\nexport function TapbackDetails({\n  reactors, platform: platformProp, onRemove, onClose, open = true, onExited, progress,\n  left, top, origin = \"top-left\", autoFocus = false, className, style, ...props\n}: TapbackDetailsProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = tapbackDetailsMetrics[platform];\n  const t = tapbackDetailsMotion;\n  const id = useId().replace(/:/g, \"\");\n  const reduced = usePrefersReducedMotion();\n  const platter = useRef<HTMLDivElement>(null);\n  const dim = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n\n  /**\n   * The dismissal is derived DURING RENDER, not in an effect: an effect leaves one committed frame\n   * with the platter already unmounted and the exit never gets to run. `macos-plus-menu.tsx` and\n   * `ios-details.tsx` derive theirs the same way.\n   */\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [shown, setShown] = useState(open);\n  const [settled, setSettled] = useState(open && reduced);\n  if (seenOpen !== open) {\n    setSeenOpen(open);\n    if (open) { setShown(true); setSettled(reduced); }\n    // Under reduced motion there is no dismissal to wait for, so it goes in the same frame.\n    else if (reduced) setShown(false);\n  }\n  const closing = shown && !open;\n  const state = closing ? \"closing\" : reduced || settled || (progress !== undefined && clamp01(progress) === 1) ? \"open\" : \"entering\";\n\n  const exited = useRef(onExited);\n  const close = useRef(onClose);\n  useEffect(() => { exited.current = onExited; close.current = onClose; });\n\n  // One place fires `onExited`, whether the platter left on its animation or under reduced motion.\n  const wasShown = useRef(shown);\n  useEffect(() => {\n    if (wasShown.current && !shown) exited.current?.();\n    wasShown.current = shown;\n  }, [shown]);\n\n  /**\n   * Web Animations, not a transition or a rAF loop, so `document.getAnimations()` can reach the\n   * timeline and a `progress` frame is a seek rather than a replay. iOS rises off the bottom edge the\n   * way a sheet does; macOS pops out of the corner it is anchored by, from ChatKit's own 0.3.\n   */\n  useEffect(() => {\n    const element = platter.current;\n    if (!element || !shown || reduced) return;\n    const poses: Keyframe[] = platform === \"ios\"\n      ? [{ opacity: 0, transform: `translateY(${m.height + m.minPadding}px)` }, { opacity: 1, transform: \"translateY(0px)\" }]\n      : [{ opacity: 0, transform: `scale(${t.scale})` }, { opacity: 1, transform: \"scale(1)\" }];\n    const animation = open\n      ? element.animate(poses, { duration: t.enter, easing: t.ease, fill: \"both\" })\n      : element.animate([poses[1], poses[0]], { duration: t.exit, easing: t.exitEase, fill: \"both\" });\n    if (progress !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      animation.pause();\n      try { animation.currentTime = clamp01(progress) * (open ? t.enter : t.exit); } catch { /* no timeline yet */ }\n      return () => animation.cancel();\n    }\n    const done = () => (open ? setSettled(true) : setShown(false));\n    animation.addEventListener(\"finish\", done);\n    return () => {\n      animation.removeEventListener(\"finish\", done);\n      // Reopened mid-dismissal: drop the fold so it cannot hold a stale pose under the entrance.\n      if (animation.playState !== \"finished\") animation.cancel();\n    };\n  }, [shown, open, progress, reduced, platform, m.height, m.minPadding, t.enter, t.exit, t.ease, t.exitEase, t.scale]);\n\n  // The dim rides the same timeline, so a scrubbed frame is consistent with the platter's.\n  useEffect(() => {\n    const element = dim.current;\n    if (!element || !shown || reduced) return;\n    const animation = open\n      ? element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: t.enter, easing: \"ease-out\", fill: \"both\" })\n      : element.animate([{ opacity: 1 }, { opacity: 0 }], { duration: t.exit, easing: \"ease-out\", fill: \"both\" });\n    if (progress !== undefined) {\n      animation.pause();\n      try { animation.currentTime = clamp01(progress) * (open ? t.enter : t.exit); } catch { /* no timeline yet */ }\n    }\n    return () => { try { animation.cancel(); } catch { /* already gone */ } };\n  }, [shown, open, progress, reduced, t.enter, t.exit]);\n\n  // Escape from wherever focus is, and on macOS a click anywhere outside the popover.\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\" || !close.current) return;\n      event.preventDefault();\n      close.current();\n    };\n    const onPointerDown = (event: globalThis.PointerEvent) => {\n      const target = event.target as Node | null;\n      if (platform !== \"macos\" || !close.current || !target || platter.current?.contains(target)) return;\n      // The control that opened it owns the toggle; closing here would let its own click reopen it.\n      if (target instanceof Element && target.closest('[aria-haspopup=\"dialog\"]')) return;\n      close.current();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n    };\n  }, [open, platform]);\n\n  useEffect(() => {\n    // A scrubbed entrance must not move the caret: the harness seeks frames, it does not open dialogs.\n    if (!open || !autoFocus || progress !== undefined) return;\n    platter.current?.querySelector<HTMLElement>(\"button, [tabindex]:not([tabindex='-1'])\")?.focus({ preventScroll: true });\n  }, [open, autoFocus, progress]);\n\n  /**\n   * ChatKit fades the platter's content over `votingViewBlurWidth` at each end, which only means\n   * anything once there is more than fits. Keyboard users get the scroller itself as a focus stop\n   * when that happens, which is the accessible pattern for a scrollable region.\n   */\n  const [edges, setEdges] = useState({ start: false, end: false });\n  const measure = useCallback(() => {\n    const node = scroller.current;\n    if (!node) return;\n    const hidden = node.scrollWidth - node.clientWidth;\n    const next = { start: node.scrollLeft > 0.5, end: hidden > 0.5 && node.scrollLeft < hidden - 0.5 };\n    setEdges(current => (current.start === next.start && current.end === next.end ? current : next));\n  }, []);\n  useLayoutEffect(() => {\n    measure();\n    const node = scroller.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [measure, reactors.length]);\n  const overflowing = edges.start || edges.end;\n\n  if (!shown || reactors.length === 0) return null;\n\n  // Tallies keep first-appearance order, and one counts as yours if any reaction in it is yours.\n  const tallies: Array<{ key: string; reactor: TapbackReactor; count: number; own: boolean; names: string[] }> = [];\n  for (const reactor of reactors) {\n    const key = reactionKey(reactor);\n    const found = tallies.find(entry => entry.key === key);\n    if (found) { found.count += 1; found.own = found.own || Boolean(reactor.own); found.names.push(reactor.name); }\n    else tallies.push({ key, reactor, count: 1, own: Boolean(reactor.own), names: [reactor.name] });\n  }\n\n  const badge = scaledBalloonGeometry(platform, m.glyphFrame);\n  const fade = `linear-gradient(to right, transparent 0, #000 ${m.blurWidth}px, #000 calc(100% - ${m.blurWidth}px), transparent 100%)`;\n\n  /**\n   * Hover, focus and pressed feedback on the one cell that does something. Driven from CSS so a\n   * pointer crossing the platter never touches React state, and gated on `data-state` so nothing\n   * lights up while the platter is still growing under a stationary cursor. The scroller hides its\n   * bar: native fades its content instead, and a bar would eat the cells' bottom edge.\n   */\n  const platterCss = `[data-slot=\"tapback-details\"] [data-slot=\"tapback-details-cell-fill\"]{opacity:0;transition:opacity 80ms linear}\n[data-slot=\"tapback-details\"][data-state=\"open\"] [data-slot=\"tapback-details-remove\"]:hover [data-slot=\"tapback-details-cell-fill\"]{opacity:1}\n[data-slot=\"tapback-details\"] [data-slot=\"tapback-details-remove\"]:focus-visible [data-slot=\"tapback-details-cell-fill\"]{opacity:1}\n[data-slot=\"tapback-details\"] [data-slot=\"tapback-details-remove\"]:active [data-slot=\"tapback-details-cell-fill\"]{opacity:1}\n[data-slot=\"tapback-details-scroller\"]::-webkit-scrollbar{display:none}\n@media (prefers-reduced-motion: reduce){[data-slot=\"tapback-details\"] [data-slot=\"tapback-details-cell-fill\"]{transition:none}}`;\n\n  const cellBody = (reactor: TapbackReactor) => (\n    <>\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"tapback-details-cell-fill\"\n        style={{ position: \"absolute\", left: 0, right: 0, top: -m.topInset / 2, bottom: -m.topInset / 2, borderRadius: m.avatar / 2, background: \"var(--im-td-fill, rgba(120,120,128,0.16))\" }}\n      />\n      <span style={{ position: \"relative\", display: \"block\", width: m.avatar, height: m.avatar, marginInline: \"auto\" }}>\n        <Avatar size={m.avatar} initials={initialsOf(reactor)} src={reactor.avatar} role=\"presentation\" aria-hidden=\"true\" style={{ position: \"absolute\", inset: 0 }} />\n        {/* The badge sits on the avatar's trailing-bottom with its trail pointing away, the way a\n            balloon points away from its bubble. Its 0.5 knockout rim is measured on macOS\n            (`macosBalloonRim`); using it on iOS too, where a balloon over a bubble has none, is\n            JUDGEMENT: an avatar is not a bubble and the badge needs the separation. */}\n        <Balloon geometry={badge} own={Boolean(reactor.own)} side=\"right\" rim style={{ position: \"absolute\", right: -m.glyphFrame * 0.1, bottom: -m.glyphFrame * 0.1 }}>\n          <TapbackGlyph\n            type={reactor.emoji ? undefined : reactor.reaction ?? \"love\"}\n            emoji={reactor.emoji}\n            size={badge.glyph}\n            onAccent={Boolean(reactor.own)}\n            style={{ marginTop: reactor.emoji ? 0 : 2 * (badge.glyphOffsetY ?? 0) }}\n          />\n        </Balloon>\n      </span>\n      <span\n        data-slot=\"tapback-details-name\"\n        style={{\n          position: \"relative\", display: \"block\", marginTop: m.avatarToText, width: m.cellWidth, height: m.labelHeight,\n          lineHeight: `${m.labelHeight}px`, fontSize: m.nameFontSize, fontWeight: 400, letterSpacing: 0,\n          color: \"var(--im-td-label, rgba(0,0,0,0.498))\", textAlign: \"center\",\n          overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\",\n        }}\n      >\n        {shortNameOf(reactor)}\n      </span>\n    </>\n  );\n\n  const cells = reactors.map((reactor, index) => {\n    // ChatKit's own accessibility string is \"%@ reacted with %@\"; the action that takes one back is\n    // \"Remove Tapback\".\n    const reacted = `${reactor.name} reacted with ${reactionName(reactor)}`;\n    const box: CSSProperties = { position: \"relative\", display: \"block\", width: m.cellWidth, textAlign: \"center\" };\n    return (\n      <li key={reactor.id} data-slot=\"tapback-details-cell\" data-own={reactor.own || undefined}\n        style={{ listStyle: \"none\", margin: 0, padding: 0, marginLeft: index === 0 ? 0 : m.itemSpacing, flex: \"0 0 auto\" }}>\n        {reactor.own && onRemove ? (\n          <button type=\"button\" data-slot=\"tapback-details-remove\" aria-label={`${reacted}. Remove Tapback`} onClick={() => onRemove(reactor)}\n            // The ring is inset: the scroller has to clip on the cross axis, so an outset one is cut.\n            style={{ ...box, border: 0, background: \"transparent\", padding: 0, margin: 0, cursor: \"default\", font: \"inherit\", color: \"inherit\", outlineOffset: -2 }}>\n            {cellBody(reactor)}\n          </button>\n        ) : (\n          <span data-slot=\"tapback-details-person\" role=\"img\" aria-label={reacted} style={box}>\n            {cellBody(reactor)}\n          </span>\n        )}\n      </li>\n    );\n  });\n\n  // The platter hugs its content and only then runs into `maxWidth`, which is what ChatKit's pair of\n  // a max width and a minimum padding from the presenting edge describes. On macOS an absolutely\n  // positioned box with an auto width already shrink-wraps; on iOS a centring row does it instead,\n  // because setting both `left` and `right` would stretch it.\n  const platterStyle: CSSProperties = {\n    ...(platform === \"ios\"\n      ? { position: \"relative\", maxWidth: `min(${m.maxWidth}px, 100%)` }\n      : { position: \"absolute\", left, top, maxWidth: m.maxWidth }),\n    boxSizing: \"border-box\",\n    height: m.height,\n    borderRadius: m.radius,\n    paddingTop: m.topInset,\n    paddingInline: m.paddingX,\n    display: \"flex\",\n    alignItems: \"center\",\n    background: \"var(--im-td-platter, #ededef)\",\n    boxShadow: \"var(--im-td-shadow, 0 6px 24px rgba(0,0,0,0.10)), inset 0 0 0 0.5px var(--im-td-rim, rgba(255,255,255,0.55))\",\n    transformOrigin: transformOrigins[origin],\n    // Nothing is clickable while it folds away, so a dismissal cannot pick a cell by accident.\n    pointerEvents: closing ? \"none\" : undefined,\n  };\n\n  const inside = (\n    <>\n      <span id={`${id}-title`} style={{ position: \"absolute\", width: 1, height: 1, margin: -1, padding: 0, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\", border: 0 }}>Tapback Details</span>\n\n      {/* The count and the people scroll together, which is what one fade at each end of the platter\n          (`votingViewBlurWidth`) describes; only the close button is pinned. */}\n      <div ref={scroller} data-slot=\"tapback-details-scroller\"\n        role={overflowing ? \"group\" : undefined} aria-label={overflowing ? \"Reactions\" : undefined} tabIndex={overflowing ? 0 : undefined}\n        style={{\n          flex: \"0 1 auto\", minWidth: 0, overflowX: \"auto\", overflowY: \"hidden\", scrollbarWidth: \"none\",\n          WebkitMaskImage: overflowing ? fade : undefined, maskImage: overflowing ? fade : undefined,\n        }}>\n        <div style={{ display: \"flex\", alignItems: \"center\", width: \"max-content\" }}>\n          {/* ChatKit calls the count the expanded tally: one per reaction kind with its total beside\n              it at `votingViewTallyLabelSpacing` 0, named with ChatKit's own\n              ACCESSIBILITY_EXPANDED_TAPBACK_FORMAT, \"%lu %@ reactions from %@\". */}\n          <div data-slot=\"tapback-details-tallies\" style={{ display: \"flex\", alignItems: \"center\", flex: \"0 0 auto\", marginRight: m.itemSpacing }}>\n            {tallies.map((entry, index) => (\n              <span key={entry.key} data-slot=\"tapback-details-tally\" role=\"img\"\n                aria-label={`${entry.count} ${reactionName(entry.reactor)} ${entry.count === 1 ? \"reaction\" : \"reactions\"} from ${entry.names.join(\", \")}`}\n                style={{ display: \"inline-flex\", alignItems: \"center\", marginLeft: index === 0 ? 0 : m.itemSpacing }}>\n                {/* The tally is the bare glyph in ChatKit's `votingViewExpandedTally` box, not a\n                    balloon: a balloon's neck and trail point at the bubble it belongs to, and a\n                    summary has no bubble. */}\n                <span aria-hidden=\"true\" style={{ width: m.tally, height: m.tally, display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n                  <TapbackGlyph type={entry.reactor.emoji ? undefined : entry.reactor.reaction ?? \"love\"} emoji={entry.reactor.emoji} size={m.tally} />\n                </span>\n                <span aria-hidden=\"true\" data-slot=\"tapback-details-count\"\n                  style={{ marginLeft: m.tallyLabelSpacing, fontSize: m.countFontSize, lineHeight: 1, fontWeight: 400, letterSpacing: 0, color: \"var(--im-td-label, rgba(0,0,0,0.498))\" }}>\n                  {entry.count}\n                </span>\n              </span>\n            ))}\n          </div>\n          <ul data-slot=\"tapback-details-list\" style={{ display: \"flex\", alignItems: \"center\", margin: 0, padding: 0, listStyle: \"none\" }}>\n            {cells}\n          </ul>\n        </div>\n      </div>\n\n      {onClose && (\n        <button type=\"button\" data-slot=\"tapback-details-close\" aria-label=\"Close\" onClick={onClose}\n          style={{\n            flex: \"0 0 auto\", marginLeft: m.closeLeftPadding, width: m.closeSize, height: m.closeSize,\n            borderRadius: m.closeSize / 2, border: 0, padding: 0, cursor: \"default\",\n            display: \"flex\", alignItems: \"center\", justifyContent: \"center\",\n            background: \"var(--im-td-fill, rgba(120,120,128,0.16))\", color: \"var(--im-td-label, rgba(0,0,0,0.498))\",\n            outlineOffset: -2,\n          }}>\n          {/* The cross is 0.394 of its button, the ratio between the 17.33 cross and the Ø44 glass\n              circle measured on `newmsg-light.png` in `ios-new-message-sheet.tsx`. */}\n          <svg aria-hidden=\"true\" width={m.closeSize * 0.394} height={m.closeSize * 0.394} viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n            <path d=\"M1 1 11 11M11 1 1 11\" />\n          </svg>\n        </button>\n      )}\n    </>\n  );\n\n  // macOS: the popover is the root, positioned by the caller against the message it belongs to.\n  if (platform === \"macos\") {\n    return (\n      <div ref={platter} data-slot=\"tapback-details\" data-platform=\"macos\" data-state={state}\n        role=\"dialog\" aria-labelledby={`${id}-title`}\n        className={cn(\"select-none\", darkVars, className)}\n        style={{ fontFamily: fontStack, zIndex: 40, ...lightVars, ...platterStyle, ...style } as CSSProperties}\n        {...props}>\n        <style>{platterCss}</style>\n        {inside}\n      </div>\n    );\n  }\n\n  // iOS: the platter comes up over the dimmed conversation, so the dim is the root.\n  return (\n    <div data-slot=\"tapback-details\" data-platform=\"ios\" data-state={state}\n      className={cn(\"absolute inset-0 z-40 select-none\", darkVars, className)}\n      style={{ fontFamily: fontStack, ...lightVars, ...style } as CSSProperties}\n      {...props}>\n      <style>{platterCss}</style>\n      {/* The dim is the 20% black measured on `newmsg-light.png`; tapping it dismisses, as a sheet does. */}\n      <div ref={dim} data-slot=\"tapback-details-dim\" aria-hidden=\"true\" onClick={onClose}\n        style={{ position: \"absolute\", inset: 0, background: \"var(--im-td-dim, rgba(0,0,0,0.2))\" }} />\n      <div style={{ position: \"absolute\", left: m.minPadding, right: m.minPadding, bottom: m.minPadding, display: \"flex\", justifyContent: \"center\" }}>\n        <div ref={platter} data-slot=\"tapback-details-platter\" role=\"dialog\" aria-modal=\"true\" aria-labelledby={`${id}-title`} style={platterStyle}>\n          {inside}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/tapback-details.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "typing-indicator",
      "title": "Typing indicator",
      "description": "The native typing bubble with its three staggered dots and trailing circles.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/typing-indicator.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/imessage/bubble-shape\";\nimport { bubbleMetrics, palettes } from \"@/components/imessage/tokens\";\n\n/**\n * The \"someone is typing\" balloon.\n *\n * NOT MEASURED. No capture in `references/` shows a typing indicator: every iOS and macOS still, both\n * 60 fps recordings and every motion contact sheet were checked on 2026-09-08 and none contains one.\n *\n * What is anchored to a capture is the balloon itself. It is an incoming bubble, so it takes the\n * measured incoming geometry rather than numbers of its own: the one-line body height\n * (2 x paddingY + lineHeight), the measured corner radius, the traced tail on the bottom-left, and\n * the incoming screen-space fill, all read from `bubbleMetrics` and `bubble-shape` so it can never\n * drift from a real bubble. The message list insets it by the same measured edge inset as a bubble.\n *\n * Only the dots are invented: diameter, spacing and the 1.2 s pulse are provisional, and the dot\n * colour reuses the platform's measured secondary-label gray because no capture shows the real one.\n * The pulse is a CSS animation with negative delays, so every dot is inside its active phase at every\n * time: pausing `document.getAnimations()` and setting `currentTime` pins the balloon to a frame, and\n * that frozen frame is the one the animation shows while running.\n */\nexport type TypingIndicatorMetrics = {\n  /** Body box, both derived from the measured one-line incoming bubble. */\n  width: number;\n  height: number;\n  /** Provisional: dot diameter and the gap between two dots. */\n  dot: number;\n  dotGap: number;\n  /** Measured bubble radius and tail scale, carried through so the balloon matches a bubble exactly. */\n  radius: number;\n  tailScale: number;\n};\n\n/** The only free numbers in the balloon. Everything else falls out of the measured bubble. */\nconst dotSizes: Record<Platform, { dot: number; dotGap: number }> = {\n  ios: { dot: 10, dotGap: 5 },\n  macos: { dot: 7, dotGap: 3.5 },\n};\n\nfunction metricsFor(platform: Platform): TypingIndicatorMetrics {\n  const b = bubbleMetrics[platform];\n  const { dot, dotGap } = dotSizes[platform];\n  return {\n    width: 2 * b.paddingX + 3 * dot + 2 * dotGap,\n    height: 2 * b.paddingY + b.lineHeight,\n    dot,\n    dotGap,\n    radius: b.radius,\n    tailScale: b.tailScale,\n  };\n}\n\nexport const typingIndicatorMetrics: Record<Platform, TypingIndicatorMetrics> = {\n  ios: metricsFor(\"ios\"),\n  macos: metricsFor(\"macos\"),\n};\n\n/** Dot loop and the stagger between two dots. Provisional; no capture times the pulse. */\nexport const typingLoopMs = 1200;\nexport const typingStaggerMs = 200;\n\n/**\n * The delays are negative so a dot is never in its \"before\" phase: at time 0 dot 1 is already\n * `typingStaggerMs` into the loop instead of sitting on its unanimated base style, which is what made\n * a frozen frame at t = 0 disagree with the running animation.\n */\nconst dotDelayMs = (index: number) => index * typingStaggerMs - typingLoopMs;\n\nconst keyframes = `\n@keyframes im-typing-dot { 0%, 60%, 100% { opacity: .35; transform: translateY(0) scale(1); } 30% { opacity: 1; transform: translateY(-1px) scale(1.06); } }\n[data-slot=\"typing-indicator\"] > span[data-dot] { animation: im-typing-dot ${typingLoopMs}ms ease-in-out infinite both; }\n@media (prefers-reduced-motion: reduce) { [data-slot=\"typing-indicator\"] > span[data-dot] { animation: none; opacity: .7; } }\n`;\n\nexport type TypingIndicatorProps = ComponentProps<\"div\"> & {\n  label?: string;\n  /** The balloon is always the last thing in the transcript, so it carries a tail like any last bubble. */\n  tail?: boolean;\n  /**\n   * Screen-space y of the body's bottom edge, in px, for the position-dependent incoming fill. The\n   * message list tracks this element too, so it is only needed outside a list: pass it to keep a macOS\n   * dark balloon exactly on the incoming ramp; the iOS grays are flat and never need it.\n   */\n  screenBottom?: number;\n  platform?: Platform;\n};\n\nexport function TypingIndicator({\n  label = \"Someone is typing\", tail = true, screenBottom, platform: platformProp, className, style, ...props\n}: TypingIndicatorProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const t = typingIndicatorMetrics[platform];\n  const tailW = tailBox.width * t.tailScale;\n  const tailH = tailBox.height * t.tailScale;\n  const hang = tailBox.hang * t.tailScale;\n\n  // Same screen-space fill an incoming bubble paints: one gradient in screen coordinates, anchored to\n  // the body's bottom so the body and the tail (which hangs `hang` lower) share the same image. The\n  // fallbacks only matter when the balloon is used outside a palette; inside one they never apply.\n  const screen = `var(--im-screen-h, ${palettes[platform].light.incoming.screenHeight}px)`;\n  const bottomVar = screenBottom === undefined ? `var(--bubble-bottom, calc(${screen} * 0.55))` : `${screenBottom}px`;\n  const fill: CSSProperties = {\n    backgroundImage: \"linear-gradient(var(--im-fill-top), var(--im-fill-bottom))\",\n    backgroundSize: `100% ${screen}`,\n    backgroundRepeat: \"no-repeat\",\n    backgroundColor: \"var(--im-fill-bottom)\",\n  };\n  const bodyFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (${screen} - ${bottomVar}))` };\n  const tailFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (${screen} - ${bottomVar} - ${hang}px))` };\n  const vars = {\n    \"--im-fill-top\": \"var(--im-gray-top, #e9e9eb)\",\n    \"--im-fill-bottom\": \"var(--im-gray-bottom, #e9e9eb)\",\n    ...(screenBottom === undefined ? {} : { \"--bubble-bottom\": `${screenBottom}px` }),\n  } as CSSProperties;\n\n  return (\n    <div role=\"status\" aria-label={label} data-slot=\"typing-indicator\" data-platform={platform}\n      className={cn(\"relative flex shrink-0 items-center justify-center\", className)}\n      style={{ width: t.width, height: t.height, gap: t.dotGap, ...vars, ...style }} {...props}>\n      <style>{keyframes}</style>\n      {/* The fill sits behind the dots so clipping the tail corner never clips a dot. */}\n      <div aria-hidden=\"true\" data-slot=\"fill\" className=\"pointer-events-none absolute inset-0\"\n        style={{ borderRadius: t.radius, clipPath: tail ? bodyClipPath(\"left\", t.tailScale, tailSeamOverlap[platform]) : undefined, ...bodyFill }} />\n      {tail && <div aria-hidden=\"true\" data-slot=\"tail\" className=\"pointer-events-none absolute\" style={{\n        left: 0, bottom: -hang, width: tailW, height: tailH + hang, clipPath: `path(\"${tailPath(\"left\", t.tailScale)}\")`, ...tailFill,\n      }} />}\n      {[0, 1, 2].map(index => (\n        <span key={index} aria-hidden=\"true\" data-dot={index} className=\"relative block rounded-full\"\n          style={{ width: t.dot, height: t.dot, background: \"var(--im-secondary, #8a8a8e)\", animationDelay: `${dotDelayMs(index)}ms` }} />\n      ))}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/typing-indicator.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "date-separator",
      "title": "Date separator",
      "description": "Date headers: the iOS two-line header with its service line, the mid-list one-liner, and the macOS variant.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/date-separator.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * The centered timestamp that separates conversation history.\n *\n * Measured (references/ios/captures/conv3-light.png @3x, references/macos/captures/conversation-pane-light-partial.png @2x):\n * - iOS: two lines (\"iMessage\" then \"Today 1:25 AM\"), 11pt on a 14pt pitch, #8a8a8e. The service line and\n *   \"Today\" render at weight 500; the time is regular. The first bubble sits 7.6pt below the second line box.\n * - macOS: one line, 9pt on an 11pt pitch (ink 63 x 9pt for \"Today 1:47 AM\"; SPEC.md's 11pt is 20% too wide),\n *   \"Today\" at weight **500**, #808080 light / #9a9a9a dark. The first bubble's body top sits 6.0 below the\n *   header's ink bottom. Weight 500 is measured, not assumed: rendering the string at 9pt/2x and comparing\n *   the ten ink runs of `conversation-pane-light-partial.png` (normalised to the \"T\", pt from its left edge:\n *   0.00-10.50, 11.00-16.00, 16.50-21.00, 22.00-26.50, 30.00-33.00, 34.50-36.00, 37.00-42.50, 43.00-47.50,\n *   49.50-55.50, 56.50-63.00) puts weight 500 within one device px on all ten and on the 63.00 total, where\n *   600 runs 0.5 wide on seven of them and 63.50 overall, and 400 is 0.5 narrow on eight. Same weight as iOS.\n *   `gapBelow` is 6 even though the box wants 5.7: at 9pt/2x Blink paints the ink at `round(box top) + 2`,\n *   so 5.7 and 6 put the ink on the same device row, and 6 is the number the capture states.\n *\n * Only the header that opens a conversation carries the service name; a header inserted mid-list after an\n * hour-long gap is one line and sits in a gap of its own. Measured in `dateheader-mid-light.png`: the\n * previous body bottom is at 605.0, the header ink runs 621.0 to 631.33, the next bubble top is 639.0.\n * Chrome snaps a baseline to whole CSS px, so the ink can land a third of a point off the capture even\n * when the box is exactly right; the gaps below are the box positions, not the rasterised ink.\n */\nexport type DateSeparatorVariant = \"first\" | \"mid\";\nexport type DateSeparatorMetrics = {\n  fontSize: number; lineHeight: number; weight: number; dayWeight: number;\n  /** Space between the previous row and the line box, and between the line box and the next row. */\n  gapAbove: number; gapBelow: number;\n  /** The same two gaps for a mid-list header, which has no bubble above it to lean on. */\n  midGapAbove: number; midGapBelow: number;\n};\n\nexport const dateSeparatorMetrics: Record<Platform, DateSeparatorMetrics> = {\n  ios: { fontSize: 11, lineHeight: 14, weight: 400, dayWeight: 500, gapAbove: 0, gapBelow: 7.6, midGapAbove: 13.33, midGapBelow: 6.67 },\n  // No macOS capture of a mid-list header yet: it reuses the between-cluster gap above and the measured 6 below.\n  macos: { fontSize: 9, lineHeight: 11, weight: 400, dayWeight: 500, gapAbove: 0, gapBelow: 6, midGapAbove: 11.5, midGapBelow: 6 },\n};\n\nexport type DateLabel = {\n  /** \"Today\", \"Yesterday\", a weekday name, or \"Sep 8, 2026\". */\n  day: string;\n  /** \"1:25 AM\" (narrow no-break space before the period, as Apple renders it). */\n  time: string;\n  /** \" \" for relative days, \" at \" before absolute dates. */\n  joiner: \" \" | \" at \";\n  text: string;\n  iso: string;\n};\n\nconst WEEKDAYS = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\nfunction startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); }\n\nexport function formatClockTime(value: Date | number): string {\n  const d = new Date(value);\n  const hours = d.getHours();\n  const h12 = hours % 12 || 12;\n  return `${h12}:${String(d.getMinutes()).padStart(2, \"0\")} ${hours < 12 ? \"AM\" : \"PM\"}`;\n}\n\n/** Apple's relative date style: Today / Yesterday / weekday within a week / \"Sep 8, 2026 at 1:25 AM\". */\nexport function formatDateLabel(value: Date | number, now: Date | number = Date.now()): DateLabel {\n  const d = new Date(value);\n  const days = Math.round((startOfDay(new Date(now)) - startOfDay(d)) / 86_400_000);\n  const time = formatClockTime(d);\n  let day: string;\n  let joiner: DateLabel[\"joiner\"] = \" \";\n  if (days === 0) day = \"Today\";\n  else if (days === 1) day = \"Yesterday\";\n  else if (days > 1 && days < 7) day = WEEKDAYS[d.getDay()];\n  else { day = `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; joiner = \" at \"; }\n  return { day, time, joiner, text: `${day}${joiner}${time}`, iso: d.toISOString() };\n}\n\nexport type DateSeparatorProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** The moment the header describes. Omit to render `children` verbatim. */\n  date?: Date | number;\n  /** Reference time for \"Today\"/\"Yesterday\"; defaults to the current time. */\n  now?: Date | number;\n  /** iOS shows the conversation's service (\"iMessage\", \"Text Message\") above the date. Ignored on macOS. */\n  service?: ReactNode;\n  /** \"first\" opens the conversation; \"mid\" is inserted after an hour-long gap and gets its own spacing. */\n  variant?: DateSeparatorVariant;\n  platform?: Platform;\n  /** Custom label used instead of the formatted date. */\n  children?: ReactNode;\n  dateTime?: string;\n};\n\nexport function DateSeparator({ date, now, service, variant = \"first\", platform: platformProp, children, dateTime, className, style, ...props }: DateSeparatorProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = dateSeparatorMetrics[platform];\n  const mid = variant === \"mid\";\n  const label = date === undefined ? undefined : formatDateLabel(date, now);\n  return (\n    <div data-slot=\"date-separator\" data-platform={platform} data-variant={variant} className={cn(\"text-center\", className)}\n      style={{ fontFamily: fontStack, fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, fontWeight: m.weight, letterSpacing: 0, color: \"var(--im-secondary, #8a8a8e)\", paddingTop: mid ? m.midGapAbove : m.gapAbove, paddingBottom: mid ? m.midGapBelow : m.gapBelow, ...style }} {...props}>\n      {platform === \"ios\" && !mid && service ? <div data-slot=\"service\" style={{ fontWeight: m.dayWeight }}>{service}</div> : null}\n      <time dateTime={dateTime ?? label?.iso}>\n        {children ?? (label ? <><span data-slot=\"day\" style={{ fontWeight: m.dayWeight }}>{label.day}</span>{label.joiner}{label.time}</> : null)}\n      </time>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/date-separator.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$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/imessage/platform\";\nimport { fontStack } from \"@/components/imessage/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  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, 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=\"\" style={{ display: \"block\", width: \"100%\", height: \"100%\", objectFit: \"cover\" }} /> : null);\n  const rich = Boolean(mediaNode);\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      <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/imessage/link-preview.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$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/imessage/bubble-shape\";\nimport { usePlatform, type Platform } from \"@/components/imessage/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/imessage/message-attachment.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "conversation",
      "title": "Conversation",
      "description": "A complete, composable conversation for either platform: header, scrolling log with clusters and reactions, typing state, and a composer. Bring your own data and send handler.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/ios-composer.json",
        "https://imessage.swerdlow.dev/r/ios-nav-bar.json",
        "https://imessage.swerdlow.dev/r/macos-composer.json",
        "https://imessage.swerdlow.dev/r/macos-header.json",
        "https://imessage.swerdlow.dev/r/message-list.json",
        "https://imessage.swerdlow.dev/r/palette.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json"
      ],
      "files": [
        {
          "path": "registry/imessage/conversation.tsx",
          "content": "\"use client\";\n\nimport { useRef, type ComponentProps, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { PlatformProvider, type Platform } from \"@/components/imessage/platform\";\nimport { PaletteStyle } from \"@/components/imessage/palette\";\nimport { MessageList, type Message, type MessageListHandle } from \"@/components/imessage/message-list\";\nimport { IosNavBar } from \"@/components/imessage/ios-nav-bar\";\nimport { IosComposer } from \"@/components/imessage/ios-composer\";\nimport { MacHeader, macHeaderMetrics } from \"@/components/imessage/macos-header\";\nimport { MacComposer, macComposerMetrics } from \"@/components/imessage/macos-composer\";\nimport { Tapback, type TapbackType } from \"@/components/imessage/tapback\";\n\nexport type { Message as ConversationMessage };\n\n/**\n * What the macOS log keeps clear at the bottom. The composer's field top edge sits 42 above the pane\n * bottom (`macComposerMetrics.bottom` 11 + field height 31) and the log's content box stops 16.15 above\n * that edge. Measured in `references/macos/captures/conversation-pane-light.png` (630 x 640 pane at 2x):\n * the field's top edge is at pane y 598.0, the last bubble's body bottom at 566.85 and the \"Delivered\"\n * ink bottom at 580.5, which is where 57.2 puts them. The same number as `macScreen.listBottom` in the\n * app shell; kept local so installing `conversation` does not pull in the whole window.\n */\nconst macListBottom = macComposerMetrics.bottom + macComposerMetrics.field.height + 16.15;\n\nexport type ConversationProps = Omit<ComponentProps<\"section\">, \"children\"> & {\n  platform?: Platform;\n  name: string;\n  initials?: string;\n  messages: Message[];\n  typing?: boolean;\n  group?: boolean;\n  now?: Date | number;\n  onSend?: (text: string) => void | Promise<void>;\n  onAttach?: () => void;\n  onBack?: () => void;\n  onVideoCall?: () => void;\n  onDetails?: () => void;\n  /** Called when a balloon is clicked (e.g. to remove your own reaction). */\n  onReaction?: (message: Message, reaction: NonNullable<Message[\"reactions\"]>[number]) => void;\n  renderReactions?: (message: Message) => ReactNode;\n  width?: number;\n  height?: number;\n};\n\n/**\n * A complete conversation pane for either platform: header, the scrolling log with native clusters,\n * tails, date headers and reactions, and the composer. Bring your own data and send handler.\n */\nexport function Conversation({ platform = \"ios\", name, initials, messages, typing = false, group = false, now, onSend, onAttach, onBack, onVideoCall, onDetails, onReaction, renderReactions, width, height, className, style, ...props }: ConversationProps) {\n  const frame = useRef<HTMLElement>(null);\n  const list = useRef<MessageListHandle>(null);\n  const ios = platform === \"ios\";\n  const reactions = renderReactions ?? ((message: Message) => message.reactions?.length ? (\n    <div className=\"flex\" style={{ gap: 2 }}>\n      {message.reactions.map((reaction, index) => (\n        <Tapback key={index} reaction={reaction.emoji ? undefined : (reaction.type as TapbackType)} emoji={reaction.emoji} own={reaction.byMe ?? true}\n          side={message.direction === \"outgoing\" ? \"left\" : \"right\"} onClick={onReaction ? () => onReaction(message, reaction) : undefined} />\n      ))}\n    </div>\n  ) : undefined);\n  const size = { width: width ?? (ios ? 402 : 630), height: height ?? (ios ? 760 : 640) };\n  return (\n    <PlatformProvider platform={platform}>\n      <PaletteStyle platform={platform} />\n      <section ref={frame} aria-label={`Conversation with ${name}`} data-slot=\"conversation\" data-im-platform={platform}\n        className={cn(\"relative isolate overflow-hidden font-sans\", ios ? \"rounded-[24px]\" : \"rounded-[12px]\", className)}\n        style={{ ...size, background: \"var(--im-bg)\", color: \"var(--im-incoming-text)\", ...style }} {...props}>\n        <MessageList ref={list} frameRef={frame} messages={messages} typing={typing ? { sender: name } : false} group={group} now={now}\n          anchor={ios ? \"top\" : \"bottom\"} insetTop={ios ? 115.5 : macHeaderMetrics.height + 29.3} insetBottom={ios ? 79 : macListBottom} renderReactions={reactions} className=\"absolute inset-0\" />\n        {ios ? (\n          <>\n            <IosNavBar name={name} initials={initials} onBack={onBack} onDetails={onDetails} className=\"absolute left-0 top-0\" />\n            {onSend && <IosComposer className=\"absolute bottom-0 left-0\" onSend={onSend} onAttach={onAttach} />}\n          </>\n        ) : (\n          <>\n            <MacHeader name={name} initials={initials} onVideoCall={onVideoCall} onOpenDetails={onDetails} className=\"absolute left-0 top-0 w-full\" style={{ height: macHeaderMetrics.height }} />\n            {onSend && <MacComposer className=\"absolute bottom-0 left-0 w-full\" onSend={onSend} onAttach={onAttach} />}\n          </>\n        )}\n      </section>\n    </PlatformProvider>\n  );\n}\n",
          "type": "registry:component",
          "target": "components/imessage/conversation.tsx"
        }
      ],
      "type": "registry:block"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-motion",
      "title": "Message motion",
      "description": "The measured send and receive animations, seekable so a harness can scrub them.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-motion.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, type ComponentProps, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { bubblePath, tailBox } from \"@/components/imessage/bubble-shape\";\nimport { bubbleMetrics } from \"@/components/imessage/tokens\";\nimport type { Platform } from \"@/components/imessage/platform\";\n\n/**\n * Motion engine for the message list. Everything is built on the Web Animations API so a running\n * animation can be paused and scrubbed with `seek(ms)`, which is how the lab captures frames for\n * comparison with the 60 fps native recording (references/ios/motion/).\n *\n * Send timeline, re-measured frame by frame from `references/ios/motion/send-60fps.mp4`. Frames are the\n * 0-based output of `ffmpeg -vsync 0 -start_number 0` (299 frames, 60 fps), so t = (f - 73) / 60 s: f68 to\n * f72 differ by at most 2 in any channel and every one of them paints the blue send button (3321 px with\n * B - R > 80 in an 80 x 65 box over it; f73 has none), so f73 is the first animated frame and t = 0. The\n * contact sheets in that folder are 1-based on the same clip - their \"f74\" is this file's f73 - and the\n * times this file used to carry were read from f71, two frames (33 ms) early of the first frame that moves.\n * - 0 ms (f73): the text row is *already* a tinted rounded rect, tail and all, over exactly the field's text\n *   box; the send button is already gone and the placeholder is already at full strength (its darkest glyph\n *   luma is 170.5 here and 175.9 once the rect has passed, over backgrounds of 232.6 and 253.0).\n * - 17 to 150 ms: the rect collapses toward the field's right end. Widths, pt: 292 (f73), 294 (f74), 272\n *   (f75), 252, 226, 195, 154 (f79), 123 (f80), 98, 82, 68 (f84). f74 is still full width, so the collapse\n *   starts between 17 and 33 ms. Native keeps shrinking past the bubble's own size to 0.772 at 183 ms; the\n *   clone cannot hold a scale above 1, so the rect carries the shape to 150 ms, where the scale crosses 1.\n * - 33 ms on: the bubble leaves the field on a spring released from rest. Fitted over f84 to f133 with the\n *   amplitude pinned to the geometric travel (127.1 pt), damping 0.73 and 11.9 rad/s reproduces the body's\n *   bottom edge to 0.28 pt rms / 0.77 pt worst over 50 frames; the old 0.8 and 15 rad/s from 103 ms misses\n *   by 9.6 pt rms / 35 pt worst. Scale recovers on a second, slower spring released 10 ms past the minimum.\n * - The bubble overshoots: its tail corner passes 5.0 pt above the slot at 417 ms (f98) and falls back,\n *   inside 0.25 pt of rest at 633 ms (f111) and settled by 690 ms (f114). Scale is done by ~500 ms flat.\n *\n * Measured \"Hi there\" body tops, recording/this model (402x874 screen, slot top 686.8/687.25): 150 ms\n * 760.8/763.2, 233 ms 717.6/718.0, 317 ms 691.8/692.9, 350 ms 686.9/688.0. f83 duplicates f82, so 167 ms\n * is a repeat. At 1017 ms (f134) \"Delivered\" moves off the bubble above and the slot rises 15 pt to 671.75.\n */\nexport const messageMotion = {\n  /** Legacy tokens kept for the harness and pressable-message. */\n  arrival: 420,\n  delivery: 900,\n  reaction: 320,\n  press: 120,\n  longPress: 500,\n  send: {\n    morph: 17, liftoff: 33, shrink: 150, duration: 690,\n    textReveal: 25, textVisible: 70,\n    /** The placeholder is already at full strength in f73, so it holds rather than fading in. */\n    placeholderIn: 0, tint: 0.55,\n    /** In-flight scale: the bubble flies at (1 - squash) of its size and grows back on `growSpring`. */\n    squash: 0.228, grow: 193,\n    spring: { damping: 0.73, frequency: 11.9 },\n    growSpring: { damping: 0.8, frequency: 13.1 },\n  },\n  receive: { duration: 300, fade: 80, startScale: 0.5, spring: { damping: 0.7, frequency: 30 } },\n  reduced: { duration: 200 },\n} as const;\n\nexport type MotionHandle = {\n  duration: number;\n  /** Pause every animation and jump to `ms`. */\n  seek(ms: number): void;\n  play(): void;\n  pause(): void;\n  /** Stop and remove any helper elements. */\n  cancel(): void;\n  /** Resolves when the animation finishes (or is cancelled). */\n  finished: Promise<void>;\n  readonly animations: Animation[];\n};\n\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\n/**\n * Remaining offset (as a fraction of the start offset) of an underdamped spring released from rest,\n * `t` in seconds. With the send spring's damping 0.73 and 11.9 rad/s the first overshoot is 3.5% of the\n * travel 386 ms after release, 3.8% once `offsetAt` normalises it: 4.9 pt of the send's 127.1 pt travel at\n * 419 ms, which is the 5.0 pt at 417 ms (f98) that the recording overshoots its slot by.\n */\nexport function springOffset(t: number, damping: number, frequency: number): number {\n  if (t <= 0) return 1;\n  const root = Math.sqrt(1 - damping * damping);\n  const wd = frequency * root;\n  return Math.exp(-damping * frequency * t) * (Math.cos(wd * t) + (damping / root) * Math.sin(wd * t));\n}\n\ntype Rect = { left: number; top: number; width: number; height: number };\n\nfunction makeHandle(animations: Animation[], duration: number, cleanup: () => void, paused: boolean | undefined, tick?: (ms: number) => void): MotionHandle {\n  if (paused) animations.forEach(a => a.pause());\n  let done = false;\n  let raf = 0;\n  const finish = () => { if (done) return; done = true; cancelAnimationFrame(raf); cleanup(); animations.forEach(a => { try { a.cancel(); } catch { /* already gone */ } }); };\n  const finished = Promise.all(animations.map(a => a.finished.catch(() => undefined))).then(finish);\n  const loop = () => {\n    raf = 0;\n    if (done || !tick) return;\n    const t = Number(animations[0]?.currentTime ?? 0);\n    tick(Math.max(0, Math.min(duration, t)));\n    if (animations[0]?.playState === \"running\") raf = requestAnimationFrame(loop);\n  };\n  if (tick && !paused) raf = requestAnimationFrame(loop);\n  return {\n    duration,\n    animations,\n    finished,\n    // Once `finish` has run the helper elements are gone, so every control is inert from then on:\n    // a late seek or play would otherwise drive animations whose targets are no longer in the page.\n    seek(ms) { if (done) return; const t = Math.max(0, Math.min(duration, ms)); animations.forEach(a => { a.pause(); a.currentTime = t; }); tick?.(t); },\n    play() { if (done) return; animations.forEach(a => a.play()); if (tick && !raf) raf = requestAnimationFrame(loop); },\n    pause() { if (done) return; animations.forEach(a => a.pause()); },\n    cancel() { finish(); },\n  };\n}\n\nfunction hexToRgb(hex: string): [number, number, number] | null {\n  const h = hex.trim().replace(\"#\", \"\");\n  if (h.length === 3) return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];\n  if (h.length === 6) return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n  return null;\n}\n\n/** Solid color of the bubble's screen-space gradient at screen y `bottom` (px from the frame top). */\nfunction fillAt(body: HTMLElement, bottom: number): string {\n  const cs = getComputedStyle(body);\n  const top = hexToRgb(cs.getPropertyValue(\"--im-fill-top\"));\n  const end = hexToRgb(cs.getPropertyValue(\"--im-fill-bottom\"));\n  const screen = parseFloat(cs.getPropertyValue(\"--im-screen-h\")) || 874;\n  if (!top || !end) return cs.backgroundColor;\n  const k = Math.max(0, Math.min(1, bottom / screen));\n  const c = top.map((v, i) => Math.round(v + (end[i] - v) * k));\n  return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;\n}\n\nfunction visibleText(body: HTMLElement): string {\n  return Array.from(body.childNodes).map(node => (node instanceof HTMLElement && node.classList.contains(\"sr-only\") ? \"\" : node.textContent ?? \"\")).join(\"\").trim();\n}\n\nexport type SendAnimationOptions = {\n  /** The element that represents the device screen. The transient ghost is appended to it, so it needs `position: relative`. */\n  frame: HTMLElement;\n  /** The composer text field's rect in viewport coordinates (from getBoundingClientRect). */\n  field: Rect;\n  /** The new message's `[data-slot=\"message-bubble\"]` element (already laid out in its final slot). */\n  bubble: HTMLElement;\n  /** Composer placeholder to fade in while the rect shrinks. */\n  placeholder?: HTMLElement | null;\n  /** Composer send button to hide in the first 17 ms. */\n  sendButton?: HTMLElement | null;\n  /** Text drawn inside the shrinking rect; defaults to the bubble's text. */\n  text?: string;\n  /** Corner radius of the composer field; the rect starts as that shape. Defaults to a pill. */\n  fieldRadius?: number;\n  /**\n   * Where the shrinking rect is drawn. Native draws it inside the field, under the placeholder, so pass the\n   * field element (a positioned one) to get that layering; it is added as the first child. Defaults to `frame`.\n   */\n  ghostParent?: HTMLElement | null;\n  /** Start paused so the caller can `seek`. */\n  paused?: boolean;\n  reducedMotion?: boolean;\n};\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nexport function playSendAnimation(o: SendAnimationOptions): MotionHandle {\n  const S = messageMotion.send;\n  const reduced = o.reducedMotion ?? prefersReducedMotion();\n  if (reduced) {\n    const D = messageMotion.reduced.duration;\n    const a = o.bubble.animate([{ opacity: 0 }, { opacity: 1 }], { duration: D, fill: \"both\", easing: \"ease-out\" });\n    const b = o.placeholder ? [o.placeholder.animate([{ opacity: 0 }, { opacity: 1 }], { duration: D, fill: \"both\" })] : [];\n    return makeHandle([a, ...b], D, () => undefined, o.paused);\n  }\n  const D = S.duration;\n  const at = (ms: number) => ms / D;\n  const timing: KeyframeAnimationOptions = { duration: D, fill: \"both\", easing: \"linear\" };\n  const body = o.bubble.querySelector<HTMLElement>('[data-slot=\"bubble\"], [data-slot=\"emoji\"]') ?? o.bubble;\n  const platform = (o.bubble.dataset.platform ?? \"ios\") as Platform;\n  const m = bubbleMetrics[platform];\n  const side = o.bubble.dataset.direction === \"incoming\" ? \"left\" : \"right\";\n  const frameRect = o.frame.getBoundingClientRect();\n  const slot = body.getBoundingClientRect();\n  const fx = o.field.left - frameRect.left, fy = o.field.top - frameRect.top, fw = o.field.width, fh = o.field.height;\n  const bw = slot.width, bh = slot.height;\n  const sx = slot.left - frameRect.left, sy = slot.top - frameRect.top;\n  const gx = side === \"right\" ? fx + fw - bw : fx, gy = fy + (fh - bh) / 2;\n  const dx = gx - sx, dy = gy - sy;\n  const cs = getComputedStyle(body);\n\n  // Two springs, both released from rest and both normalised so they land exactly on the slot at `duration`\n  // (without that the last keyframe would step by ~0.5 pt). `offsetAt` is the fraction of the travel still\n  // to go; `scaleAt` is the uniform scale, anchored at the bubble's tail corner so that corner flies alone.\n  const P = S.spring, G = S.growSpring;\n  const kEnd = springOffset((D - S.liftoff) / 1000, P.damping, P.frequency);\n  const gEnd = springOffset((D - S.grow) / 1000, G.damping, G.frequency);\n  const offsetAt = (ms: number) => (springOffset((ms - S.liftoff) / 1000, P.damping, P.frequency) - kEnd) / (1 - kEnd);\n  const scaleAt = (ms: number) => 1 - S.squash * (springOffset((ms - S.grow) / 1000, G.damping, G.frequency) - gEnd) / (1 - gEnd);\n  /** Where the flying bubble's box sits at `ms`, in frame coordinates. */\n  const flightBox = (ms: number) => {\n    const k = offsetAt(ms), scale = scaleAt(ms);\n    const width = bw * scale, height = bh * scale;\n    const bottom = sy + bh + dy * k;\n    const left = side === \"right\" ? sx + bw + dx * k - width : sx + dx * k;\n    return { left, top: bottom - height, width, height, scale };\n  };\n\n  // Phase 1: the field's text row becomes a bubble-shaped rect and shrinks toward the field's sending end.\n  // It ends exactly on the flying bubble's box at `shrink`, so the hand-over is invisible.\n  const ghost = document.createElement(\"div\");\n  ghost.dataset.slot = \"send-ghost\";\n  ghost.setAttribute(\"aria-hidden\", \"true\");\n  Object.assign(ghost.style, {\n    position: \"absolute\", left: \"0px\", top: \"0px\", boxSizing: \"border-box\",\n    background: fillAt(body, fy + fh), pointerEvents: \"none\",\n    // Inside the field it paints in DOM order, under the placeholder; over the frame it needs to be lifted.\n    zIndex: o.ghostParent ? \"auto\" : \"30\",\n    // A transient element must never become a scroll anchor: scrubbing rebuilds it every frame, and each\n    // insert and remove would otherwise let scroll anchoring nudge whatever scroller contains it.\n    overflowAnchor: \"none\",\n  } satisfies Partial<CSSStyleDeclaration>);\n  const tint = document.createElement(\"div\");\n  Object.assign(tint.style, { position: \"absolute\", inset: \"0\", background: \"#ffffff\", opacity: String(S.tint) });\n  const label = document.createElement(\"div\");\n  label.textContent = o.text ?? visibleText(body);\n  Object.assign(label.style, {\n    position: \"absolute\", [side]: \"0\", top: \"0\", width: `${bw}px`, height: `${bh}px`, boxSizing: \"border-box\",\n    padding: cs.padding, transformOrigin: `${side} top`,\n    font: cs.font, letterSpacing: cs.letterSpacing, lineHeight: cs.lineHeight, color: cs.color, whiteSpace: \"nowrap\", overflow: \"hidden\", opacity: \"0\",\n    textAlign: bw <= parseFloat(cs.minWidth || \"0\") + 0.5 ? \"center\" : \"start\",\n  });\n  ghost.append(tint, label);\n  const ghostParent = o.ghostParent ?? o.frame;\n  // Under the field's own text, but above whatever paints the field itself (a glass stack, a background).\n  const editor = ghostParent.querySelector(\"textarea, input, [contenteditable]\");\n  ghostParent.insertBefore(ghost, editor?.parentElement === ghostParent ? editor : ghostParent.firstChild);\n  // The keyframes below are in frame coordinates; shift them if the rect lives inside the field instead.\n  const ox = ghostParent === o.frame ? 0 : ghostParent.getBoundingClientRect().left - frameRect.left;\n  const oy = ghostParent === o.frame ? 0 : ghostParent.getBoundingClientRect().top - frameRect.top;\n\n  const fieldRadius = o.fieldRadius ?? fh / 2;\n  const shape: Keyframe[] = [];\n  const labelScale: Keyframe[] = [];\n  for (let ms = 0; ms <= S.shrink; ms = Math.min(S.shrink, ms + 5)) {\n    const p = Math.max(0, Math.min(1, (ms - S.morph) / (S.shrink - S.morph)));\n    const b = flightBox(ms);\n    const width = lerp(fw, b.width, p), height = lerp(fh, b.height, p);\n    const tailScale = m.tailScale * lerp(1, b.scale, p);\n    shape.push({\n      offset: at(ms), opacity: ms < S.morph ? at(ms) / at(S.morph) : 1,\n      left: `${(lerp(fx, b.left, p) - ox).toFixed(2)}px`, top: `${(lerp(fy, b.top, p) - oy).toFixed(2)}px`,\n      width: `${width.toFixed(2)}px`, height: `${(height + tailBox.hang * tailScale).toFixed(2)}px`,\n      clipPath: `path(\"${bubblePath(width, height, side, lerp(fieldRadius, m.radius * b.scale, p), tailScale)}\")`,\n    });\n    labelScale.push({ offset: at(ms), transform: `scale(${lerp(1, b.scale, p).toFixed(4)})` });\n    if (ms >= S.shrink) break;\n  }\n  const settled = { ...shape[shape.length - 1], offset: undefined } as Keyframe;\n  const animations: Animation[] = [\n    ghost.animate([...shape, { ...settled, offset: at(S.shrink) + 0.002, opacity: 0 }, { ...settled, offset: 1, opacity: 0 }], timing),\n    tint.animate([{ offset: 0, opacity: S.tint }, { offset: at(S.morph), opacity: S.tint }, { offset: at(S.shrink), opacity: 0 }, { offset: 1, opacity: 0 }], timing),\n    label.animate(labelScale.map(frame => ({ ...frame, opacity: labelOpacity(Number(frame.offset) * D, S) })), timing),\n  ];\n\n  // Phase 2: the flight is flown by a clone of the bubble in the frame's overlay layer, so it passes over the\n  // composer the way the native bubble does (at 133 ms the recording draws its tail across the field's pill);\n  // the real bubble stays invisible in its slot until the clone lands.\n  const rootRect = o.bubble.getBoundingClientRect();\n  const clone = o.bubble.cloneNode(true) as HTMLElement;\n  clone.dataset.slot = \"send-clone\";\n  clone.setAttribute(\"aria-hidden\", \"true\");\n  clone.querySelectorAll(\"[id]\").forEach(el => el.removeAttribute(\"id\"));\n  Object.assign(clone.style, {\n    position: \"absolute\", left: `${rootRect.left - frameRect.left}px`, top: `${rootRect.top - frameRect.top}px`, width: `${rootRect.width}px`,\n    margin: \"0\", pointerEvents: \"none\", zIndex: \"31\", opacity: \"0\", overflowAnchor: \"none\",\n    // The tail corner is the anchor: it flies alone and the rest of the bubble unfolds from it.\n    transformOrigin: `${((side === \"right\" ? slot.right : slot.left) - rootRect.left).toFixed(2)}px ${(slot.bottom - rootRect.top).toFixed(2)}px`,\n  });\n  // `cloneNode` copies declarations but not the palette the bubble inherits, so resolve it onto the clone.\n  // It then paints the measured gradient wherever it is appended, not only under the element that\n  // happens to declare `--im-*` today (both shells append it inside that element; this stops depending on it).\n  for (const property of Array.from(cs)) if (property.startsWith(\"--im-\")) clone.style.setProperty(property, cs.getPropertyValue(property));\n  const realFrame = o.bubble.querySelector<HTMLElement>('[data-slot=\"bubble-frame\"]');\n  const cloneFrame = clone.querySelector<HTMLElement>('[data-slot=\"bubble-frame\"]');\n  if (realFrame && cloneFrame) { cloneFrame.style.width = `${realFrame.getBoundingClientRect().width}px`; cloneFrame.style.maxWidth = \"none\"; }\n  o.frame.appendChild(clone);\n\n  const transformAt = (ms: number) => `translate(${(dx * offsetAt(ms)).toFixed(2)}px, ${(dy * offsetAt(ms)).toFixed(2)}px) scale(${scaleAt(ms).toFixed(4)})`;\n  const flight: Keyframe[] = [{ offset: 0, transform: transformAt(0) }, { offset: at(S.shrink) - 0.002, transform: transformAt(S.shrink) }];\n  for (let ms = S.shrink; ms < D; ms += 5) flight.push({ offset: at(ms), transform: transformAt(ms) });\n  flight.push({ offset: 1, transform: \"translate(0px, 0px) scale(1)\" });\n  animations.push(clone.animate(flight, timing));\n  // The clone and the real bubble swap places with a step, not a crossfade, so at `duration` (and at\n  // progress 1, which never runs `cleanup` because a seeked animation never finishes) exactly one of\n  // them is painted and the frame equals the settled layout. A ramp here would let a scrub land on a\n  // frame with both of them half opaque, which reads as a lighter, doubled bubble.\n  animations.push(clone.animate([{ offset: 0, opacity: 0 }, { offset: at(S.shrink) - 0.002, opacity: 0 }, { offset: at(S.shrink), opacity: 1, easing: \"step-end\" }, { offset: 1, opacity: 0 }], timing));\n  animations.push(o.bubble.animate([{ offset: 0, opacity: 0, easing: \"step-end\" }, { offset: 1, opacity: 1 }], timing));\n  // f73 already draws the placeholder at full strength behind the rect (hence `placeholderIn: 0`), so it is\n  // held up for the whole flight rather than faded in; it is animated only so the handle owns its opacity.\n  if (o.placeholder) animations.push(o.placeholder.animate([{ offset: 0, opacity: 1 }, { offset: 1, opacity: 1 }], timing));\n  if (o.sendButton) animations.push(o.sendButton.animate([{ offset: 0, opacity: 1 }, { offset: at(S.morph), opacity: 0 }, { offset: 1, opacity: 0 }], timing));\n  // Keep the clone's screen-space fill honest while it climbs (the body's bottom edge moves through the gradient).\n  const tick = (ms: number) => clone.style.setProperty(\"--bubble-bottom\", `${(sy + bh + dy * offsetAt(ms)).toFixed(2)}px`);\n  tick(0);\n  return makeHandle(animations, D, () => { ghost.remove(); clone.remove(); }, o.paused, tick);\n}\n\n/** The message text fades in inside the shrinking rect as it takes the bubble's shape. */\nfunction labelOpacity(ms: number, S: typeof messageMotion.send): number {\n  if (ms <= S.textReveal) return 0;\n  if (ms >= S.textVisible) return 1;\n  return (ms - S.textReveal) / (S.textVisible - S.textReveal);\n}\n\nexport type ReceiveAnimationOptions = {\n  /** The new incoming message's `[data-slot=\"message-bubble\"]` element. */\n  bubble: HTMLElement;\n  /** Where the bubble pops from (the typing indicator's rect, viewport coordinates). Defaults to just below the slot. */\n  from?: Rect | null;\n  paused?: boolean;\n  reducedMotion?: boolean;\n};\n\nexport function playReceiveAnimation(o: ReceiveAnimationOptions): MotionHandle {\n  const R = messageMotion.receive;\n  const reduced = o.reducedMotion ?? prefersReducedMotion();\n  if (reduced) {\n    const D = messageMotion.reduced.duration;\n    return makeHandle([o.bubble.animate([{ opacity: 0 }, { opacity: 1 }], { duration: D, fill: \"both\", easing: \"ease-out\" })], D, () => undefined, o.paused);\n  }\n  const D = R.duration;\n  const body = o.bubble.querySelector<HTMLElement>('[data-slot=\"bubble\"], [data-slot=\"emoji\"]') ?? o.bubble;\n  const slot = body.getBoundingClientRect();\n  const dx = o.from ? o.from.left - slot.left : 0;\n  const dy = o.from ? o.from.top + o.from.height - (slot.top + slot.height) : 8;\n  // The corner it grows from rides in the keyframes rather than in the element's own style: a seeked\n  // animation never finishes, so a cleanup that restored an inline `transform-origin` would never run.\n  const transformOrigin = o.bubble.dataset.direction === \"outgoing\" ? \"right bottom\" : \"left bottom\";\n  const frames: Keyframe[] = [];\n  for (let ms = 0; ms <= D; ms += 5) {\n    const k = springOffset(ms / 1000, R.spring.damping, R.spring.frequency);\n    const p = 1 - k;\n    const scale = R.startScale + (1 - R.startScale) * p;\n    frames.push({ offset: ms / D, opacity: Math.min(1, ms / R.fade), transformOrigin, transform: `translate(${(dx * k).toFixed(2)}px, ${(dy * k).toFixed(2)}px) scale(${scale.toFixed(4)})` });\n  }\n  frames.push({ offset: 1, opacity: 1, transformOrigin, transform: \"translate(0px, 0px) scale(1)\" });\n  const animation = o.bubble.animate(frames, { duration: D, fill: \"both\", easing: \"linear\" });\n  return makeHandle([animation], D, () => undefined, o.paused);\n}\n\n/** Runs the send animation for a freshly rendered outgoing bubble. Cancels on unmount and when a new send starts. */\nexport function useSendAnimation() {\n  const current = useRef<MotionHandle | null>(null);\n  useEffect(() => () => current.current?.cancel(), []);\n  const play = useCallback((options: SendAnimationOptions) => {\n    current.current?.cancel();\n    const handle = playSendAnimation(options);\n    current.current = handle;\n    void handle.finished.then(() => { if (current.current === handle) current.current = null; });\n    return handle;\n  }, []);\n  return { play, current };\n}\n\n/** Pops a freshly rendered incoming bubble in from the typing indicator's position. */\nexport function useReceiveAnimation() {\n  const current = useRef<MotionHandle | null>(null);\n  useEffect(() => () => current.current?.cancel(), []);\n  const play = useCallback((options: ReceiveAnimationOptions) => {\n    current.current?.cancel();\n    const handle = playReceiveAnimation(options);\n    current.current = handle;\n    void handle.finished.then(() => { if (current.current === handle) current.current = null; });\n    return handle;\n  }, []);\n  return { play, current };\n}\n\n/** One message's arrival: `progress` (0 to 1) seeks the animation instead of playing it. */\nexport type ArrivalAnimation = { id: string; progress?: number };\n\nexport type ArrivalAnimationOptions = {\n  /** The device frame (iOS) or window pane (macOS); the flying copy is added to it. */\n  frame: RefObject<HTMLElement | null>;\n  /** The message that was just sent: the composer's text row becomes that bubble and flies to its slot. */\n  send?: ArrivalAnimation | null;\n  /** The message that just arrived: it pops in from the typing indicator's position. */\n  receive?: ArrivalAnimation | null;\n  /** Called when a played (not seeked) send animation finishes. */\n  onSendEnd?: () => void;\n};\n\n/**\n * Drives the send and receive animations for the app shells: give it the frame and the id of the message\n * that just arrived and it finds the bubble, the composer field and the typing indicator itself.\n */\nexport function useArrivalAnimation({ frame, send, receive, onSendEnd }: ArrivalAnimationOptions) {\n  const running = useRef<MotionHandle | null>(null);\n  // Kept in a ref so a fresh inline callback cannot restart a flight that is already in the air.\n  const end = useRef(onSendEnd);\n  useEffect(() => { end.current = onSendEnd; }, [onSendEnd]);\n  const sendId = send?.id ?? null, sendProgress = send?.progress;\n  const receiveId = receive?.id ?? null, receiveProgress = receive?.progress;\n\n  useLayoutEffect(() => {\n    const root = frame.current;\n    if (!root || !sendId) return;\n    const bubble = root.querySelector<HTMLElement>(`[data-message-id=\"${CSS.escape(sendId)}\"] [data-slot=\"message-bubble\"]`);\n    const field = root.querySelector<HTMLElement>('[data-slot=\"field\"]');\n    if (!bubble || !field) return;\n    running.current?.cancel();\n    const handle = playSendAnimation({\n      frame: root, field: field.getBoundingClientRect(), bubble, ghostParent: field,\n      sendButton: field.querySelector<HTMLElement>('[data-slot=\"send\"]'),\n      fieldRadius: parseFloat(getComputedStyle(field).borderTopLeftRadius) || undefined,\n      paused: sendProgress !== undefined,\n    });\n    running.current = handle;\n    if (sendProgress === undefined) void handle.finished.then(() => { if (running.current === handle) { running.current = null; end.current?.(); } });\n    else handle.seek(Math.max(0, Math.min(1, sendProgress)) * handle.duration);\n    return () => { handle.cancel(); if (running.current === handle) running.current = null; };\n  }, [frame, sendId, sendProgress]);\n\n  useLayoutEffect(() => {\n    const root = frame.current;\n    if (!root || !receiveId) return;\n    const bubble = root.querySelector<HTMLElement>(`[data-message-id=\"${CSS.escape(receiveId)}\"] [data-slot=\"message-bubble\"]`);\n    if (!bubble) return;\n    running.current?.cancel();\n    const indicator = root.querySelector<HTMLElement>('[data-slot=\"typing-indicator\"]');\n    const handle = playReceiveAnimation({ bubble, from: indicator?.getBoundingClientRect() ?? null, paused: receiveProgress !== undefined });\n    running.current = handle;\n    if (receiveProgress !== undefined) handle.seek(Math.max(0, Math.min(1, receiveProgress)) * handle.duration);\n    return () => { handle.cancel(); if (running.current === handle) running.current = null; };\n  }, [frame, receiveId, receiveProgress]);\n\n  useEffect(() => () => running.current?.cancel(), []);\n}\n\n/** Progress-driven arrival wrapper kept for the harness: `progress` 0..1 maps onto the receive spring. */\nexport function MessageMotion({ progress = 1, direction = \"incoming\", className, style, ...props }: ComponentProps<\"div\"> & { progress?: number; direction?: \"incoming\" | \"outgoing\" }) {\n  const t = Math.max(0, Math.min(1, progress));\n  const k = springOffset((t * messageMotion.receive.duration) / 1000, messageMotion.receive.spring.damping, messageMotion.receive.spring.frequency);\n  const p = t >= 1 ? 1 : 1 - k;\n  const scale = messageMotion.receive.startScale + (1 - messageMotion.receive.startScale) * p;\n  return <div data-slot=\"message-motion\" data-progress={t.toFixed(3)} className={cn(\"motion-reduce:!transform-none motion-reduce:!opacity-100\", direction === \"outgoing\" ? \"origin-bottom-right\" : \"origin-bottom-left\", className)}\n    style={{ opacity: Math.min(1, t * 4), transform: t >= 1 ? undefined : `translateY(${((1 - p) * (direction === \"outgoing\" ? 30 : 12)).toFixed(2)}px) scale(${scale.toFixed(4)})`, ...style }} {...props} />;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-motion.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "facetime-card",
      "title": "FaceTime card",
      "description": "A FaceTime link card in the native card style, plus the call states an application may want to show around it.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/facetime-card.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * A FaceTime link shared into a conversation, and the call states an app may want to show around it.\n *\n * Messages does not render a live call inline: an active FaceTime call lives in its own window, and\n * call history lives in the Phone and FaceTime apps. What genuinely appears in a transcript is a\n * FaceTime *link* card, which SPEC.md records as \"the same gray card as a link preview\".\n *\n * **No capture in this repo shows a FaceTime card, in either state.** Nothing below was measured off\n * one. What the card does inherit is the link card's own measured geometry, so that \"the same gray\n * card\" is true of the pixels and not only of the prose (`references/macos/captures/conversation-pane-light.png`,\n * via `link-preview.tsx`): the fill #e9e9eb light / #3b3b3d dark, the 14 radius, the 10 side padding,\n * and the caption at 10pt on a 12pt pitch in #808084 / #a6a6a9. iOS takes the same parts from the\n * numbers iOS itself measures, exactly as `link-preview.tsx` does: the 19 bubble radius, the 280.5\n * widest bubble, the incoming gray, and the secondary label. Everything else here is invented: the\n * icon, the greens and the red, the button, and every gap. The four states other than `invitation`\n * are app surfaces, not reproductions of a native element. Do not quote any of it as measured.\n */\nexport type FaceTimeState = \"invitation\" | \"ringing\" | \"connected\" | \"ended\" | \"missed\";\n\ntype Metrics = { width: number; radius: number; padX: number; padY: number; icon: number; iconRadius: number; title: number; titleLine: number; sub: number; subLine: number; gap: number; button: number; buttonText: number; rowGap: number };\n\nconst metrics: Record<Platform, Metrics> = {\n  ios: { width: 280.5, radius: 19, padX: 14, padY: 12, icon: 44, iconRadius: 11, title: 17, titleLine: 20, sub: 14, subLine: 17, gap: 11, button: 36, buttonText: 15, rowGap: 12 },\n  macos: { width: 240, radius: 14, padX: 10, padY: 10, icon: 34, iconRadius: 8.5, title: 13, titleLine: 16, sub: 10, subLine: 12, gap: 9, button: 28, buttonText: 12, rowGap: 9 },\n};\n\n/**\n * The link card's fill and caption colour, copied from `link-preview.tsx` so the two cards match.\n * macOS is measured off the apple.com card; iOS swaps in its own measured incoming gray and secondary\n * label, because #3b3b3d is the macOS gray and reads wrong beside iOS bubbles.\n */\nconst cardVars: Record<Platform, string> = {\n  macos: \"[--im-card:#e9e9eb] [--im-card-fg:#808084] [--im-card-title:#000000] dark:[--im-card:#3b3b3d] dark:[--im-card-fg:#a6a6a9] dark:[--im-card-title:#ffffff]\",\n  ios: \"[--im-card:#e9e9eb] [--im-card-fg:#8a8a8e] [--im-card-title:#000000] dark:[--im-card:#262629] dark:[--im-card-fg:#8d8d93] dark:[--im-card-title:#ffffff]\",\n};\n\n/** Unverified: no capture holds a FaceTime card, so these are the system colours, not measured ones. */\nconst facetimeGreen = \"#30c653\";\nconst missedRed = \"#ff453a\";\n\nfunction VideoGlyph({ size }: { size: number }) {\n  // SF Symbol \"video.fill\": a rounded rectangle body with a wedge lens on the trailing side.\n  return (\n    <svg aria-hidden=\"true\" width={size} height={size} viewBox=\"0 0 28 28\" fill=\"none\">\n      <rect x=\"3\" y=\"8\" width=\"15\" height=\"12\" rx=\"3.6\" fill=\"currentColor\" />\n      <path d=\"M19.4 12.6 23.4 9.9c.7-.5 1.6 0 1.6.8v6.6c0 .8-.9 1.3-1.6.8l-4-2.7z\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nexport type FaceTimeCardProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  state?: FaceTimeState;\n  /** Shown while connected, e.g. \"12:04\". */\n  duration?: string;\n  /** Line under the title. Defaults to a sensible label for the state. */\n  caption?: string;\n  onJoin?: () => void;\n  onEnd?: () => void;\n  platform?: Platform;\n};\n\nexport function FaceTimeCard({ state = \"invitation\", duration = \"00:00\", caption, onJoin, onEnd, platform: platformProp, className, style, ...props }: FaceTimeCardProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = metrics[platform];\n  const active = state === \"connected\" || state === \"ringing\";\n  const missed = state === \"missed\";\n  const label = caption ?? (state === \"invitation\" ? \"FaceTime Link\" : state === \"ringing\" ? \"Calling\" : state === \"connected\" ? duration : missed ? \"Missed FaceTime\" : \"FaceTime ended\");\n  const action = active ? onEnd : onJoin;\n  const actionLabel = active ? \"Leave\" : state === \"invitation\" ? \"Join\" : \"Call Back\";\n  // The button's visible word is one syllable; out of context it needs to say what it calls.\n  const actionName = active ? \"Leave the FaceTime call\" : state === \"invitation\" ? \"Join the FaceTime call\" : \"Call back on FaceTime\";\n  const card: CSSProperties = {\n    width: m.width, maxWidth: \"100%\", borderRadius: m.radius, padding: `${m.padY}px ${m.padX}px`,\n    background: \"var(--im-card)\", color: \"var(--im-card-title)\", fontFamily: fontStack,\n  };\n  return (\n    <div data-slot=\"facetime-card\" data-call-state={state} data-platform={platform} className={cn(\"select-none\", cardVars[platform], className)} style={{ ...card, ...style }} {...props}>\n      <div className=\"flex items-center\" style={{ gap: m.gap }}>\n        <span aria-hidden=\"true\" className=\"flex shrink-0 items-center justify-center text-white\"\n          style={{ width: m.icon, height: m.icon, borderRadius: m.iconRadius, background: missed ? missedRed : facetimeGreen }}>\n          <VideoGlyph size={m.icon * 0.62} />\n        </span>\n        <span className=\"min-w-0\">\n          <span className=\"block truncate\" style={{ fontSize: m.title, lineHeight: `${m.titleLine}px`, fontWeight: 600 }}>FaceTime</span>\n          {/* A live region only while the call is one: \"FaceTime Link\" is a caption, not an update. */}\n          <span role={state === \"invitation\" ? undefined : \"status\"} className=\"block truncate\" style={{ fontSize: m.sub, lineHeight: `${m.subLine}px`, color: \"var(--im-card-fg)\" }}>{label}</span>\n        </span>\n      </div>\n      {action && (\n        <button type=\"button\" onClick={action} aria-label={actionName}\n          className=\"flex w-full items-center justify-center font-semibold text-white transition-[filter] active:brightness-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ marginTop: m.rowGap, height: m.button, borderRadius: m.button / 2, fontSize: m.buttonText, background: active ? missedRed : facetimeGreen }}>\n          {actionLabel}\n        </button>\n      )}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/facetime-card.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "avatar",
      "title": "Avatar",
      "description": "Initials on the measured Messages gradient, a photo, or the generic silhouette, at native sizes.",
      "files": [
        {
          "path": "registry/imessage/avatar.tsx",
          "content": "import type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Contact avatar: initials over the native gray-blue gradient, a photo, or the generic silhouette.\n *\n * **Fill.** A vertical linear gradient across the circle's own box: light #a9c2e1 → #747fb9, dark\n * #575368 → #302649. Fitted to the Ø45 row avatars in `references/ios/captures/list-light.png` and\n * `list-dark.png`, and re-fitted independently to the Ø60 nav-bar avatar in `conv3-light.png`, which\n * returns the same endpoints (max residual 0.7/255 against a straight line). The gradient does not\n * change with the circle's size.\n *\n * **Initials.** White, weight 600, at **7/15 of the diameter** (0.4667). Three diameters in the\n * captures pin that ratio, and Chrome reproduces each one's ink exactly:\n *\n * | Where | Ø | Type | Native ink | Chrome at 3x |\n * |---|---|---|---|---|\n * | List row (`list-light.png`), circle x 26-71 y 188-233 | 45 | 21 | \"JA\" 24.67 wide, cap 15.00, ink top 15.33 below the circle | 24.67 wide, ink top 15.33 |\n * | Nav bar (`conv3-light.png`), circle x 171-231 y 62-122 | 60 | 28 | \"JA\" 32.67 wide, cap 19.67, ink top 20.33 below the circle | 33.00 wide, ink top 20.67 |\n * | Details (`details-light.png`), circle x 161-241 y 62-142 | 80 | 37.33 | \"JA\" 44.00 wide, cap 26.67, ink top 27.00 below the circle | 44.00 wide, ink top 27.00 |\n *\n * Chrome rasterises the caps one to two device px taller than the simulator, so no offset can land both\n * edges. `translateY(0.02em)` puts the ink *top* on native at Ø45 and Ø80 and one device px low at Ø60,\n * which no other offset beats; the ink then hangs a device px below native. Diffed against the captures\n * that hold an avatar, that leaves 0.74% of Ø45 \"JA\", 0.62% of Ø45 \"KB\" and 0.50% of Ø80 \"JA\" mismatched,\n * all of it glyph edges: the circle and the gradient are exact.\n *\n * **Photo.** A plain circular clip, no rim and no shadow: measured on the Ø40 macOS header avatar in\n * `references/macos/captures/conversation-pane-light.png` (x 592-671, y 16-95 at 2x = 40.0 square).\n *\n * **Silhouette: unverified.** No committed capture has a contact without initials or a photo, so the\n * glyph's proportions are drawn from the SF Symbol, not measured. Do not quote them as measured.\n *\n * `ios-nav-bar.tsx`, `ios-conversation-list.tsx` and `ios-details.tsx` each draw their own circle with\n * their own copy of these values instead of importing this component.\n */\nexport type AvatarSize = 40 | 45 | 60 | 72 | 80 | number;\n\nexport type AvatarProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  size?: AvatarSize;\n  /** One or two letters. Ignored when `src` is set. */\n  initials?: string;\n  /** Photo variant. */\n  src?: string;\n  /** Accessible name; falls back to the initials. */\n  name?: string;\n};\n\n/** Initials type size for a circle of `size`: the measured 7/15 of the diameter (45 → 21, 60 → 28, 80 → 37.33). */\nexport function avatarFontSize(size: number): number {\n  return (size * 7) / 15;\n}\n\n/** The literal value of `fontStack` in tokens.ts, inlined so the avatar installs with no dependencies. */\nconst fontStack = '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\nexport function Avatar({ size = 40, initials, src, name, className, style, ...props }: AvatarProps) {\n  const label = name ?? initials ?? \"Contact\";\n  const vars = { \"--av-top\": \"#a9c2e1\", \"--av-bottom\": \"#747fb9\" } as CSSProperties;\n  return (\n    <span\n      data-slot=\"avatar\"\n      data-size={size}\n      role=\"img\"\n      aria-label={label}\n      className={cn(\"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full align-middle text-white dark:[--av-bottom:#302649] dark:[--av-top:#575368]\", className)}\n      style={{\n        width: size, height: size, fontSize: avatarFontSize(size), fontWeight: 600, lineHeight: 1, letterSpacing: 0,\n        fontFamily: fontStack,\n        background: \"linear-gradient(var(--av-top), var(--av-bottom))\",\n        ...vars, ...style,\n      }}\n      {...props}\n    >\n      {src ? (\n        // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral\n        <img data-slot=\"avatar-photo\" src={src} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n      ) : initials ? (\n        <span data-slot=\"avatar-initials\" aria-hidden=\"true\" style={{ transform: \"translateY(0.02em)\" }}>{initials.slice(0, 2)}</span>\n      ) : (\n        <Silhouette size={size} />\n      )}\n    </span>\n  );\n}\n\n/**\n * The generic contact glyph: head above shoulders, white, clipped by the circle.\n * Unverified: no capture in this repo shows a contact without initials or a photo.\n */\nfunction Silhouette({ size }: { size: number }) {\n  return (\n    <svg data-slot=\"avatar-silhouette\" aria-hidden=\"true\" viewBox=\"0 0 40 40\" width={size} height={size} className=\"absolute inset-0\" fill=\"#ffffff\">\n      <circle cx=\"20\" cy=\"15.5\" r=\"7.5\" />\n      <path d=\"M6 40c0-9 5.8-14.5 14-14.5S34 31 34 40Z\" />\n    </svg>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/avatar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-window",
      "title": "macOS window",
      "description": "The Messages window frame: traffic lights, sidebar and content slots, active and inactive states.",
      "files": [
        {
          "path": "registry/imessage/macos-window.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * macOS 26 Messages window frame. The pane captures are crops of window x 330–960, so they carry the\n * window's top, right and bottom edges but never its left column. Measured at 2x:\n * - corners: continuous. `conversation-pane-light.png` and `conversation-pane-dark-2.png` are both\n *   shadow-free captures, so their alpha edge is the window's own outline: fitting it with the\n *   exponent Chrome's `superellipse(1.4)` draws (2.639) gives radius 31.67 and 31.81 pt at 0.23 px\n *   rms, and the best plain circle is 25.5 pt at 0.38 px rms (0.9 px worst point). Tracing a Chrome\n *   render of those two values back returns them to 0.06 pt, so the fit reads a radius honestly.\n *   The rim ridge of `conversation-pane-dark.png` reads 0.8 pt larger; that capture sits on the\n *   desktop and its rim is a lit band, so the two alpha outlines are the better evidence.\n * - dark frame: 1 pt #4b4b4b down the sides and along the bottom (two device columns at window\n *   x 959–960 of `conversation-pane-dark.png`). The top edge is brighter and is not painted gray: it\n *   reads #6f6f6f then #5d5d5d over the #1e1e1e pane, and #74787f then #63696f where a blue bubble\n *   sits under the header, which is white at 0.36 and 0.28 over both.\n * - light frame: no side or bottom rim, the pane runs to the edge. The same top highlight is there,\n *   white at 0.70 then 0.40: #fefefe then #fdfdfd over the #fcfcfc header glass, and white over the\n *   plain pane of `conversation-pane-light-partial.png`.\n * - traffic lights: Ø14 centred (25.75, 25.75), (48.75, 25.75), (71.75, 25.75), from SPEC \"macOS\n *   Chrome\". No committed capture contains them: every pane crop starts at window x 330, so these\n *   come from the full-window frames held outside the repo and cannot be re-measured here. Their\n *   inactive fills are not measured at all, since no capture shows a window that is not key.\n */\nexport const macWindowMetrics = {\n  width: 960,\n  height: 640,\n  sidebarWidth: 330,\n  /** Best circular fit to the window outline. Browsers with `corner-shape` get `continuousRadius`. */\n  cornerRadius: 25.5,\n  continuousRadius: 31.75,\n  trafficLight: { diameter: 14, centers: [25.75, 48.75, 71.75] as const, y: 25.75 },\n};\n\nexport type MacWindowProps = Omit<ComponentProps<\"div\">, \"children\" | \"content\"> & {\n  width?: number;\n  height?: number;\n  /** Key window: colored traffic lights and the blue sidebar selection. */\n  active?: boolean;\n  /** The conversation list. Rendered in the left `sidebarWidth` (330 pt) column. */\n  sidebar?: ReactNode;\n  sidebarWidth?: number;\n  /** The conversation pane. */\n  content?: ReactNode;\n  children?: ReactNode;\n};\n\nconst lights = [\n  { name: \"Close\", fill: \"#ff5f57\", rim: \"#e0443e\" },\n  { name: \"Minimize\", fill: \"#febc2e\", rim: \"#dea123\" },\n  { name: \"Zoom\", fill: \"#28c840\", rim: \"#1aab29\" },\n];\n\nexport function MacWindow({ width = macWindowMetrics.width, height = macWindowMetrics.height, active = true, sidebar, sidebarWidth = macWindowMetrics.sidebarWidth, content, children, className, style, ...props }: MacWindowProps) {\n  const { diameter, centers, y } = macWindowMetrics.trafficLight;\n  return (\n    <div\n      data-slot=\"mac-window\"\n      data-active={active ? \"true\" : \"false\"}\n      className={cn(\"mac-window relative isolate overflow-hidden bg-white text-black [--mac-light-inactive-rim:rgba(0,0,0,0.12)] [--mac-light-inactive:#dcdcdc] [--mac-rim-top-1:rgba(255,255,255,0.50)] [--mac-rim-top-2:rgba(255,255,255,0.40)] dark:bg-[#1e1e1e] dark:text-white dark:[--mac-light-inactive-rim:rgba(255,255,255,0.08)] dark:[--mac-light-inactive:#4f4f4f] dark:[--mac-rim-top-1:rgba(255,255,255,0.111)] dark:[--mac-rim-top-2:rgba(255,255,255,0.28)]\", className)}\n      style={{ width, height, fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", WebkitFontSmoothing: \"antialiased\", ...style }}\n      {...props}\n    >\n      <style>{`\n        .mac-window { border-radius: ${macWindowMetrics.cornerRadius}px; }\n        @supports (corner-shape: superellipse(1.4)) { .mac-window { border-radius: ${macWindowMetrics.continuousRadius}px; corner-shape: superellipse(1.4); } }\n      `}</style>\n      <div data-slot=\"mac-window-sidebar\" className=\"absolute inset-y-0 left-0\" style={{ width: sidebarWidth }}>{sidebar}</div>\n      <div data-slot=\"mac-window-content\" className=\"absolute inset-y-0 right-0\" style={{ left: sidebarWidth }}>{content ?? children}</div>\n      <div data-slot=\"traffic-lights\" role=\"group\" aria-label=\"Window controls\" className=\"absolute left-0 top-0 z-20\" style={{ height: y * 2, width: centers[2] + diameter }}>\n        {lights.map((light, index) => (\n          <span\n            key={light.name}\n            data-slot=\"traffic-light\"\n            role=\"img\"\n            aria-label={light.name}\n            className=\"absolute rounded-full\"\n            style={{\n              width: diameter, height: diameter, left: Math.floor(centers[index] - diameter / 2), top: Math.floor(y - diameter / 2),\n              // The measured box starts on a quarter point (18.75). A positioned box is snapped to whole\n              // device pixels, which would round that away; a fractional transform is not, so it carries\n              // the remainder and the light lands where the capture puts it.\n              transform: `translate(${((centers[index] - diameter / 2) % 1).toFixed(3)}px, ${((y - diameter / 2) % 1).toFixed(3)}px)`,\n              background: active ? light.fill : \"var(--mac-light-inactive)\",\n              boxShadow: `inset 0 0 0 0.5px ${active ? light.rim : \"var(--mac-light-inactive-rim)\"}`,\n            } as CSSProperties}\n          />\n        ))}\n      </div>\n      {/* Frame rim, in two parts because native's top edge replaces the side rim rather than sitting on\n          top of it: dark draws #4b4b4b down the sides and along the bottom, and both themes draw the\n          top edge as white glass, so whatever scrolls under the header shows through it. */}\n      <div aria-hidden=\"true\" data-slot=\"mac-window-rim\" className=\"mac-window pointer-events-none absolute inset-0 z-30 hidden dark:block\" style={{ boxShadow: \"inset 1px 0 0 #4b4b4b, inset -1px 0 0 #4b4b4b, inset 0 -1px 0 #4b4b4b\" }} />\n      {/* The top edge is two device rows at 2x: white 0.36 then 0.28 in dark, 0.70 then 0.40 in light.\n          The first shadow paints over the second, so `--mac-rim-top-1` carries only the difference. */}\n      <div aria-hidden=\"true\" data-slot=\"mac-window-top-edge\" className=\"mac-window pointer-events-none absolute inset-0 z-30\" style={{ boxShadow: \"inset 0 0.5px 0 var(--mac-rim-top-1), inset 0 1px 0 var(--mac-rim-top-2)\" }} />\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-window.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-sidebar",
      "title": "macOS sidebar",
      "description": "The conversation list sidebar with search, pinned conversations, rows, and selection states.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/macos-sidebar.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar } from \"@/components/imessage/avatar\";\n\n/**\n * macOS 26 Messages conversation list. Every number is measured from a native 960×640 window at 2x\n * (see `references/SPEC.md` → macOS Chrome). The sidebar is not a flat 330 pt column: it is a floating\n * panel inset 8 from the window's left, top and bottom, 320 wide (window x 8–328), radius ≈18, with a\n * 1 pt bright rim and a soft shadow onto the pane. Everything inside sits in window coordinates:\n * search field x 18–318 y 52–88 (a 36 tall capsule), pinned Ø73 avatar centered (168, 142.5) with an\n * 11 pt gray label, rows 80.5 tall from y 215 with a Ø40 avatar at x 36–76, a 13 pt semibold name\n * (baseline 28.75 into the row), a 12 pt time on the same baseline ending at x 307.5, a 12 pt preview\n * (baseline 45, 15 pt line pitch, two lines), 1 pt separators from x 82 to 306, a #3478f6 selection\n * (#3a3a3a when the window is not key) and a 49 pt footer bar at the panel's bottom.\n *\n * **Unread** (no capture; read out of ChatKit 26.5 on macOS 26.5, the framework macOS Messages is\n * built on). An unread row draws a dot and nothing else: nothing in the row's layout moves, no label\n * changes weight or colour, and there is no count badge.\n * - Ø **9**, from `-[CKUIBehaviorMac unreadIndicatorImageViewSize]` = `{9, 9}`. The asset behind it,\n *   `-[CKUIBehavior unreadIndicatorTintedImage]`, is a Ø12 circle that fills its box edge to edge, so\n *   the image view's size *is* the dot's diameter.\n * - Fill **#0088ff light / #0091ff dark, opaque**, from `-[CKUIThemeMac unreadIndicatorColor]`.\n *   `__42-[CKUIBehavior unreadIndicatorTintedImage]_block_invoke` is that colour's only consumer:\n *   it bakes it into the circle, and rendering the image under each appearance returns those two.\n * - Placement `-[CKConversationListCellLayout unreadFrame]` = `{4.5, y, 9, 9}` in the same row space\n *   whose avatar is `{18, ·, 40, 40}` and whose name box starts at 64, i.e. exactly this component's.\n *   `-[CKConversationListStandardCell _calculateIndicatorFrameForSize:trailing:displayScale:insets:]`\n *   derives both: x = (`conversationListCellLeftMargin` 18 − 9) / 2 = 4.5, so the dot is centred in the\n *   gutter left of the avatar, and y = (row height − 9) / 2, so it is centred on the row.\n * - **Selected and unread**: the dot turns **white**, opaque. `-[CKUIBehaviorMac\n *   shouldUnreadIndicatorChangeOnSelection]` is `YES` (it is `NO` on `CKUIBehaviorPhone`), and\n *   `-[CKConversationListCell unreadIndicatorImageForVisibility:withMuteState:]` reads\n *   `shouldLabelsBeHighlighted && shouldUnreadIndicatorChangeOnSelection ? unreadIndicatorSelectedImage\n *   : unreadIndicatorTintedImage`. The selected image renders #ffffff in both appearances. It is the\n *   same test that whitens the labels, because the dot sits inside the selection fill, not beside it,\n *   so the dot follows `highlighted` here. `shouldLabelsBeHighlighted` is a bare ivar on the cell with\n *   no notion of a key window, so an inactive window's gray selection is the one case not settled by\n *   the framework: the capture shows its labels staying dark, and the dot stays blue to match.\n * - **No count.** `_TtC7ChatKit32CKConversationListIndicatorsView`, the row's accessory strip, holds\n *   only image views, and `-[CKConversationListCell unreadMessageCount]` is read by nothing that\n *   draws. A number passed here is announced and never painted.\n * - **A pinned conversation** draws the same dot before its title label. `CKPinnedConversationView`\n *   vends `unreadIndicatorSize` {9, 9} and `unreadIndicatorPreferredPadding` trailing **3**, and laying\n *   a 96 × 119 one out (the tile size this component uses) puts the label at x 15.5 w 65.5 y 90 h 19\n *   and the dot at x 3.5 y 95, i.e. the label stays centred in the tile and the dot hangs off its\n *   leading edge 3 away, vertically centred on the label's line. `_unreadIndicatorColor` picks between\n *   three colours and both of its branches are now settled:\n *   `isFilteredByFocus ? conversationListPinnedConversationFilteredByFocusIndicatorColor\n *    : isSelectedWithDarkAppearance ? readSelectedIndicatorColor : unreadIndicatorColor`.\n *   `isFilteredByFocus` is set from `-[CKConversationList updateFilteredByFocusStateForConversations:]`,\n *   the \"a Focus is filtering this conversation out\" dimming; this kit has no Focus, so it is always NO\n *   and the #000000@25.9% / #ffffff@24.7% colour it would pick is unreachable. `isSelectedWithDarkAppearance`\n *   is written in exactly one place, `-[CKPinnedConversationCollectionViewCell updateConfigurationUsingState:]`,\n *   as `useSelectedAppearanceForConversationCellState:traitCollection: && showsBackgroundViewWhenSelected`,\n *   and `-[CKUIBehaviorMac useSelectedAppearanceForConversationCellState:traitCollection:]` is\n *   `(state.isSelected || state.cellDropState == 2) && traitCollection.activeAppearance.isMainWindowForegroundActive`.\n *   So the name means \"selected on the key window, while the tile paints its selected background\", and\n *   `readSelectedIndicatorColor` is opaque **#ffffff** in both appearances, the same white the row dot\n *   takes. This component's pinned selection is a ring around the avatar, not the filled radius-8\n *   background view native draws (`conversationListPinnedCellSelectedBackgroundCornerRadius` 8), and\n *   its label does not go white either, so the second term is NO here and the dot always takes\n *   `unreadIndicatorColor`. Give the tile that background and the white branch turns on with it.\n *\n * **Group rows** (`members`). A group conversation's row draws the same `CKAvatarView` the one-to-one\n * row does (`CKConversationListStandardCell._avatarView`), handed every participant instead of one, and\n * `CNAvatarView` lays those out through `ContactsUICore.SnowglobeUIView` as a stack of circles inside\n * the same Ø40 box. See `groupPhotoRecipes` for the measured stack. The preview line is prefixed with\n * `sender`, in the same 12 pt type and the same gray as the rest of the preview: ChatKit does not\n * compose that prefix (`-[CKConversation previewText]` is the message's text alone, and the cell has\n * only a from-label and a summary label), so the wording is the caller's and only the metrics it has to\n * fit are measured.\n */\nexport type SidebarConversation = {\n  id: string;\n  name: string;\n  initials: string;\n  preview: string;\n  time: string;\n  pinned?: boolean;\n  muted?: boolean;\n  /**\n   * Unread conversation: draws the dot. A number is announced (\"3 unread messages\") but never\n   * painted, because macOS Messages draws no count on a row.\n   */\n  unread?: boolean | number;\n  /** Photo variant of the avatar. */\n  photo?: string;\n  /**\n   * Group conversation: its participants, drawn as the stacked group photo in place of one avatar.\n   * Two or more entries make it a group; the stack shows the first seven.\n   */\n  members?: GroupMember[];\n  /**\n   * Who sent the last message. A group row prefixes its preview with it (\"Sam Rivera: Tuesday…\").\n   * Ignored on a one-to-one row, which never carries a sender.\n   */\n  sender?: string;\n};\n\nexport type GroupMember = {\n  /** One or two letters. Ignored when `photo` is set. */\n  initials: string;\n  /** Accessible name, and the name the group photo announces. */\n  name?: string;\n  photo?: string;\n};\n\nexport type MacSidebarProps = Omit<ComponentProps<\"nav\">, \"onSelect\"> & {\n  conversations: SidebarConversation[];\n  selectedId?: string;\n  onSelect?: (id: string) => void;\n  /** Key window: blue selection. Otherwise the neutral inactive selection. */\n  active?: boolean;\n  /** Footer line, e.g. \"Syncing with iCloud Paused\". */\n  footer?: string;\n  onSearch?: (query: string) => void;\n  onOptions?: () => void;\n};\n\nexport const macSidebarMetrics = {\n  /** The column the window reserves. The panel itself is inset inside it. */\n  width: 330,\n  panel: { left: 8, top: 8, bottom: 8, width: 320, radius: 18 },\n  search: { left: 18, top: 52, width: 300, height: 36 },\n  pinned: {\n    top: 96, avatar: 73, centerX: 168, avatarCenterY: 142.5, height: 119, labelTop: 184.75,\n    // CKPinnedConversationView: the dot is the same Ø9, and `unreadIndicatorPreferredPadding` puts 3\n    // between it and the title label, which does not move to make room for it.\n    unread: 9, unreadGap: 3,\n  },\n  row: {\n    left: 18, width: 300, height: 80.5, radius: 8, avatar: 40, avatarLeft: 18,\n    textLeft: 64, textRight: 10.5, separatorRight: 12, nameTop: 15.75, previewTop: 33, previewLine: 15,\n    muted: 11.5, mutedTop: 36.3,\n    // ChatKit's own unreadFrame: Ø9 centred in the 18 pt gutter left of the avatar, centred on the row.\n    unread: 9, unreadLeft: 4.5,\n  },\n  footer: { height: 49, textTop: 17.75 },\n  options: { centerX: 306, centerY: 25.85 },\n  /**\n   * How long a separator takes to cross when the selection moves. **Unverified**: no capture holds a\n   * switch. It is the same 140 ms `macos-messages-app.tsx` gives the travelling highlight\n   * (`macTransitions.selection.duration`), copied rather than imported because that file imports this\n   * one. The two have to agree: the separators the selection uncovers and covers are the ones it is\n   * moving between, so a different number would make them lead or trail the fill.\n   */\n  selectionFade: 140,\n};\n\n/**\n * ChatKit's group photo, measured. A group row's avatar is the same `CKAvatarView` a one-to-one row\n * uses (`CKConversationListStandardCell._avatarView`), handed the conversation's participants through\n * `-[CNAvatarView setContacts:]` instead of one contact. `CNAvatarView` then builds a\n * `ContactsUICore.SnowglobeUIView` holding one `ContactsUICore.AvatarUIView` per person, and the\n * constraint layout it runs is what these numbers are: a probe that swizzles `-[UIDevice\n * userInterfaceIdiom]` to Mac, dlopens ChatKit, builds `CKAvatarView` over N fixture `CNMutableContact`s\n * and calls `layoutIfNeeded` reports each circle's frame.\n *\n * Each row is `[x, y, diameter]` on a **44-unit** box, back to front, and the layout is a pure ratio:\n * asking for 88 doubles every number exactly and asking for 40 divides them by 1.1 exactly, so a\n * circle's frame is its entry times `size / 44`. Every value lands on a quarter unit at 44, which is\n * what says 44 is the grid the recipes were authored on. **The same layout on both platforms**: the\n * probe run with the idiom left as Phone returns the same frames to the last digit, so this is also the\n * iOS group avatar. One contact is the plain full-box circle; two and three have their own recipes;\n * four, five and six add a slot to the three-person stack; seven re-lays the whole stack out, and an\n * eighth participant changes nothing, so seven is the cap.\n *\n * Not reproduced: `SnowglobeUIView` also puts a `UIVisualEffectView` (`UIBlurEffect material=20`) behind\n * the circles, filling the box. Nothing in `references/` shows a group row, so whether that plate is\n * visible against a sidebar row, and what it does over the blue selection, is unmeasured; this draws the\n * circles alone.\n */\nexport const groupPhotoRecipes: readonly (readonly (readonly [number, number, number])[])[] = [\n  [[0, 0, 44]],\n  [[4.75, 4.75, 24], [23.75, 23.75, 14]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10], [4.75, 25.5, 7]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10], [4.75, 25.5, 7], [23.25, 3.25, 5]],\n  [[5.25, 5.25, 21], [24.25, 20.25, 15], [27.25, 8.25, 11], [6.5, 26.75, 10], [19.25, 33.25, 7.5], [17.75, 26.75, 5.5], [24, 3.75, 5]],\n];\n\n/** The stack, at any diameter. `macos-header.tsx` carries its own copy, the way the iOS chrome does. */\nexport function GroupPhoto({ size, members, name, className, style, ...props }: Omit<ComponentProps<\"span\">, \"children\"> & { size: number; members: GroupMember[]; name?: string }) {\n  const people = members.slice(0, groupPhotoRecipes.length);\n  const recipe = groupPhotoRecipes[people.length - 1] ?? groupPhotoRecipes[0];\n  const unit = size / 44;\n  return (\n    <span\n      data-slot=\"group-photo\"\n      role=\"img\"\n      aria-label={name ?? people.map(person => person.name ?? person.initials).join(\", \")}\n      className={cn(\"relative block shrink-0\", className)}\n      style={{ width: size, height: size, ...style }}\n      {...props}\n    >\n      {people.map((person, index) => {\n        const [x, y, diameter] = recipe[index];\n        return (\n          <Avatar key={index} aria-hidden=\"true\" size={diameter * unit} initials={person.initials} src={person.photo} name={person.name}\n            className=\"absolute\" style={{ left: x * unit, top: y * unit }} />\n        );\n      })}\n    </span>\n  );\n}\n\n/** Sidebar list options: three centred bars, 16.5 / 12.5 / 9.5 wide, 1.25 thick, 4.1 apart. */\nfunction OptionsIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16.5 9.45\" width=\"16.5\" height=\"9.45\" fill=\"currentColor\">\n      <rect x=\"0\" y=\"0\" width=\"16.5\" height=\"1.25\" rx=\"0.625\" />\n      <rect x=\"2\" y=\"4.1\" width=\"12.5\" height=\"1.25\" rx=\"0.625\" />\n      <rect x=\"3.5\" y=\"8.2\" width=\"9.5\" height=\"1.25\" rx=\"0.625\" />\n    </svg>\n  );\n}\n\n/** Ø10 magnifier with a handle running to the corner: the native ink box is 12.5 square at (32.5, 63.5). */\nfunction SearchIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 12.5 12.5\" width=\"12.5\" height=\"12.5\" fill=\"none\" stroke=\"currentColor\">\n      <circle cx=\"5\" cy=\"5\" r=\"4.4\" strokeWidth=\"1.2\" />\n      <path d=\"M8.4 8.4 11.9 11.9\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** bell.slash.fill: a filled bell with the slash separated from it by a background-coloured stroke. */\nfunction MutedIcon({ size, color, halo }: { size: number; color: string; halo: string }) {\n  return (\n    <svg aria-label=\"Muted\" role=\"img\" viewBox=\"0 0 10 10\" width={size} height={size}>\n      <path fill={color} d=\"M5 0.6a2.6 2.6 0 0 0-2.6 2.6c0 2.2-.6 2.9-1.1 3.4a.5.5 0 0 0 .35.85h6.7a.5.5 0 0 0 .35-.85c-.5-.5-1.1-1.2-1.1-3.4A2.6 2.6 0 0 0 5 .6Zm0 8.8a1.2 1.2 0 0 0 1.15-.9h-2.3A1.2 1.2 0 0 0 5 9.4Z\" />\n      <path stroke={halo} strokeWidth=\"1.9\" strokeLinecap=\"round\" d=\"M1.5 1.5 8.5 8.5\" />\n      <path stroke={color} strokeWidth=\"0.95\" strokeLinecap=\"round\" d=\"M1.5 1.5 8.5 8.5\" />\n    </svg>\n  );\n}\n\n/**\n * What an unread row announces. Nothing in ChatKit builds this string, so the wording is ours; the\n * count comes from the caller because macOS Messages never paints one.\n */\nfunction unreadLabel(unread: boolean | number): string {\n  if (unread === true) return \"Unread\";\n  return unread === 1 ? \"1 unread message\" : `${unread} unread messages`;\n}\n\nexport function MacSidebar({ conversations, selectedId, onSelect, active = true, footer, onSearch, onOptions, className, style, ...props }: MacSidebarProps) {\n  const m = macSidebarMetrics;\n  const pinned = conversations.filter(c => c.pinned);\n  const rows = conversations.filter(c => !c.pinned);\n  const listTop = pinned.length ? m.pinned.top + m.pinned.height : m.pinned.top;\n  return (\n    <nav\n      data-slot=\"mac-sidebar\"\n      data-active={active ? \"true\" : \"false\"}\n      aria-label=\"Conversations\"\n      className={cn(\n        \"mac-sidebar relative h-full select-none overflow-hidden bg-[#f8f8f8] text-black\",\n        \"[--sb-fill:#fafafa] [--sb-rim:#ffffff] [--sb-rim-inner:#fefefe] [--sb-inactive:#e2e2e2] [--sb-name:#000000] [--sb-secondary:#6e6e6d] [--sb-muted:#aeaeae] [--sb-glyph:#232323] [--sb-field:#eeeeee] [--sb-placeholder:#777777] [--sb-separator:#e1e1e1] [--sb-footer-line:#d0d2d7] [--sb-footer-top:#e4e6eb] [--sb-footer-bottom:#eff0f2] [--sb-footer-text:#000000] [--sb-unread:#0088ff]\",\n        \"dark:bg-[#1c1c1c] dark:text-[#f4f4f4]\",\n        \"dark:[--sb-fill:#1b1b1b] dark:[--sb-rim:#424242] dark:[--sb-rim-inner:#323232] dark:[--sb-inactive:#3a3a3a] dark:[--sb-name:#f4f4f4] dark:[--sb-secondary:#a4a4a4] dark:[--sb-muted:#5b5b5b] dark:[--sb-glyph:#dddddd] dark:[--sb-field:#1e1e1e] dark:[--sb-placeholder:#9a9a9a] dark:[--sb-separator:#3a3a3a] dark:[--sb-footer-line:#43454a] dark:[--sb-footer-top:#27292e] dark:[--sb-footer-bottom:#27272a] dark:[--sb-footer-text:#f5f5f5] dark:[--sb-unread:#0091ff]\",\n        className,\n      )}\n      style={{ width: m.width, fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style }}\n      {...props}\n    >\n      {/*\n        Native draws continuous corners: a plain circle of radius 18 (panel) / 8 (row selection) is the\n        closest circular fit, so browsers with `corner-shape` get the superellipse the capture shows.\n      */}\n      <style>{`\n        .mac-sidebar-panel { border-radius: ${m.panel.radius}px; }\n        .mac-sidebar-row { border-radius: ${m.row.radius}px; }\n        @supports (corner-shape: superellipse(1.4)) {\n          .mac-sidebar-panel { border-radius: 22px; corner-shape: superellipse(1.4); }\n          .mac-sidebar-row { border-radius: 10px; corner-shape: superellipse(1.4); }\n        }\n        .mac-sidebar-separator { transition: opacity ${m.selectionFade}ms linear; }\n        @media (prefers-reduced-motion: reduce) { .mac-sidebar-separator { transition: none; } }\n      `}</style>\n      <div\n        aria-hidden=\"true\"\n        data-slot=\"sidebar-panel\"\n        className=\"mac-sidebar-panel absolute bg-[var(--sb-fill)]\"\n        style={{ left: m.panel.left, top: m.panel.top, bottom: m.panel.bottom, width: m.panel.width, // The panel rim is two device pixels and they are not the same colour: #424242 outside,\n          // #323232 inside on dark. One 1 pt ring paints both columns the same and reads too bright.\n          boxShadow: \"0 0 20px rgba(0,0,0,0.05), inset 0 0 0 0.5px var(--sb-rim), inset 0 0 0 1px var(--sb-rim-inner)\" }}\n      />\n\n      <button type=\"button\" data-slot=\"sidebar-options\" aria-label=\"Conversation list options\" aria-haspopup=\"menu\" onClick={onOptions}\n        className=\"absolute flex size-[26px] items-center justify-center rounded-full text-[var(--sb-glyph)] hover:bg-black/5 dark:hover:bg-white/10\"\n        style={{ left: m.options.centerX - 13, top: m.options.centerY - 13 }}>\n        <OptionsIcon />\n      </button>\n\n      <label data-slot=\"sidebar-search\" className=\"absolute flex items-center bg-[var(--sb-field)] text-[var(--sb-placeholder)]\"\n        style={{ left: m.search.left, top: m.search.top, width: m.search.width, height: m.search.height, borderRadius: m.search.height / 2 }}>\n        <span aria-hidden=\"true\" className=\"absolute\" style={{ left: 32.5 - m.search.left, top: 63.5 - m.search.top }}><SearchIcon /></span>\n        <span className=\"sr-only\">Search conversations</span>\n        <input type=\"search\" placeholder=\"Search\" onChange={event => onSearch?.(event.target.value)}\n          className=\"absolute bg-transparent text-[13px] leading-[16px] text-[var(--sb-name)] outline-none placeholder:font-medium placeholder:text-[var(--sb-placeholder)] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden\"\n          style={{ left: 53 - m.search.left, right: 8, top: 61.75 - m.search.top }} />\n      </label>\n\n      {pinned.length > 0 && (\n        <ul data-slot=\"sidebar-pinned\" aria-label=\"Pinned\" className=\"absolute flex list-none justify-center gap-[24px] p-0\"\n          style={{ left: m.panel.left, top: m.pinned.top, width: m.panel.width, height: m.pinned.height }}>\n          {pinned.map(c => {\n            const selected = c.id === selectedId;\n            return (\n              <li key={c.id} className=\"flex w-[96px] flex-col items-center\">\n                <button type=\"button\" aria-current={selected ? \"true\" : undefined} onClick={() => onSelect?.(c.id)}\n                  className=\"flex flex-col items-center rounded-[14px] outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#3478f6]\"\n                  style={{ paddingTop: m.pinned.avatarCenterY - m.pinned.top - m.pinned.avatar / 2 }}>\n                  {c.unread ? <span className=\"sr-only\">{unreadLabel(c.unread)}. </span> : null}\n                  {c.members && c.members.length > 1\n                    ? <GroupPhoto size={m.pinned.avatar} members={c.members} name={c.name}\n                        style={selected ? { boxShadow: `0 0 0 2px ${active ? \"#3478f6\" : \"#9a9a9a\"}`, borderRadius: \"50%\" } : undefined} />\n                    : <Avatar size={m.pinned.avatar} initials={c.initials} src={c.photo} name={c.name}\n                        style={selected ? { boxShadow: `0 0 0 2px ${active ? \"#3478f6\" : \"#9a9a9a\"}` } : undefined} />}\n                  {/* The tile's label stays centred whether or not there is a dot; the dot hangs off its\n                      leading edge, which is what CKPinnedConversationView's own layout does. */}\n                  <span className=\"relative flex max-w-[96px]\" style={{ marginTop: m.pinned.labelTop - (m.pinned.avatarCenterY + m.pinned.avatar / 2) }}>\n                    {c.unread ? (\n                      <span aria-hidden=\"true\" data-slot=\"pinned-unread\" className=\"absolute rounded-full\"\n                        style={{ right: \"100%\", marginRight: m.pinned.unreadGap, top: (13 - m.pinned.unread) / 2, width: m.pinned.unread, height: m.pinned.unread, background: \"var(--sb-unread)\" }} />\n                    ) : null}\n                    <span data-slot=\"pinned-name\" className=\"truncate text-[11px] leading-[13px] text-[var(--sb-secondary)]\">{c.name}</span>\n                  </span>\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      )}\n\n      <ul data-slot=\"sidebar-rows\" role=\"list\" className=\"absolute m-0 list-none p-0\" style={{ left: m.row.left, top: listTop, width: m.row.width }}>\n        {rows.map((c, index) => {\n          const selected = c.id === selectedId;\n          const nextSelected = rows[index + 1]?.id === selectedId;\n          // Inactive windows keep the neutral text colors on the gray selection.\n          const highlighted = selected && active;\n          const secondary = highlighted ? \"#d6e4fd\" : \"var(--sb-secondary)\";\n          return (\n            <li key={c.id} data-slot=\"sidebar-row\" data-selected={selected ? \"true\" : \"false\"} className=\"relative\" style={{ height: m.row.height }}>\n              <button type=\"button\" aria-current={selected ? \"true\" : undefined} onClick={() => onSelect?.(c.id)}\n                className=\"mac-sidebar-row absolute inset-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-[#3478f6]/60\"\n                style={{ background: selected ? (active ? \"#3478f6\" : \"var(--sb-inactive)\") : \"transparent\" } as CSSProperties}>\n                {c.unread ? (\n                  <>\n                    {/* First in the row so the announcement leads: \"Unread, Alex Morgan, Yesterday, ...\". */}\n                    <span className=\"sr-only\">{unreadLabel(c.unread)}. </span>\n                    {/* The dot lies inside the selection fill, so it takes the same white the labels take. */}\n                    <span aria-hidden=\"true\" data-slot=\"row-unread\" className=\"absolute rounded-full\"\n                      style={{ left: m.row.unreadLeft, top: (m.row.height - m.row.unread) / 2, width: m.row.unread, height: m.row.unread, background: highlighted ? \"#ffffff\" : \"var(--sb-unread)\" }} />\n                  </>\n                ) : null}\n                {c.members && c.members.length > 1\n                  ? <GroupPhoto size={m.row.avatar} members={c.members} name={c.name} className=\"absolute\" style={{ left: m.row.avatarLeft, top: (m.row.height - m.row.avatar) / 2 }} />\n                  : <Avatar size={m.row.avatar} initials={c.initials} src={c.photo} name={c.name} className=\"absolute\" style={{ left: m.row.avatarLeft, top: (m.row.height - m.row.avatar) / 2 }} />}\n                <span className=\"absolute flex items-baseline justify-between gap-2\" style={{ left: m.row.textLeft, right: m.row.textRight, top: m.row.nameTop }}>\n                  <span data-slot=\"row-name\" className=\"truncate text-[13px] font-semibold leading-[16px]\" style={{ color: highlighted ? \"#ffffff\" : \"var(--sb-name)\" }}>{c.name}</span>\n                  <span data-slot=\"row-time\" className=\"shrink-0 text-[12px] leading-[15px]\" style={{ color: secondary }}>{c.time}</span>\n                </span>\n                <span data-slot=\"row-preview\" className=\"absolute line-clamp-2 text-[12px] leading-[15px]\"\n                  style={{ left: m.row.textLeft, right: m.row.textRight, top: m.row.previewTop, color: secondary }}>\n                  {/* A group row names the sender first. Same type and same gray: it is one run of\n                      preview text, wrapping and clamping with the rest of it. */}\n                  {c.sender && c.members && c.members.length > 1 ? <span data-slot=\"row-sender\">{c.sender}: </span> : null}\n                  {c.preview}\n                </span>\n                {c.muted && (\n                  <span className=\"absolute\" style={{ right: m.row.separatorRight, top: m.row.mutedTop }}>\n                    <MutedIcon size={m.row.muted} color={highlighted ? \"#d6e4fd\" : \"var(--sb-muted)\"} halo={selected ? (active ? \"#3478f6\" : \"var(--sb-inactive)\") : \"var(--sb-fill)\"} />\n                  </span>\n                )}\n              </button>\n              {/* Native draws no separator above or below the selected row. Mounting it always and\n                  crossing its opacity is what makes a switch read as one move: the pair the selection\n                  is leaving fades up while the pair it is arriving at fades down, over the same span\n                  the highlight travels. Mounting it conditionally instead pops one in at the departing\n                  row and one out at the arriving row on the click, while the highlight is still in\n                  flight. A transition, not a keyframe animation, so a seeked frame carries none of it\n                  and `document.getAnimations()` still reaches the live one. */}\n              {index < rows.length - 1 && (\n                <span aria-hidden=\"true\" data-slot=\"row-separator\" className=\"mac-sidebar-separator absolute bottom-0 h-px bg-[var(--sb-separator)]\"\n                  style={{ left: m.row.textLeft, right: m.row.separatorRight, opacity: selected || nextSelected ? 0 : 1 }} />\n              )}\n            </li>\n          );\n        })}\n      </ul>\n      {footer && (\n        <div data-slot=\"sidebar-footer\" className=\"absolute overflow-hidden\"\n          style={{ left: m.panel.left, bottom: m.panel.bottom, width: m.panel.width, height: m.footer.height, borderRadius: `0 0 ${m.panel.radius}px ${m.panel.radius}px` }}>\n          <div aria-hidden=\"true\" className=\"absolute inset-0\" style={{ background: \"linear-gradient(to bottom, var(--sb-footer-top), var(--sb-footer-bottom))\", boxShadow: \"inset 0 1px 0 var(--sb-footer-line)\" }} />\n          <p className=\"absolute left-0 m-0 w-full text-center text-[10px] leading-[12px] text-[var(--sb-footer-text)]\" style={{ top: m.footer.textTop }}>{footer}</p>\n        </div>\n      )}\n    </nav>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-sidebar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-header",
      "title": "macOS header",
      "description": "The translucent conversation header with compose, contact, and video controls.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/macos-header.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar } from \"@/components/imessage/avatar\";\n\n/**\n * macOS 26 Messages conversation header. Measured from `references/macos/captures/conversation-pane-dark.png`\n * and `conversation-pane-light.png` (pane x 330–960 of a 960×640 window, 2x), in pane coordinates:\n * - compose button: Ø36 glass circle, left 6, top 8. Native's pure-white light rim occupies device columns\n *   13–14 and 84–85 and device rows 16–17 and 86–87, i.e. a box of x 6.5–43 by y 8–44 (36.5 × 36). Chrome\n *   snaps a box's paint origin to a whole CSS px, so left 6.5 renders at 7 and drags the glyph with it;\n *   left 6 keeps \"square.and.pencil\" ink at its measured 17.5–33.5 on both axes and leaves the rim 0.5\n *   left of native. Stroke ≈1.3, #dcdcdc; the rim (peak #373739 over a #1b1c1c fill) sits inside the circle.\n * - avatar: Ø40 at (295, 8), so its center is (315, 28), the pane's horizontal center.\n * - name pill: a 52.6 × 27.1 stadium centred (315, 57.95), i.e. y 44.4–71.5, whose top 3.6 pt hide under\n *   the avatar. Its caps are **circular**, not a squircle: tracing the rim of the dark capture at 2x and\n *   least-squares fitting a superellipse to 34 outline points gives exponent s = 2.01 (a circle) at\n *   0.45 device px rms, while `corner-shape: superellipse(1.4)` (s = 2.639) only reaches 0.93 rms. The\n *   same fit on our own render returns s = 2.64 exactly, so the fit does separate the two shapes.\n *   Fill #1b1b1b under a 0.75pt rgba(255,255,255,0.13) rim (peak #383838 over the fill); 13pt bold\n *   #f4f4f4 text (\"Ben\" ink 601–646 px, cap top 53.5, baseline 63) starting 11 from the pill edge, then a\n *   3.55 × 9.5 chevron (#5b5b5b, stroke 2, ink 329–332.6) that spans exactly the cap height.\n * - video button: 40×36 stadium (radius 18: the cap fits a circle to 0.2 pt), right inset 8, top 8. Its\n *   \"video\" glyph is a 13.75 × 12.3 rounded rect (radius 2, stroke 1.15) at (11.05, 11.65) in button\n *   coordinates plus a triangle whose right edge sits at x 30.1; total ink 592.5–612.65 × 18.9–32.5, #dcdddf.\n * - the bar itself is 55 tall (not visible in a capture: the glass has no edge) and translucent: the list\n *   scrolls under it, blurred and washed toward the pane color (light: #fcfcfc wash, bubbles show at ≈15%;\n *   dark: rgba(30,30,30) wash) for the top 50 pt, fading back to full content by ~95 pt.\n * Light theme (conversation-pane-light.png): buttons and pill are #fcfcfc/#fdfdfd discs with a 1 px white\n * rim and a soft downward shadow (−15/255 just below, −8 above), text #000000, chevron #b1b1b1, glyphs #262626.\n *\n * **How the pill grows.** 52.6 × 27.1 is the box for \"Ben\"; everything around the name is fixed, so the\n * pill is that name's advance plus a constant **28.25** (11 before the text, 4.7 after it, the 3.55\n * chevron box, 9 after that). Chrome renders \"Ben\" at 13px/700 `-apple-system` in a 24.766 box, which\n * puts the whole pill at 53.0 against the measured 52.6, and the extra 0.4 is that one string's advance,\n * the same 1.5-on-a-bubble difference `SPEC.md` records for \"Second of two\". The height never moves. The\n * pill is centred under the avatar on the pane's centre, so a longer name grows it by half each way and\n * the chevron keeps its 9 to the pill's right edge: the checked render of a 22-character group name\n * comes out 179.2 wide, still centred on 315, with the chevron's box still ending 9 inside it.\n * **Unverified**: where it stops growing. Nothing in a capture bounds it, so `maxWidthInset` is\n * judgement: the pill stops 8 short of the video button, whose 48 (right inset 8 plus width 40) is the\n * wider of the two ends, and the name truncates. That keeps the growth symmetric about the centre.\n *\n * **Groups** (`members`). A group's header draws the same stacked photo the sidebar row does, in the\n * same Ø40 box, and the pill carries the group's name. `groupPhotoRecipes` below is the same measured\n * table `macos-sidebar.tsx` carries, copied rather than imported the way the iOS chrome files each keep\n * their own copy of the avatar's numbers.\n */\nexport const macHeaderMetrics = {\n  height: 55,\n  /** Scroll-edge effect: content under the bar is blurred and washed out at full strength for the top\n   *  `fadeStart` pt, then eases back to normal by `fadeEnd`. Measured two ways and they agree: over the\n   *  empty pane of conversation-pane-light.png (column x 220, clear of the pill's shadow) the wash darkens\n   *  white to 252/255 down to y 50.5, then 253 to 70.5, 254 to 89.5, and 255 from 89.5; over the bubbles of\n   *  conversation-pane-dark-2.png the wash alpha solves to ≈0.83 at y 32, 0.15 at y 88 and 0.11 at y 92. */\n  fadeStart: 50,\n  fadeEnd: 96,\n  compose: { left: 6, top: 8, size: 36 },\n  avatar: { top: 8, size: 40 },\n  pill: { top: 44.4, height: 27.1, textTop: 48, paddingLeft: 11, gap: 4.7, paddingRight: 9, fontSize: 13 },\n  video: { right: 8, top: 8, width: 40, height: 36 },\n  /** Judgement, not measured: how much of each end the pill leaves for the buttons before it truncates. */\n  maxWidthInset: 8 + 40 + 8,\n};\n\n/**\n * ChatKit's group photo. `CKAvatarButton._avatarView` is a `CNAvatarView` like the conversation list's,\n * and handed more than one contact it lays the circles out through `ContactsUICore.SnowglobeUIView`.\n * Each row is `[x, y, diameter]` on a 44-unit box, back to front, scaled by `size / 44`; see\n * `macos-sidebar.tsx` for how they were read off the framework and what is deliberately not drawn.\n */\nexport const groupPhotoRecipes: readonly (readonly (readonly [number, number, number])[])[] = [\n  [[0, 0, 44]],\n  [[4.75, 4.75, 24], [23.75, 23.75, 14]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10], [4.75, 25.5, 7]],\n  [[5.25, 5.25, 21], [24.75, 17.75, 16], [12.5, 27.25, 13], [27, 6.75, 10], [4.75, 25.5, 7], [23.25, 3.25, 5]],\n  [[5.25, 5.25, 21], [24.25, 20.25, 15], [27.25, 8.25, 11], [6.5, 26.75, 10], [19.25, 33.25, 7.5], [17.75, 26.75, 5.5], [24, 3.75, 5]],\n];\n\nexport type MacHeaderMember = { initials: string; name?: string; photo?: string };\n\nexport type MacHeaderProps = Omit<ComponentProps<\"header\">, \"children\"> & {\n  name: string;\n  initials?: string;\n  /** Photo URL, or a custom avatar node. */\n  photo?: string;\n  avatar?: ReactNode;\n  /**\n   * Group conversation: its participants, drawn as the stacked group photo in place of one avatar.\n   * Two or more entries make it a group; the stack shows the first seven. Ignored when `avatar` is set.\n   */\n  members?: MacHeaderMember[];\n  onCompose?: () => void;\n  onVideoCall?: () => void;\n  /** Clicking the name pill opens the conversation details. */\n  onOpenDetails?: () => void;\n};\n\nfunction GroupPhoto({ size, members, name }: { size: number; members: MacHeaderMember[]; name: string }) {\n  const people = members.slice(0, groupPhotoRecipes.length);\n  const recipe = groupPhotoRecipes[people.length - 1] ?? groupPhotoRecipes[0];\n  const unit = size / 44;\n  return (\n    <span data-slot=\"group-photo\" role=\"img\" aria-label={name} className=\"relative block shrink-0\" style={{ width: size, height: size }}>\n      {people.map((person, index) => {\n        const [x, y, diameter] = recipe[index];\n        return (\n          <Avatar key={index} aria-hidden=\"true\" size={diameter * unit} initials={person.initials} src={person.photo} name={person.name}\n            className=\"absolute\" style={{ left: x * unit, top: y * unit }} />\n        );\n      })}\n    </span>\n  );\n}\n\nconst glassButton = \"absolute block bg-[var(--hd-fill)] p-0 text-[var(--hd-ink)] shadow-[var(--hd-rim)] outline-offset-2 hover:bg-[var(--hd-fill-hover)] focus-visible:outline-2 focus-visible:outline-[#3478f6]\";\n\nexport function MacHeader({ name, initials, photo, avatar, members, onCompose, onVideoCall, onOpenDetails, className, style, ...props }: MacHeaderProps) {\n  const m = macHeaderMetrics;\n  const fallbackInitials = initials ?? name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\");\n  const group = members && members.length > 1 ? members : null;\n  return (\n    <header\n      data-slot=\"mac-header\"\n      className={cn(\n        \"absolute inset-x-0 top-0 z-10 select-none\",\n        // Light glass over the white pane (derived from the light composer: white discs with a soft shadow).\n        // Light glass, measured: #fcfcfc discs with a white rim and a soft downward shadow; pure black text.\n        \"[--hd-chevron:#b1b1b1] [--hd-fill-hover:#ffffff] [--hd-fill:#fcfcfc] [--hd-ink:#262626] [--hd-name:#000000] [--hd-pill-rim:inset_0_0_0_1px_#ffffff,0_9px_28px_rgba(0,0,0,0.09)] [--hd-pill:#fdfdfd] [--hd-rim:inset_0_0_0_1px_#ffffff,0_9px_28px_rgba(0,0,0,0.09)] [--hd-tint-40:rgba(251,251,251,0.35)] [--hd-tint-60:rgba(251,251,251,0.53)] [--hd-tint:rgba(251,251,251,0.88)]\",\n        // Dark glass, measured: fill #1b1c1c over #1e1e1e, rim #373739 fading to #2c2c2c. The pill's rim is\n        // lit on one diagonal, the same way the composer's is, and it is **not** brighter at the top.\n        // Integrating the rim's excess over the pill fill along the inward normal every 10 degrees around the\n        // stadium (pill box: device x 577-683, y 88-144 of conversation-pane-dark.png, so radius 28) gives\n        // 3.0 + 54.4 * |cos(angle - 45.5deg)| at 1.5 rms: brightest at the top-left AND the bottom-right,\n        // gone at the other two (57 up-left, 45 due left, 7 down-left). The band's width is constant at\n        // 1.55 device px and it is the alpha that varies, peaking at #404040 over the fill; a box-shadow can\n        // only vary the width, so this is the closest three-shadow stand-in. Scored per pixel over the 928 px\n        // within 3 device px of the rim, it halves the error of a uniform ring (mean 3.4 -> 2.4 of 255, worst\n        // 28 -> 13), and the two dim caps, which a uniform ring misses by 17, land within 4.\n        \"dark:[--hd-chevron:#5b5b5b] dark:[--hd-fill-hover:rgba(255,255,255,0.08)] dark:[--hd-fill:rgba(0,0,0,0.08)] dark:[--hd-ink:#dcdcdc] dark:[--hd-name:#f4f4f4] dark:[--hd-pill-rim:inset_0_0_0_0.8px_rgba(255,255,255,0.03),inset_0.75px_0.75px_0_0_rgba(255,255,255,0.1),inset_-0.75px_-0.75px_0_0_rgba(255,255,255,0.1)] dark:[--hd-pill:#1b1b1b] dark:[--hd-rim:inset_0_0_0_0.75px_rgba(255,255,255,0.13)] dark:[--hd-tint-40:rgba(30,30,30,0.34)] dark:[--hd-tint-60:rgba(30,30,30,0.51)] dark:[--hd-tint:rgba(30,30,30,0.85)]\",\n        className,\n      )}\n      style={{ height: m.height, fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style }}\n      {...props}\n    >\n      {/* Scroll-edge effect. Chromium does not mask backdrop-filter output, so the blur is stacked in steps\n          (each layer compounds the ones below it) and the wash is a plain gradient that fades over the same span. */}\n      <div aria-hidden=\"true\" data-slot=\"header-glass\" className=\"pointer-events-none absolute inset-x-0 top-0\" style={{ height: m.fadeEnd }}>\n        {[0, 1, 2, 3].map(step => (\n          <div key={step} className=\"absolute inset-x-0 top-0\" style={{ height: m.fadeEnd - step * ((m.fadeEnd - m.fadeStart) / 4), backdropFilter: \"blur(5px)\", WebkitBackdropFilter: \"blur(5px)\" }} />\n        ))}\n        <div className=\"absolute inset-x-0 top-0\" style={{ height: m.fadeEnd, background: `linear-gradient(var(--hd-tint) ${m.fadeStart}px, var(--hd-tint-60) ${m.fadeStart + (m.fadeEnd - m.fadeStart) * 0.3}px, var(--hd-tint-40) ${m.fadeStart + (m.fadeEnd - m.fadeStart) * 0.65}px, transparent ${m.fadeEnd}px)` }} />\n      </div>\n\n      <button type=\"button\" data-slot=\"compose-button\" aria-label=\"New message\" onClick={onCompose}\n        className={cn(glassButton, \"rounded-full\")} style={{ left: m.compose.left, top: m.compose.top, width: m.compose.size, height: m.compose.size }}>\n        {/* square.and.pencil, drawn in button coordinates so nothing is re-centered. */}\n        <svg aria-hidden=\"true\" viewBox=\"0 0 36 36\" width=\"36\" height=\"36\" className=\"block\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"M20.4 11.6h-5.2a3 3 0 0 0-3 3v7.2a3 3 0 0 0 3 3h7.15a3 3 0 0 0 3-3v-6\" strokeWidth=\"1.35\" />\n          <path d=\"M17.5 19.4 25.9 11\" />\n          <path d=\"M16.5 20.4l1-1\" strokeWidth=\"0.9\" />\n          <circle cx=\"26.9\" cy=\"10\" r=\"0.6\" fill=\"currentColor\" stroke=\"none\" />\n        </svg>\n      </button>\n\n      <div data-slot=\"header-contact\" className=\"absolute left-1/2 -translate-x-1/2\" style={{ top: m.avatar.top, maxWidth: `calc(100% - ${m.maxWidthInset * 2}px)` }}>\n        <div className=\"flex flex-col items-center\">\n          <span className=\"relative z-10 flex\">\n            {avatar ?? (group\n              ? <GroupPhoto size={m.avatar.size} members={group} name={name} />\n              : <Avatar size={m.avatar.size} initials={fallbackInitials} src={photo} name={name} />)}\n          </span>\n          {/* The pill sizes to the name: its paddings, the gap and the chevron are fixed, so its width is\n              the name's advance plus 28.25 and it grows half each way about the pane's centre. `maxWidth`\n              stops it before the video button and truncates instead; the chevron never shrinks, so it\n              keeps its 9 to the right edge at every width. */}\n          <button type=\"button\" data-slot=\"name-pill\" onClick={onOpenDetails} aria-label={`${name}, show details`}\n            className=\"flex max-w-full items-start bg-[var(--hd-pill)] p-0 shadow-[var(--hd-pill-rim)] outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#3478f6]\"\n            style={{ height: m.pill.height, borderRadius: m.pill.height / 2, marginTop: m.pill.top - m.avatar.top - m.avatar.size, paddingTop: m.pill.textTop - m.pill.top, paddingLeft: m.pill.paddingLeft, paddingRight: m.pill.paddingRight }}>\n            <span data-slot=\"name\" className=\"overflow-hidden text-ellipsis whitespace-nowrap font-bold text-[var(--hd-name)]\" style={{ fontSize: m.pill.fontSize, lineHeight: \"20px\", letterSpacing: 0 }}>{name}</span>\n            {/* Chevron, fitted to the dark capture by coverage rather than by a threshold: a round-capped,\n                round-joined polyline whose distance field is rasterised at 12×12 samples per device pixel\n                and least-squares matched to the ink. The fit lands at 0.008 rms coverage and gives a vertex\n                at pane (331.7, 58.25), arms 1.72 across per 3.75 down (0.459, not the 0.413 an earlier\n                1.55/3.75 path drew), and ink 3.69 × 9.47 spanning the cap height. The same fit reads 1.97\n                for the stroke and reads a drawn 2 as 1.96, so 2 stays.\n                Two Chrome quirks, both measured back off the render rather than predicted: it snaps an\n                inline SVG's paint origin to a whole CSS px vertically, so the box sits on y 53 and the\n                viewBox carries the missing half pixel; and horizontally it paints 0.44 CSS px left of the\n                box origin `getBoundingClientRect` reports (328.953), so the path coordinates carry that\n                back. The ink therefore laps 0.63 past the box's right edge, which `overflow: visible`\n                keeps: the box is the layout slot, not the ink box. Re-measure after any change here. */}\n            <svg aria-hidden=\"true\" viewBox=\"0 -0.5 3.55 10.5\" width=\"3.55\" height=\"10.5\" className=\"block shrink-0\" style={{ marginLeft: m.pill.gap, marginTop: 5, overflow: \"visible\" }} fill=\"none\" stroke=\"var(--hd-chevron)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M1.471 1 3.179 4.75 1.471 8.5\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n\n      <button type=\"button\" data-slot=\"video-button\" aria-label={`FaceTime ${name}`} onClick={onVideoCall}\n        className={glassButton} style={{ right: m.video.right, top: m.video.top, width: m.video.width, height: m.video.height, borderRadius: m.video.height / 2 }}>\n        {/* video: the body's stroke centres measure x 11.05–24.8 and y 11.65–23.95 in button coordinates\n            (from the 2x dark capture), and the lens's right edge sits at x 30.1 with arms 1.19 across per down.\n            Corner radius 2.3, not 2 and not 2.5: rendering each candidate and comparing the four corner\n            quadrants to the capture in coverage space (each image normalised by its own fitted background\n            plane, so the content behind the glass cannot bias it) gives rms 0.064 at 2.2, 0.056 at 2.3,\n            0.059 at 2.35, 0.087 at 2.5 and 0.125 at 2.65, a parabola with its vertex at 2.30. All four\n            corners agree, and the same sweep against the light capture picks the same value. */}\n        <svg aria-hidden=\"true\" viewBox=\"0 0 40 36\" width=\"40\" height=\"36\" className=\"block\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.15\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <rect x=\"11.05\" y=\"11.65\" width=\"13.75\" height=\"12.3\" rx=\"2.3\" />\n          <path d=\"M24.8 16.4 28.43 13.35c.77-.45 1.67 0 1.67.9v7.1c0 .9-.9 1.35-1.67.9L24.8 19.2\" />\n        </svg>\n      </button>\n    </header>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-header.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-composer",
      "title": "macOS composer",
      "description": "The message field with the plus, audio, and emoji controls; Return sends.",
      "files": [
        {
          "path": "registry/imessage/macos-composer.tsx",
          "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ComponentProps, type CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * macOS 26 Messages composer row. Measured from `references/macos/captures/conversation-pane-dark.png`,\n * `conversation-pane-dark-2.png`, `composer-empty-and-typed-dark.png`, `conversation-pane-light.png` and\n * `conversation-pane-light-partial.png` (pane coordinates, 630 wide, window 640 tall; every capture is 2x,\n * so the pane pixel 1196 quoted below is point 598):\n * - \"+\" button: Ø30, left 9, bottom 11 (center 24, 614; outer edge px 18–77 and 1198–1257 in the dark pane).\n *   Cross 12.3 long including the caps, 1.7 stroke, centered (14.8, 15.2) in the button's own 30 box, so it\n *   sits 0.2 left of and 0.2 below the button centre. #f1f1f1 dark / #010101 light.\n * - field: x 49–579 (530 wide), 31 tall, bottom 11 (pill; the ends deviate from a circle by at most 0.35 pt).\n *   Dark: fill #232323, text #dddddd, placeholder #626262. Light: fill #ffffff, placeholder #bdbdbd, no rim\n *   at all (the fill steps straight to the pane). Every element floats on the same soft downward shadow (in\n *   light, 11/255 darker than the pane just under the field, 5/255 in the two px above it, 3/255 eight px\n *   above).\n * - the dark rim is lit on one diagonal, not uniform: it is #424242 for 0.77 inside the top edge (reading\n *   #424242 then #343434 down the two px), the same along the other three edges and at the top-left and\n *   bottom-right, and it vanishes into the fill at the top-right and bottom-left. Peaks around the \"+\"\n *   circle: 0x40 top, 0x42 left/right, 0x45 bottom-right, 0x24 top-right and bottom-left. The same rim is\n *   on the field and both buttons, and two offset inset shadows reproduce it. Our two dim diagonals\n *   still carry 0x2f against native's 0x24, and that is Chrome's antialiasing floor rather than a wrong\n *   offset: the band an inset shadow paints along a normal is `offset · normal + spread`, which is\n *   already 0 on those two diagonals, and Chrome still inks each shadow's tangent edge there. It can be\n *   pushed past 0 with a negative spread, and offsets of 1.1 with a -0.326 spread (which keeps the\n *   measured 0.774 band on the straight edges, byte for byte) do take the null to 0x23 and cut the peak\n *   error over 24 angles from 165 to 131 on the field and 107 to 64 on the \"+\" circle. It is not worth\n *   it: the same negative spread widens the *lit* diagonal from 1.094 to 1.229, which spills an extra\n *   antialiased pixel down the bottom-right of all three shapes and costs 11 mismatched pixels in each\n *   dark composer region. The measured 0.774 with no spread is what stays.\n * - text: 13pt system. Web SF at 13px runs 7% wide of native, so it carries -0.4px tracking (\"Every detail\"\n *   inks 128 px at 2x both ways, \"Message\" 99 px). Native caps measure 17.5–18.1 px (E vs M) against the\n *   font's 18.3, i.e. within half a pt. Cap top 10.75 below the field top (\"E\" ink top px 1217.5, baseline\n *   1235), ink x 62 for typed text; the placeholder sits 2.5 further right (ink x 64.35) and 0.5 lower\n *   (baseline px 1236) while the caret stays at the typed origin (x 60.75–62.25, 15 tall, accent blue).\n * - \"waveform\" glyph: five stadium bars centred on the field's middle at (559.5, 613.5), hidden while text\n *   exists. Widths 1.6 / 1.8 / 2.0 / 1.8 / 1.6, heights 3.77 / 7.0 / 14.47 / 7.0 / 3.77, centres 3.595 and\n *   7.305 either side of the middle bar. #858585 dark / #999999 light. These ink 7.347 / 13.755 / 28.673\n *   device px in their tallest column against native's 7.531 / 14.000 / 28.939, and that 0.09–0.13 pt\n *   shortfall is Chrome's, not a wrong number: it rasterizes an SVG shape's extent onto a half-device-\n *   pixel grid, so sweeping the middle bar's height at 0.02 steps the ink 28.673 → 29.184 → 29.673 with\n *   nothing in between (the boundaries land on 14.62 and 14.88, and scaling the viewBox 4x does not move\n *   them, so the grid is in device space). Native's 28.939 falls between two attainable values. Drawing\n *   the bars as HTML boxes instead is worse still: they snap to the whole CSS pixel and ink 27.673.\n * - emoji button: Ø30 outside the field, left 589 (right inset 11). \"face.smiling\" Ø15.55 centred (14.79,\n *   15.2) in the button box: outlined 1.35 in light, filled #f4f4f4 in dark with the features cut out. Eyes\n *   Ø1.98 at (12.54, 13.42) and (17.06, 13.42). The mouth is a 10.04-wide D whose top edge arcs up 0.65 at\n *   the corners (15.65) from its middle (16.3) and whose bottom reaches 20.36, with a 0.94-thick band of\n *   teeth curving through it.\n *\n * ## Growing past one line\n *\n * Every capture holds a one-line field, so nothing past the first line is measured. What is measured is\n * the one-line box (31 tall, bottom 11 above the pane bottom, cap top 10.75 below the field top) and the\n * line pitch of the same 13pt system text elsewhere in the app: **14.70**, read off the bubbles of\n * `conversation-pane-light.png` (28.76 / 43.39 / 58.10 / 72.87 for one to four lines) and already in\n * `tokens.ts` as `bubbleMetrics.macos.lineHeight`. Put the field's text on that pitch and the rest\n * follows: one line of 14.70 inside a 31 box leaves 16.30 of padding, and it is split evenly, **8.15\n * above and 8.15 below**. Evenly, because Chrome snaps a line's origin to the whole CSS pixel: sweeping\n * the padding at 0.05 and diffing the rendered rows, every value from 7.50 to 8.45 puts this line's ink\n * byte for byte where the old 16pt line box put it, and the even split is the one inside that plateau\n * that also lands the caret box on the measured caret (native 8 to 23 inside the field, 8.15 to 22.85\n * here). A \"correct\" 7.15, which is the old 6.5 plus the 0.65 of half-leading a shorter line box gives\n * back, sits on the far side of that snap and renders a whole pixel high.\n *\n * Each extra line therefore adds 14.70: two lines 45.70, three 60.40, nine 148.60. The field grows upward\n * only, so its bottom edge and both buttons stay on y 629, as the field's own `bottom: 11` and the\n * buttons' already do. The corner radius stays 15.5 while it grows, which is what the iOS field is\n * measured to do (a plain circular radius at one line and at four alike); no macOS capture shows it.\n *\n * ## Motion\n *\n * **Unverified: no capture in this repo records this field in motion**, so the height change is timed by\n * borrowing, not by measurement. 160 ms on `cubic-bezier(0.32, 0.72, 0, 1)` is the entrance\n * `macos-plus-menu.tsx` plays, which is itself the decelerating curve of the measured iOS effects screen\n * (`effectsPickerMetrics.timing`); the shrink reuses the same pair, and that symmetry is a guess too.\n * What is exact is the pose at each end: the animation runs from the height the field had to the height\n * the text needs, both of them the layout above, and it carries no fill, so the frame at progress 1 *is*\n * the settled layout rather than a copy of it. `grow={{ fromLines, progress }}` seeks it instead of\n * playing it, the way `MacPlusMenu` takes `progress`, so a scrubbed checkpoint renders the same every\n * run; `prefers-reduced-motion` builds no animation at all and the field is simply at its new height.\n *\n * ## Focus\n *\n * Every macOS composer capture is of a **focused** field, and not one of them draws a focus ring:\n * in `composer-empty-and-typed-dark.png` the empty focused field and the typed focused field carry the\n * same rim as the two buttons beside them, to the byte: sampling both crops' caps every 15 degrees\n * returns the same 0x39 at the top, 0x45 on the lit diagonals and 0x24 on the dim ones, and the same\n * #424242 then #343434 down the top edge. So this component draws no accent ring either. What focus\n * does change is measured: the caret (#3b86f7 light / #3f8ff7 dark, 1.5 wide × 15 tall at x 60.75–62.25, spanning\n * 8 to 23 inside the field) and the placeholder, which reads \"Message\" while focused. Unverified: the\n * unfocused field, since no capture holds one, so \"iMessage\" and any change the rim makes when the\n * window stops being key are invented. The caret is the browser's own: 1 CSS px wide against native's\n * 1.5, as tall as the line box (14.70 here, against a measured 15), and headless Chromium paints no\n * caret at all, so it is the one part of the focused field a screenshot in this repo cannot check.\n */\nexport const macComposerMetrics = {\n  bottom: 11,\n  plus: { left: 9, size: 30 },\n  field: { left: 49, width: 530, height: 31, paddingLeft: 12, paddingRight: 34, fontSize: 13, letterSpacing: -0.4, lineHeight: 14.7, paddingTop: 8.15, placeholderIndent: 2.5 },\n  waveform: { centerFromRight: 19.5, centerY: 15.5, hit: 24, nudge: 0.5 },\n  emoji: { left: 589, size: 30 },\n  /** Height change. Unverified: borrowed from `macPlusMenuMetrics.motion`, see the note above. */\n  motion: { grow: 160, growEase: \"cubic-bezier(0.32, 0.72, 0, 1)\" },\n};\n\n/** The field's height with `lines` lines of text: the measured 31 for one, plus the 14.70 line pitch. */\nexport function macComposerFieldHeight(lines: number) {\n  const f = macComposerMetrics.field;\n  return f.height + (Math.max(1, lines) - 1) * f.lineHeight;\n}\n\n/** Padding the field keeps above and below its text, whatever it grows to: 31 − 14.70. */\nconst fieldPaddingY = macComposerMetrics.field.height - macComposerMetrics.field.lineHeight;\n\nfunction clamp01(value: number) {\n  return Math.max(0, Math.min(1, value));\n}\n\nconst reducedMotionQuery = () => (typeof window === \"undefined\" ? null : window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") ?? null);\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = reducedMotionQuery();\n  query?.addEventListener(\"change\", onChange);\n  return () => query?.removeEventListener(\"change\", onChange);\n}\n/**\n * True when the viewer asked for less motion; false while server rendering. `macos-plus-menu.tsx` has\n * the same hook, and this file keeps its own copy so the composer stays a registry item with no\n * dependency of its own.\n */\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReducedMotion, () => reducedMotionQuery()?.matches ?? false, () => false);\n}\n\nexport type MacComposerProps = Omit<ComponentProps<\"form\">, \"onSubmit\" | \"onChange\" | \"defaultValue\"> & {\n  onSend: (message: string) => void | Promise<void>;\n  onChange?: (value: string) => void;\n  value?: string;\n  defaultValue?: string;\n  /** Shown while the field is not focused (native: the service name). */\n  placeholder?: string;\n  /** Shown while the field is focused. */\n  focusedPlaceholder?: string;\n  autoFocus?: boolean;\n  disabled?: boolean;\n  onAttach?: () => void;\n  /** Whether the attachments popover (`MacPlusMenu`) is open; sets the \"+\" button's aria-expanded. */\n  attachExpanded?: boolean;\n  onEmoji?: () => void;\n  onAudio?: () => void;\n  /** Widest the field may grow (px). Defaults to the measured 530. */\n  fieldWidth?: number;\n  /** Tallest the field grows before its text scrolls, in lines. Unverified: no capture holds a ceiling. */\n  maxLines?: number;\n  /**\n   * Seek the field's height change to this fraction (0..1) instead of playing it, which is what the\n   * harness does: the field starts at `fromLines` lines and lands on the height the current text needs.\n   * A seeked frame at 1 is the settled layout, so a checkpoint renders the same on every run.\n   */\n  grow?: { fromLines: number; progress: number };\n};\n\nexport function MacComposer({ onSend, onChange, value, defaultValue = \"\", placeholder = \"iMessage\", focusedPlaceholder = \"Message\", autoFocus = false, disabled = false, onAttach, attachExpanded, onEmoji, onAudio, fieldWidth, maxLines = 9, grow, className, style, ...props }: MacComposerProps) {\n  const m = macComposerMetrics;\n  const [draft, setDraft] = useState(defaultValue);\n  const [focused, setFocused] = useState(autoFocus);\n  /** `null` until the first measurement: the field is laid out by its own content until then. */\n  const [lines, setLines] = useState<number | null>(null);\n  const composing = useRef(false);\n  const textarea = useRef<HTMLTextAreaElement>(null);\n  const field = useRef<HTMLDivElement>(null);\n  /** The line count the field is laid out at, so the next change knows where it is growing from. */\n  const laidOut = useRef<number | null>(null);\n  /** Height a height change was interrupted at, so a second one carries on from it instead of jumping back. */\n  const interrupted = useRef<number | null>(null);\n  const reduced = usePrefersReducedMotion();\n  const id = useId();\n  const text = value ?? draft;\n  const width = fieldWidth ?? m.field.width;\n  const height = lines === null ? undefined : macComposerFieldHeight(lines);\n\n  /**\n   * The field is laid out at the height its text needs. `field-sizing: content` already grows the\n   * textarea itself, so this reads that back and snaps it to a whole number of lines: `scrollHeight`\n   * is an integer, and the field's height has to land on the 14.70 pitch exactly, not on 46 for what\n   * is really 45.70. Until it has run the field takes its height from that same textarea, so a\n   * composer that mounts with a draft in it is the right height on its first paint, not one line tall.\n   */\n  useLayoutEffect(() => {\n    const el = textarea.current;\n    if (!el) return;\n    setLines(Math.max(1, Math.min(maxLines, Math.round((el.scrollHeight - fieldPaddingY) / m.field.lineHeight))));\n  }, [text, width, maxLines, m.field.lineHeight]);\n\n  /**\n   * The height change itself. It is a transient override of the layout above (no `fill`), so the end\n   * of it is the settled field rather than a copy of it, and a cancelled one leaves nothing behind.\n   */\n  const growFrom = grow?.fromLines;\n  const growProgress = grow?.progress;\n  useLayoutEffect(() => {\n    const element = field.current;\n    if (lines === null) return;\n    // The commit that turns the first measurement into a height is the composer's layout, not a\n    // growth: `laidOut` is still null there and the field was already the right height without it.\n    // A scrubbed `grow` is the exception, since the harness mounts a fresh page at every checkpoint.\n    const from = growFrom ?? laidOut.current;\n    laidOut.current = lines;\n    if (!element || reduced || from === null || from === lines) return;\n    // The text is already laid out at its full height while the box is still opening, so the box has\n    // to clip it, and `items-end` keeps the newest line against the bottom edge with the older ones\n    // sliding out from behind the top one. It clips only while the height is moving: `playSendAnimation`\n    // draws its bubble-shaped ghost inside this same field, and that ghost's tail hangs below the\n    // field's bottom edge on purpose.\n    element.style.overflow = \"hidden\";\n    const clear = () => { element.style.overflow = \"\"; };\n    // A line that lands while an earlier one is still opening starts from the height on screen, not\n    // from the one it was heading for, so a fast typist never sees the box step backwards. A seeked\n    // `grow` ignores that and always starts at `fromLines`: a checkpoint has no history to carry.\n    const start = growFrom !== undefined ? macComposerFieldHeight(growFrom) : (interrupted.current ?? macComposerFieldHeight(from));\n    interrupted.current = null;\n    const animation = element.animate(\n      [{ height: `${start}px` }, { height: `${macComposerFieldHeight(lines)}px` }],\n      { duration: m.motion.grow, easing: m.motion.growEase },\n    );\n    animation.finished.then(clear, () => undefined);\n    if (growProgress !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      animation.pause();\n      animation.currentTime = clamp01(growProgress) * m.motion.grow;\n    }\n    return () => {\n      // While it is still running the animation owns the computed height, so this reads the pose the\n      // field is actually in; once it has finished the height is the settled one and there is nothing\n      // to carry over.\n      interrupted.current = animation.playState === \"running\" ? parseFloat(getComputedStyle(element).height) : null;\n      animation.cancel();\n      clear();\n    };\n  }, [lines, growFrom, growProgress, reduced, m.motion.grow, m.motion.growEase]);\n\n  function update(next: string) {\n    if (value === undefined) setDraft(next);\n    onChange?.(next);\n  }\n  async function send() {\n    const message = text.trim();\n    if (!message || disabled || composing.current) return;\n    await onSend(message);\n    update(\"\");\n    requestAnimationFrame(() => textarea.current?.focus());\n  }\n\n  const glass = \"absolute block rounded-full bg-[var(--cp-button)] p-0 text-[var(--cp-ink)] shadow-[var(--cp-shadow)] outline-offset-2 hover:bg-[var(--cp-button-hover)] focus-visible:outline-2 focus-visible:outline-[#3478f6] disabled:opacity-50\";\n\n  return (\n    <form\n      data-slot=\"mac-composer\"\n      aria-label=\"Send a message\"\n      onSubmit={event => { event.preventDefault(); void send(); }}\n      className={cn(\n        \"absolute inset-x-0 bottom-0 select-none\",\n        \"[--cp-button-hover:#f4f4f4] [--cp-button:#ffffff] [--cp-caret:#3b86f7] [--cp-face-fill:transparent] [--cp-face-ink:#000000] [--cp-face-stroke:#000000] [--cp-field:#ffffff] [--cp-ink:#000000] [--cp-placeholder:#bdbdbd] [--cp-shadow:0_5px_25px_rgba(0,0,0,0.07)] [--cp-text:#262626] [--cp-wave:#999999]\",\n        \"dark:[--cp-button-hover:#2c2c2c] dark:[--cp-button:#232323] dark:[--cp-caret:#3f8ff7] dark:[--cp-face-fill:#f4f4f4] dark:[--cp-face-ink:#232323] dark:[--cp-face-stroke:#f4f4f4] dark:[--cp-field:#232323] dark:[--cp-ink:#f1f1f1] dark:[--cp-placeholder:#626262] dark:[--cp-shadow:inset_0.774px_0.774px_0_0_#424242,inset_-0.774px_-0.774px_0_0_#424242,0_5px_25px_rgba(0,0,0,0.05)] dark:[--cp-text:#dddddd] dark:[--cp-wave:#858585]\",\n        className,\n      )}\n      style={{ height: m.bottom + (height ?? m.field.height) + 10, fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style }}\n      {...props}\n    >\n      <button type=\"button\" data-slot=\"attach-button\" aria-label=\"Add attachment\" aria-haspopup=\"menu\" aria-expanded={attachExpanded} disabled={disabled} onClick={onAttach}\n        className={glass} style={{ left: m.plus.left, bottom: m.bottom, width: m.plus.size, height: m.plus.size }}>\n        <svg aria-hidden=\"true\" viewBox=\"0 0 30 30\" width=\"30\" height=\"30\" className=\"block\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\">\n          {/* 12.58 of ink including the 0.85 cap radius at each end, so each arm is 10.88 of path.\n              Measured on the stroke axis: a row even 0.8 px off centre reads 12.3 because the caps taper. */}\n          <path d=\"M14.8 9.74v10.88M9.36 15.2h10.88\" />\n        </svg>\n      </button>\n\n      {/* No overflow rule here: the height animation adds one for as long as it runs (see above), and\n          at rest the field has to let the send animation's ghost tail hang past its bottom edge. */}\n      <div data-slot=\"field\" ref={field} data-lines={lines ?? undefined} className=\"absolute flex items-end bg-[var(--cp-field)] shadow-[var(--cp-shadow)]\"\n        style={{ left: m.field.left, bottom: m.bottom, width, height, minHeight: m.field.height, borderRadius: m.field.height / 2 }}>\n        <label htmlFor={id} className=\"sr-only\">Message</label>\n        <textarea\n          ref={textarea}\n          id={id}\n          rows={1}\n          value={text}\n          disabled={disabled}\n          autoFocus={autoFocus}\n          placeholder={focused ? focusedPlaceholder : placeholder}\n          onChange={event => update(event.target.value)}\n          onFocus={() => setFocused(true)}\n          onBlur={() => setFocused(false)}\n          onCompositionStart={() => { composing.current = true; }}\n          onCompositionEnd={() => { composing.current = false; }}\n          onKeyDown={event => {\n            if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing && !composing.current) { event.preventDefault(); void send(); }\n          }}\n          className=\"m-0 block w-full resize-none border-0 bg-transparent p-0 text-[var(--cp-text)] outline-none [field-sizing:content] placeholder:text-[var(--cp-placeholder)] placeholder:[text-indent:var(--cp-placeholder-indent)] disabled:opacity-50\"\n          style={{\n            \"--cp-placeholder-indent\": `${m.field.placeholderIndent}px`,\n            fontSize: m.field.fontSize, lineHeight: `${m.field.lineHeight}px`, letterSpacing: m.field.letterSpacing, caretColor: \"var(--cp-caret)\",\n            paddingTop: m.field.paddingTop, paddingBottom: m.field.height - m.field.lineHeight - m.field.paddingTop,\n            paddingLeft: m.field.paddingLeft, paddingRight: m.field.paddingRight, maxHeight: fieldPaddingY + maxLines * m.field.lineHeight, overflowY: \"auto\",\n          } as CSSProperties}\n        />\n        {!text && (\n          <button type=\"button\" data-slot=\"audio-button\" aria-label=\"Record audio message\" disabled={disabled} onClick={onAudio}\n            className=\"absolute block rounded-[6px] p-0 text-[var(--cp-wave)] outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#3478f6]\"\n            // Chrome snaps a positioned box to whole px but honours a transform exactly, so the offsets stay\n            // integers (right 8, bottom 4) and the transform carries the half-pixel that lands the measured\n            // centre. It hangs off the bottom, not the top, so it stays on the field's last line when the\n            // field is taller than one (it is hidden while text exists, so that only shows during a shrink).\n            style={{ right: m.waveform.centerFromRight - m.waveform.hit / 2 + m.waveform.nudge, bottom: m.field.height - m.waveform.centerY - m.waveform.hit / 2 + m.waveform.nudge, width: m.waveform.hit, height: m.waveform.hit, transform: `translate(${m.waveform.nudge}px, ${m.waveform.nudge}px)` }}>\n            {/* Five stadium bars, widths and heights measured one by one; the outer pair sits 7.305 out, the\n                inner pair 3.595. They ink 0.09–0.13 short of native and a taller box cannot fix it: Chrome\n                snaps an SVG extent to the half device pixel (see the note at the top of this file). */}\n            <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" className=\"block\" fill=\"currentColor\">\n              <rect x=\"3.895\" y=\"10.115\" width=\"1.6\" height=\"3.77\" rx=\"0.8\" />\n              <rect x=\"7.505\" y=\"8.5\" width=\"1.8\" height=\"7\" rx=\"0.9\" />\n              <rect x=\"11\" y=\"4.765\" width=\"2\" height=\"14.47\" rx=\"1\" />\n              <rect x=\"14.695\" y=\"8.5\" width=\"1.8\" height=\"7\" rx=\"0.9\" />\n              <rect x=\"18.505\" y=\"10.115\" width=\"1.6\" height=\"3.77\" rx=\"0.8\" />\n            </svg>\n          </button>\n        )}\n      </div>\n\n      <button type=\"button\" data-slot=\"emoji-button\" aria-label=\"Emoji\" disabled={disabled} onClick={onEmoji}\n        className={glass} style={{ left: m.emoji.left, bottom: m.bottom, width: m.emoji.size, height: m.emoji.size }}>\n        {/* The glyph sits 0.2 left of and 0.2 below the button's centre, the same offset the \"+\" carries. */}\n        <svg aria-hidden=\"true\" viewBox=\"0 0 30 30\" width=\"30\" height=\"30\" className=\"block\">\n          <circle cx=\"14.79\" cy=\"15.2\" r=\"7.11\" fill=\"var(--cp-face-fill)\" stroke=\"var(--cp-face-stroke)\" strokeWidth=\"1.35\" />\n          <circle cx=\"12.54\" cy=\"13.42\" r=\"0.99\" fill=\"var(--cp-face-ink)\" />\n          <circle cx=\"17.06\" cy=\"13.42\" r=\"0.99\" fill=\"var(--cp-face-ink)\" />\n          <path d=\"M9.8 15.65q5.02 1.3 10.04 0a5.02 4.71 0 0 1-10.04 0Z\" fill=\"var(--cp-face-ink)\" />\n          <path d=\"M11.315 17.15q3.505 0.89 7.01 0\" fill=\"none\" strokeWidth=\"0.94\" strokeLinecap=\"round\" stroke=\"var(--cp-face-fill)\" className=\"[stroke:#ffffff] dark:[stroke:var(--cp-face-fill)]\" />\n        </svg>\n      </button>\n    </form>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-composer.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "tapback-bar",
      "title": "Tapback bar",
      "description": "The iOS Tapback picker pill with recent emoji and the emoji-picker bubble, plus the macOS two-row layout.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/tapback-bar.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { fontStack } from \"@/components/imessage/tokens\";\nimport { BalloonTrail, pickerBalloonGeometry, TapbackGlyph, tapbackColors, tapbackLabels, tapbackTypes, type TapbackType } from \"@/components/imessage/tapback\";\n\n/**\n * Tapback picker, measured from iOS 26 (`references/ios/captures/longpress-ok-light.png`, 3x) and\n * macOS 26 (`references/macos/captures/ctxmenu-light.png`, 2x).\n *\n * iOS: a glass pill 64.33 tall (y 452–516.33 in the capture, measured on the centre line) with 49-wide\n * glyph slots, the first glyph centered 32.5 from the pill's left end; glyph art 25.33. The emoji-picker\n * \"thought bubble\" (Ø44 + Ø14.6 + Ø8, same shape as a balloon at 1.3x) is centered 14.67 below the pill's bottom edge.\n * macOS: two rows of six inside the context menu. Glyph ink centres in `ctxmenu-light.png` (2x, menu\n * border at x 11 px / y 3 px) are 40.00, 87.00, 133.50, 180.25, 227.00 and 273.75 pt in row 2, so the\n * pitch is 46.75 and the first slot's centre sits 34.5 from the menu's leading edge. The row-2 emoji\n * ink is 20.0 × 20.0 centred at y 64.0 (62.5 inside the menu) and the same 👍 measures 18.5 wide in\n * both rows, so one glyph size serves both; the rows are 38.5 apart (👍 ink centres 25.25 and 63.75).\n * Both rows sit on the one slot grid and nothing nudges row 2: a padding on the emoji slots used to\n * put the whole row 0.75 right of the capture, because a border-box slot spends the padding out of\n * its own width and so moves its centred content by half of it.\n * `emojiSize` is 21, the size whose ink measures the capture's 20.0 wide and lands on its exact rows\n * (108–147 at 2x). The rest of the gap is rasterisation, not geometry, and cannot be styled away:\n * Chromium snaps an Apple Color Emoji bitmap to whole device pixels, so at 21 the ink sits one device\n * pixel (0.5 pt) left of the capture, and the 20.75 that recovers that pixel loses two in y. At any\n * size it draws 👍 a point wider than the capture's 18.5 and 🥹 a point narrower than its 20.0, in\n * opposite directions, so no one size fixes both.\n *\n * `inset` puts the first slot's centre 34.5 from the menu's *fill* edge. The capture measures that\n * 34.5 from the outer edge of the menu's 0.5 pt border, half a point further left, so the whole grid\n * sits 0.5 right of native. It is left that way deliberately: the picker's glyphs are drawn about\n * that much left inside their own boxes (row 1's art in `tapback.tsx`, row 2's by the emoji\n * rasteriser), and correcting the grid alone moves every ink centre in both rows away from the\n * capture. Correct them together, not the grid on its own.\n */\nexport const tapbackBarMetrics = {\n  ios: { height: 64.33, slot: 49, firstCenter: 32.5, glyph: 25.33, selectedRing: 44, pickerDrop: 14.67, edgeInset: 10.83, emojiSize: 25 },\n  macos: { slot: 46.75, glyph: 20.5, emojiSize: 21, row1: 40, row2: 37, rowHeight: 36, inset: 11.13 },\n} as const;\n\nexport type TapbackSelection = { type: TapbackType } | { emoji: string };\nexport type TapbackBarProps = {\n  layout?: \"ios\" | \"macos\";\n  selected?: TapbackSelection;\n  /** Recently used emoji shown after the six classics (fixture data). */\n  recent?: string[];\n  onSelect?: (selection: TapbackSelection) => void;\n  /** Called when the emoji picker (thought bubble on iOS, smiley button on macOS) is activated. */\n  onPickEmoji?: () => void;\n  onClose?: () => void;\n  /** iOS: x of the emoji-picker circle's center relative to the pill's left edge; omit to hide it. */\n  pickerX?: number;\n  /** iOS: which way the picker's trail points (away from the bubble). */\n  pickerSide?: \"left\" | \"right\";\n  /** iOS: pill width; defaults to its content. */\n  width?: number;\n  /** Autofocus the first (or selected) glyph on mount. */\n  autoFocus?: boolean;\n  className?: string;\n  style?: CSSProperties;\n  /** Optional per-glyph style hook, used by the long-press overlay to stagger the entrance. */\n  glyphStyle?: (index: number) => CSSProperties | undefined;\n  children?: ReactNode;\n};\n\nconst defaultRecent = [\"😂\", \"❤️\", \"😮\", \"😢\", \"😭\", \"👍\"];\n\nfunction isSelected(sel: TapbackSelection | undefined, type?: TapbackType, emoji?: string) {\n  if (!sel) return false;\n  return \"type\" in sel ? sel.type === type : sel.emoji === emoji;\n}\n\n/** SF Symbol \"face.smiling\" look-alike, an outlined grinning face. */\nexport function SmileyIcon({ size = 22, color = \"currentColor\", strokeWidth = 1.7, style }: { size?: number; color?: string; strokeWidth?: number; style?: CSSProperties }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 22 22\" aria-hidden=\"true\" style={style}>\n      <circle cx=\"11\" cy=\"11\" r=\"9.5\" fill=\"none\" stroke={color} strokeWidth={strokeWidth} />\n      <circle cx=\"7.7\" cy=\"8.5\" r=\"1.25\" fill={color} />\n      <circle cx=\"14.3\" cy=\"8.5\" r=\"1.25\" fill={color} />\n      <path d=\"M5.4,12.3 H16.6 C16.6,15.7 14.1,18 11,18 C7.9,18 5.4,15.7 5.4,12.3 Z\" fill=\"none\" stroke={color} strokeWidth={strokeWidth * 0.9} strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** The emoji-picker thought bubble: a Ø44 glass circle merged into the bar with two trailing circles. */\nexport function EmojiPickerBubble({ side = \"left\", onClick, style, fill, iconColor, iconStyle, glass, clipTop = 0, role, tabIndex, index, onFocus }: { side?: \"left\" | \"right\"; onClick?: () => void; style?: CSSProperties; fill: string; iconColor: string; iconStyle?: CSSProperties; glass?: CSSProperties; clipTop?: number; role?: string; tabIndex?: number; index?: number; onFocus?: () => void }) {\n  const g = pickerBalloonGeometry;\n  const Tag = onClick ? \"button\" : \"div\";\n  return (\n    // Without a handler it opens nothing, so it is decoration and says nothing to a screen reader.\n    <Tag type={onClick ? \"button\" : undefined} data-slot=\"emoji-picker-bubble\" aria-label={onClick ? \"Choose an emoji\" : undefined} aria-hidden={onClick ? undefined : true} onClick={onClick}\n      role={onClick ? role : undefined} tabIndex={onClick ? tabIndex : undefined} data-index={onClick ? index : undefined} onFocus={onClick ? onFocus : undefined}\n      className={cn(\"border-0 p-0 outline-none\", onClick && \"cursor-pointer focus-visible:ring-2 focus-visible:ring-blue-500\")}\n      style={{ position: \"absolute\", width: g.main, height: g.main, borderRadius: \"50%\", background: fill, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", ...glass, clipPath: clipTop ? `inset(${clipTop}px -40px -40px -40px)` : undefined, ...style }}>\n      <SmileyIcon size={g.glyph} color={iconColor} style={iconStyle} />\n      <BalloonTrail geometry={g} side={side} color={fill} />\n    </Tag>\n  );\n}\n\nexport function TapbackBar({ layout = \"ios\", selected, recent = defaultRecent, onSelect, onPickEmoji, onClose, pickerX, pickerSide = \"left\", width, autoFocus = false, className, style, glyphStyle, children }: TapbackBarProps) {\n  const root = useRef<HTMLDivElement>(null);\n  const items: Array<{ type?: TapbackType; emoji?: string; label: string }> = [\n    ...tapbackTypes.map(type => ({ type, label: tapbackLabels[type] })),\n    ...recent.map(emoji => ({ emoji, label: emoji })),\n  ];\n  const selectedIndex = items.findIndex(item => isSelected(selected, item.type, item.emoji));\n  const [focusIndex, setFocusIndex] = useState(selectedIndex >= 0 ? selectedIndex : 0);\n\n  useEffect(() => {\n    if (!autoFocus) return;\n    root.current?.querySelector<HTMLButtonElement>(`[data-index=\"${focusIndex}\"]`)?.focus({ preventScroll: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [autoFocus]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const buttons = Array.from(root.current?.querySelectorAll<HTMLButtonElement>(\"button[data-index]\") ?? []);\n    const current = buttons.findIndex(b => b === document.activeElement);\n    const move = (next: number) => { event.preventDefault(); const i = (next + buttons.length) % buttons.length; setFocusIndex(i); buttons[i]?.focus(); };\n    if (event.key === \"Escape\") { event.preventDefault(); onClose?.(); }\n    else if (event.key === \"ArrowRight\") move((current < 0 ? focusIndex : current) + 1);\n    else if (event.key === \"ArrowLeft\") move((current < 0 ? focusIndex : current) - 1);\n    else if (event.key === \"Home\") move(0);\n    else if (event.key === \"End\") move(buttons.length - 1);\n    else if (layout === \"macos\" && event.key === \"ArrowDown\") move((current < 0 ? focusIndex : current) + 6);\n    else if (layout === \"macos\" && event.key === \"ArrowUp\") move((current < 0 ? focusIndex : current) - 6);\n  }\n\n  const glyphButton = (item: (typeof items)[number], index: number, size: number, slot: number, extra?: CSSProperties) => {\n    const active = isSelected(selected, item.type, item.emoji);\n    return (\n      <button key={item.type ?? item.emoji} type=\"button\" role=\"menuitemradio\" aria-checked={active} aria-label={item.label} data-index={index} data-slot=\"tapback-option\" data-selected={active || undefined}\n        tabIndex={index === focusIndex ? 0 : -1}\n        onClick={() => onSelect?.(item.type ? { type: item.type } : { emoji: item.emoji! })}\n        onFocus={() => setFocusIndex(index)}\n        className=\"relative flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-blue-500\"\n        style={{ width: slot, height: \"100%\", ...extra, ...glyphStyle?.(index) }}>\n        {active && layout === \"ios\" && <span aria-hidden=\"true\" data-slot=\"tapback-selected-ring\" style={{ position: \"absolute\", width: tapbackBarMetrics.ios.selectedRing, height: tapbackBarMetrics.ios.selectedRing, borderRadius: \"50%\", background: \"var(--im-tapback-ring, \" + tapbackColors.selectedRing + \")\" }} />}\n        {active && layout === \"macos\" && <span aria-hidden=\"true\" data-slot=\"tapback-selected-ring\" style={{ position: \"absolute\", width: 30, height: 30, borderRadius: \"50%\", background: \"var(--im-tapback-ring, \" + tapbackColors.selectedRing + \")\" }} />}\n        <TapbackGlyph type={item.type} emoji={item.emoji} size={size} onAccent={active} style={{ position: \"relative\" }} />\n      </button>\n    );\n  };\n\n  if (layout === \"macos\") {\n    const m = tapbackBarMetrics.macos;\n    const classics = items.slice(0, 6);\n    const emoji = items.slice(6, 11);\n    // The macOS bar is the header of a `role=\"menu\"` context menu, so it is a group inside that menu\n    // rather than a second menu: `menuitemradio` needs a menu ancestor, and a nested `menu` would be\n    // reported as a submenu that has no parent item.\n    return (\n      <div ref={root} role=\"group\" aria-label=\"Tapback\" data-slot=\"tapback-bar\" data-layout=\"macos\" className={cn(\"select-none\", className)} onKeyDown={onKeyDown}\n        style={{ fontFamily: fontStack, paddingInline: m.inset, ...style }}>\n        <div data-slot=\"tapback-row\" style={{ display: \"flex\", height: m.row1, alignItems: \"center\" }}>\n          {classics.map((item, i) => glyphButton(item, i, m.glyph, m.slot, { height: m.rowHeight }))}\n        </div>\n        <div data-slot=\"tapback-row\" style={{ display: \"flex\", height: m.row2, alignItems: \"center\" }}>\n          {emoji.map((item, i) => glyphButton(item, i + 6, m.emojiSize, m.slot, { height: m.rowHeight }))}\n          <button type=\"button\" role=\"menuitem\" aria-label=\"More emoji\" data-index={11} data-slot=\"tapback-option\" tabIndex={focusIndex === 11 ? 0 : -1} onFocus={() => setFocusIndex(11)} onClick={onPickEmoji}\n            className=\"flex shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-blue-500\" style={{ width: m.slot, height: m.rowHeight }}>\n            {/* 18.6 is the box whose r 9.5 + 1.6 stroke draws the capture's 17.5 square outline. */}\n            <SmileyIcon size={18.6} color=\"currentColor\" strokeWidth={1.6} />\n          </button>\n        </div>\n        {children}\n      </div>\n    );\n  }\n\n  const m = tapbackBarMetrics.ios;\n  const height = m.height;\n  const glass: CSSProperties = {\n    background: \"var(--im-glass, rgba(255,255,255,0.635))\",\n    backdropFilter: \"var(--im-glass-filter, blur(9px) brightness(1.32) saturate(1.35))\", WebkitBackdropFilter: \"var(--im-glass-filter, blur(9px) brightness(1.32) saturate(1.35))\",\n    boxShadow: \"var(--im-glass-shadow, 0 6px 24px rgba(0,0,0,0.10)), inset 0 0 0 0.5px var(--im-glass-rim, rgba(255,255,255,0.55))\",\n  };\n  return (\n    <div ref={root} role=\"menu\" aria-label=\"Tapback\" data-slot=\"tapback-bar\" data-layout=\"ios\" className={cn(\"select-none\", className)} onKeyDown={onKeyDown}\n      style={{ position: \"relative\", height, width, borderRadius: height / 2, fontFamily: fontStack, ...style }}>\n      <div data-slot=\"tapback-pill\" style={{ position: \"absolute\", inset: 0, borderRadius: height / 2, ...glass }} />\n      <div data-slot=\"tapback-scroll\" className=\"scrollbar-none\" style={{ position: \"absolute\", inset: 0, borderRadius: height / 2, overflowX: \"auto\", overflowY: \"hidden\", display: \"flex\", alignItems: \"center\", paddingLeft: m.firstCenter - m.slot / 2, scrollbarWidth: \"none\" }}>\n        {items.map((item, i) => glyphButton(item, i, item.type ? m.glyph : m.emojiSize, m.slot))}\n        <span aria-hidden=\"true\" style={{ flex: \"none\", width: m.firstCenter - m.slot / 2 }} />\n      </div>\n      {pickerX !== undefined && (\n        <EmojiPickerBubble side={pickerSide} onClick={onPickEmoji} fill=\"var(--im-glass-solid, #ededef)\" iconColor=\"var(--im-picker-icon, #aeaeb2)\"\n          role=\"menuitem\" index={items.length} tabIndex={focusIndex === items.length ? 0 : -1} onFocus={() => setFocusIndex(items.length)}\n          glass={{ boxShadow: \"var(--im-glass-shadow, 0 6px 24px rgba(0,0,0,0.10))\" }} iconStyle={{ marginTop: -1.5 }}\n          clipTop={pickerBalloonGeometry.main / 2 - m.pickerDrop}\n          style={{ left: pickerX - pickerBalloonGeometry.main / 2, top: height + m.pickerDrop - pickerBalloonGeometry.main / 2, ...(glyphStyle?.(-1) ?? {}) }} />\n      )}\n      {children}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/tapback-bar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "context-menu",
      "title": "Context menu",
      "description": "iOS tinted message menu and the macOS NSMenu-style menu with Tapback rows.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/context-menu.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * Message context menus, measured from iOS 26 (`references/ios/captures/longpress-ok-light.png`, 3x)\n * and macOS 26 (`references/macos/captures/ctxmenu-light.png`, 2x).\n *\n * iOS: 250 wide (y 583.5–771.3 in the capture), 10 padding above and below 42-tall rows, 17pt labels at x 60, icons centered at\n * x 36.2, Apple continuous corners of radius 32 (the corner profile fits the iOS 7 squircle curve at\n * 1.0 pt rmse; a circle of any radius is 2+ pt off), glass over the dimmed list (the bubble beneath\n * shows through as the tint).\n * macOS: 302 wide, 0.5 border, 24-tall rows, 13pt labels at x 40.5, icons centered at\n * x 25.25, 1pt separators inset 16 inside an 11pt block, an optional header slot for the tapback rows.\n * Corner radius 12.6, not the 10 this used to carry: tracing the menu's outer edge in `ctxmenu-dark.png`\n * fits a circle of 12.55 pt bottom-right, 12.60 bottom-left and 12.70 top-right (rmse 0.39 pt), and the\n * same trace on `ctxmenu-with-edit-light-2x.png` fits 13.05. Sweeping the radius against the captures\n * agrees: fitting a circle to the 0.5 pt dark outline, the one feature that coincides with our render\n * to 0.1 px along the straight edges, gives 12.41/12.42/12.43 on `ctxmenu-light.png` and\n * 12.29/12.30/12.31 on `ctxmenu-dark.png` (rmse 0.06), and a radius sweep pixel-matched against three\n * 20 pt corner crops bottoms out at 11.95-12.1. The top-left corner has to be left out of any such\n * sweep: a bubble sits behind the glass there in both captures.\n *\n * NOT MEASURED: the highlighted row. No capture in `references/` contains one. The gray band across\n * the lower half of `ctxmenu-with-edit-light-2x.png` looks like a highlight and is not: it is the\n * blurred scene behind the translucent menu. It begins mid-way up the row above the separator and\n * crosses the separator with no step, which a row highlight cannot do. So the highlight here is the\n * inset AppKit shape (4 pt in from each side, radius 6) rather than a full-bleed one, and its colour\n * is the #3478f6 the sidebar's selected row was measured at. Both are unverified.\n */\nexport const contextMenuMetrics = {\n  ios: { width: 250, row: 42, padding: 10, radius: 32, fontSize: 17, textX: 60, iconCenter: 36.2, iconBox: 28, highlightInset: 5, highlightInsetY: 1, highlightRadius: 10 },\n  macos: { width: 302, row: 24, padding: 4, paddingBottom: 5, radius: 12, fontSize: 13, textX: 40.5, iconCenter: 25.25, iconBox: 18, separatorBlock: 11, highlightInset: 4, highlightInsetY: 0, highlightRadius: 6 },\n} as const;\n\n/**\n * Apple's continuous (\"squircle\") corner as an SVG path for a w×h rectangle, using the iOS 7 icon\n * approximation (three cubics per corner, tangent length 1.528665·r).\n */\nexport function continuousRoundedRectPath(w: number, h: number, r: number): string {\n  const R = Math.min(r, w / 3.06, h / 3.06);\n  const a = 1.528665 * R, b = 1.08849 * R, c = 0.868407 * R, d = 0.631494 * R, e = 0.074911 * R, f = 0.372824 * R, g = 0.16906 * R;\n  const n = (v: number) => Number(v.toFixed(3));\n  return [\n    `M0,${n(a)}`,\n    `C0,${n(b)} 0,${n(c)} ${n(e)},${n(d)}`, `C${n(g)},${n(f)} ${n(f)},${n(g)} ${n(d)},${n(e)}`, `C${n(c)},0 ${n(b)},0 ${n(a)},0`,\n    `L${n(w - a)},0`,\n    `C${n(w - b)},0 ${n(w - c)},0 ${n(w - d)},${n(e)}`, `C${n(w - f)},${n(g)} ${n(w - g)},${n(f)} ${n(w - e)},${n(d)}`, `C${n(w)},${n(c)} ${n(w)},${n(b)} ${n(w)},${n(a)}`,\n    `L${n(w)},${n(h - a)}`,\n    `C${n(w)},${n(h - b)} ${n(w)},${n(h - c)} ${n(w - e)},${n(h - d)}`, `C${n(w - g)},${n(h - f)} ${n(w - f)},${n(h - g)} ${n(w - d)},${n(h - e)}`, `C${n(w - c)},${n(h)} ${n(w - b)},${n(h)} ${n(w - a)},${n(h)}`,\n    `L${n(a)},${n(h)}`,\n    `C${n(b)},${n(h)} ${n(c)},${n(h)} ${n(d)},${n(h - e)}`, `C${n(f)},${n(h - g)} ${n(g)},${n(h - f)} ${n(e)},${n(h - d)}`, `C0,${n(h - c)} 0,${n(h - b)} 0,${n(h - a)}`,\n    \"Z\",\n  ].join(\" \");\n}\n\n/** Height of an iOS context menu for a set of items (10 + 42 per item + 0.5 per separator + 10). */\nexport function iosContextMenuHeight(items: ContextMenuItem[]): number {\n  const m = contextMenuMetrics.ios;\n  const rows = items.filter(item => !(\"separator\" in item)).length;\n  return m.padding * 2 + m.row * rows + (items.length - rows) * 0.5;\n}\n\nexport type ContextMenuItem = { id: string; label: string; icon?: MenuIconName | ReactNode; destructive?: boolean; disabled?: boolean } | { separator: true };\nexport type MenuIconName = \"copy\" | \"translate\" | \"select\" | \"more\" | \"tapback-details\" | \"reply\" | \"sticker\" | \"forward\" | \"delete\" | \"clock\";\n\nexport const iosMessageMenu: ContextMenuItem[] = [\n  { id: \"copy\", label: \"Copy\", icon: \"copy\" },\n  { id: \"translate\", label: \"Translate\", icon: \"translate\" },\n  { id: \"select\", label: \"Select\", icon: \"select\" },\n  { id: \"more\", label: \"More…\", icon: \"more\" },\n];\nexport const macosMessageMenu: ContextMenuItem[] = [\n  { id: \"tapback-details\", label: \"Tapback Details…\", icon: \"tapback-details\" },\n  { id: \"reply\", label: \"Reply…\", icon: \"reply\" },\n  { id: \"sticker\", label: \"Attach Sticker…\", icon: \"sticker\" },\n  { separator: true },\n  { id: \"forward\", label: \"Forward…\", icon: \"forward\" },\n  { id: \"copy\", label: \"Copy\", icon: \"copy\" },\n  { separator: true },\n  { id: \"delete\", label: \"Delete…\", icon: \"delete\" },\n  { separator: true },\n  { id: \"show-times\", label: \"Show Times\", icon: \"clock\" },\n];\n\n/** SF-Symbol look-alikes drawn as strokes; `size` is the icon box, glyphs are proportioned like the originals. */\nexport function MenuIcon({ name, size = 22, style }: { name: MenuIconName; size?: number; style?: CSSProperties }) {\n  const s = { fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.55, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const };\n  const common = { width: size, height: size, viewBox: \"0 0 22 22\", \"aria-hidden\": true as const, style: { display: \"block\", ...style } };\n  switch (name) {\n    case \"copy\": return (\n      <svg {...common}><path {...s} d=\"M8.2,6.4 V3.6 A1.4,1.4 0 0 1 9.6,2.2 H14.3 L18.4,6.3 V13.2 A1.4,1.4 0 0 1 17,14.6 H14.2\" /><path {...s} d=\"M14.2,2.4 V6.4 H18.2\" /><path {...s} d=\"M4.9,7.4 H10.6 L14.2,11 V18.4 A1.4,1.4 0 0 1 12.8,19.8 H4.9 A1.4,1.4 0 0 1 3.5,18.4 V8.8 A1.4,1.4 0 0 1 4.9,7.4 Z\" /><path {...s} d=\"M10.4,7.6 V11.2 H14\" /></svg>\n    );\n    case \"translate\": return (\n      <svg {...common} viewBox=\"0 0 26 20\" width={size * 29 / 22} height={size * 22 / 22}>\n        <path {...s} d=\"M2.2,2.8 A1.4,1.4 0 0 1 3.6,1.4 H12.4 A1.4,1.4 0 0 1 13.8,2.8 V9.2 A1.4,1.4 0 0 1 12.4,10.6 H6.2 L3.4,13 V10.6 H3.6 A1.4,1.4 0 0 1 2.2,9.2 Z\" />\n        <path {...s} d=\"M5.4,8.4 L8,3.6 L10.6,8.4 M6.2,6.9 H9.8\" strokeWidth={1.35} />\n        <path d=\"M11.6,9.6 H22.4 A1.4,1.4 0 0 1 23.8,11 V17.2 A1.4,1.4 0 0 1 22.4,18.6 H19.4 L16.8,20.6 V18.6 H13 A1.4,1.4 0 0 1 11.6,17.2 Z\" fill=\"currentColor\" />\n        <path d=\"M14.4,12.3 H20.6 M17.5,11.2 V12.3 M15.4,12.3 C15.9,14.6 17.4,16 19.6,16.9 M19.6,12.3 C19,14.7 17.4,16.2 15.3,17\" fill=\"none\" stroke=\"var(--im-menu-bg, #edeff1)\" strokeWidth={0.95} strokeLinecap=\"round\" />\n      </svg>\n    );\n    case \"select\": return (\n      <svg {...common}><path {...s} d=\"M6.4,1.8 V15.6 A1.4,1.4 0 0 0 7.8,17 H17.4\" /><path {...s} d=\"M4.6,6.6 H15.2 A1.4,1.4 0 0 1 16.6,8 V20.2\" /><circle cx=\"6.4\" cy=\"1.8\" r=\"1.35\" fill=\"currentColor\" /><circle cx=\"16.6\" cy=\"20.2\" r=\"1.35\" fill=\"currentColor\" /></svg>\n    );\n    case \"more\": return (\n      <svg {...common}><circle {...s} cx=\"11\" cy=\"11\" r=\"8.3\" /><circle cx=\"7\" cy=\"11\" r=\"1.15\" fill=\"currentColor\" /><circle cx=\"11\" cy=\"11\" r=\"1.15\" fill=\"currentColor\" /><circle cx=\"15\" cy=\"11\" r=\"1.15\" fill=\"currentColor\" /></svg>\n    );\n    case \"tapback-details\": return (\n      <svg {...common}><circle {...s} cx=\"9.6\" cy=\"9.6\" r=\"6.8\" /><path {...s} d=\"M14.6,14.6 L19.6,19.6\" strokeWidth={2} /><path {...s} d=\"M9.6,6.4 V12.8 M6.4,9.6 H12.8\" /></svg>\n    );\n    case \"reply\": return (\n      <svg {...common}><path {...s} d=\"M9.4,4.6 L3.2,10.2 L9.4,15.8 V12.3 C13.6,12.3 16.8,13.6 19,17.6 C18.6,12.4 15.6,8.2 9.4,8.1 Z\" /></svg>\n    );\n    case \"forward\": return (\n      <svg {...common}><path {...s} d=\"M12.6,4.6 L18.8,10.2 L12.6,15.8 V12.3 C8.4,12.3 5.2,13.6 3,17.6 C3.4,12.4 6.4,8.2 12.6,8.1 Z\" /></svg>\n    );\n    case \"sticker\": return (\n      <svg {...common}><path {...s} d=\"M11,3.2 A8,8 0 1 0 19,11.2 L18.6,11.2 A7.4,7.4 0 0 1 11,3.6 Z\" /><path {...s} d=\"M11.6,3 L19.2,10.6\" /><path {...s} d=\"M6,7.4 A5.3,5.3 0 0 0 5,13\" /><path {...s} d=\"M17.2,14.2 V19.4 M14.6,16.8 H19.8\" strokeWidth={1.8} /></svg>\n    );\n    case \"delete\": return (\n      <svg {...common}><path {...s} d=\"M4.2,6.2 H17.8 M8.6,6.2 V4.4 A1.2,1.2 0 0 1 9.8,3.2 H12.2 A1.2,1.2 0 0 1 13.4,4.4 V6.2\" /><path {...s} d=\"M5.6,6.2 L6.6,17.6 A1.6,1.6 0 0 0 8.2,19 H13.8 A1.6,1.6 0 0 0 15.4,17.6 L16.4,6.2\" /><path {...s} d=\"M9.2,9.2 L9.6,16 M12.8,9.2 L12.4,16\" /></svg>\n    );\n    case \"clock\": return (\n      <svg {...common}><circle {...s} cx=\"11\" cy=\"11\" r=\"8.3\" /><path {...s} d=\"M11,6.2 V11.3 L14.4,13.3\" /></svg>\n    );\n  }\n}\n\nexport type ContextMenuProps = {\n  variant?: \"ios\" | \"macos\";\n  items: ContextMenuItem[];\n  onAction?: (id: string) => void;\n  onClose?: () => void;\n  /** Rendered above the items (macOS: the two tapback rows). */\n  header?: ReactNode;\n  /**\n   * iOS: a color painted at the top of the menu, fading to the neutral fill. Native gets this from the\n   * bubble showing through the glass; pass one when the menu floats over a plain background.\n   */\n  tint?: string;\n  /**\n   * iOS: a faint wash of the bubble color over the whole menu (native glass carries a little of the\n   * bubble's color far below the blurred edge). Pass the bubble's fill color.\n   */\n  wash?: string;\n  /** macOS: menu width; iOS is fixed at 250. */\n  width?: number;\n  autoFocus?: boolean;\n  /** Flip to false to play the dismissal; `onExited` fires when it is over. */\n  open?: boolean;\n  onExited?: () => void;\n  className?: string;\n  style?: CSSProperties;\n  \"aria-label\"?: string;\n};\n\nexport function ContextMenu({ variant = \"ios\", items, onAction, onClose, header, tint, wash, width, open = true, onExited, autoFocus = false, className, style, \"aria-label\": ariaLabel = \"Message actions\" }: ContextMenuProps) {\n  const root = useRef<HTMLDivElement>(null);\n  // Native menus fade and settle back rather than vanishing; keep the element mounted for it.\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n  useEffect(() => {\n    if (open) return;\n    const element = root.current;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n    if (!element || reduced) { exited.current?.(); return; }\n    const animation = element.animate(\n      [{ opacity: 1, transform: \"scale(1)\" }, { opacity: 0, transform: \"scale(0.94)\" }],\n      { duration: 120, easing: \"cubic-bezier(0.4, 0, 1, 1)\", fill: \"both\" },\n    );\n    animation.finished.then(() => exited.current?.()).catch(() => {});\n    return () => { try { animation.cancel(); } catch { /* already gone */ } };\n  }, [open]);\n  const m = contextMenuMetrics[variant];\n\n  // Opened for the keyboard: focus the first item, and hand focus back to whatever opened the menu\n  // when it goes. A layout effect, so the cleanup runs while the menu is still in the DOM.\n  useLayoutEffect(() => {\n    if (!autoFocus) return;\n    const menu = root.current;\n    const opener = document.activeElement as HTMLElement | null;\n    // The first item of any kind, so a menu whose header is the Tapback row starts on a tapback.\n    menu?.querySelector<HTMLButtonElement>('[role=\"menuitem\"]:not([aria-disabled=\"true\"]), [role=\"menuitemradio\"]:not([aria-disabled=\"true\"]), [role=\"menuitemcheckbox\"]:not([aria-disabled=\"true\"])')?.focus({ preventScroll: true });\n    return () => {\n      const active = document.activeElement;\n      const inside = active instanceof Node && menu?.contains(active);\n      if (inside && opener?.isConnected) opener.focus({ preventScroll: true });\n    };\n  }, [autoFocus]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (event.key === \"Escape\") { event.preventDefault(); onClose?.(); return; }\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    // The macOS menu carries the Tapback bar in its header, and that bar owns its own arrow, Home and\n    // End behaviour. Let a key that started inside it through instead of moving the menu's focus.\n    if ((event.target as HTMLElement | null)?.closest?.('[data-slot=\"tapback-bar\"]')) return;\n    const buttons = Array.from(root.current?.querySelectorAll<HTMLButtonElement>(\"[role=menuitem]:not([aria-disabled=true])\") ?? []);\n    if (!buttons.length) return;\n    event.preventDefault();\n    const i = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? buttons.length - 1 : event.key === \"ArrowDown\" ? (i + 1) % buttons.length : (i - 1 + buttons.length) % buttons.length;\n    buttons[next]?.focus();\n  }\n\n  // Half of a separator's 11pt block, i.e. the bare gap the row above or below it has to fill.\n  const separatorGap = (contextMenuMetrics.macos.separatorBlock - 1) / 2;\n\n  // The highlight is a child rather than the button's own background so it can sit inset the way\n  // AppKit draws it. Driving its opacity from CSS keeps it out of React state on every pointer move.\n  const highlightStyle = `[data-slot=\"menu-item\"] > [data-slot=\"menu-highlight\"]{opacity:0;transition:opacity 60ms linear}\n[data-slot=\"menu-item\"]:hover > [data-slot=\"menu-highlight\"],[data-slot=\"menu-item\"]:focus-visible > [data-slot=\"menu-highlight\"]{opacity:1}\n[data-slot=\"menu-item\"][aria-disabled=\"true\"] > [data-slot=\"menu-highlight\"]{opacity:0}`;\n\n  const rows = items.map((item, index) => {\n    if (\"separator\" in item) {\n      return <div key={`sep-${index}`} role=\"separator\" data-slot=\"menu-separator\" style={variant === \"macos\"\n        ? { height: 1, marginBlock: separatorGap, marginInline: 16, background: \"var(--im-menu-separator, #dfe0e2)\" }\n        : { height: 0.5, background: \"var(--im-menu-separator, rgba(0,0,0,0.12))\" }} />;\n    }\n    const gapTop = 0;\n    const gapBottom = 0;\n    return (\n      <button key={item.id} type=\"button\" role=\"menuitem\" aria-disabled={item.disabled || undefined} tabIndex={item.disabled ? -1 : undefined} data-slot=\"menu-item\" data-id={item.id} onClick={() => !item.disabled && onAction?.(item.id)}\n        className={cn(\"flex w-full cursor-default items-center border-0 bg-transparent text-left outline-none\",\n          item.destructive ? \"text-[var(--im-menu-destructive,#ff3b30)]\" : \"text-[var(--im-menu-text,#000)]\",\n          !item.disabled && variant === \"macos\" && \"hover:text-white focus-visible:text-white\")}\n        style={{ height: m.row + gapTop + gapBottom, boxSizing: \"border-box\", paddingTop: gapTop || undefined, paddingBottom: gapBottom || undefined, marginTop: gapTop ? -gapTop : undefined, marginBottom: gapBottom ? -gapBottom : undefined,\n          paddingLeft: m.textX, position: \"relative\", fontFamily: fontStack, fontSize: m.fontSize, lineHeight: `${m.row}px`, letterSpacing: 0, opacity: item.disabled ? 0.4 : 1, width: \"100%\" }}>\n        {/* The highlight is inset and rounded on both platforms, never a full-bleed line. iOS uses a\n            neutral wash inside the menu's own padding; macOS uses the accent. */}\n        <span aria-hidden=\"true\" data-slot=\"menu-highlight\" className=\"pointer-events-none absolute\"\n          style={variant === \"macos\"\n            ? { left: 4, right: 4, top: 0, bottom: 0, borderRadius: 6, background: \"#3478f6\" }\n            : { left: m.highlightInset, right: m.highlightInset, top: m.highlightInsetY, bottom: m.highlightInsetY, borderRadius: m.highlightRadius, background: \"var(--im-menu-highlight, rgba(0,0,0,0.05))\" }} />\n        <span aria-hidden=\"true\" data-slot=\"menu-icon\" style={{ position: \"absolute\", left: m.iconCenter - m.iconBox / 2, top: gapTop, bottom: gapBottom, width: m.iconBox, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n          {typeof item.icon === \"string\" ? <MenuIcon name={item.icon as MenuIconName} size={variant === \"ios\" ? 22 : 16} /> : item.icon}\n        </span>\n        <span data-slot=\"menu-label\" style={{ whiteSpace: \"nowrap\", position: \"relative\" }}>{item.label}</span>\n      </button>\n    );\n  });\n\n  if (variant === \"macos\") {\n    return (\n      <div ref={root} role=\"menu\" aria-label={ariaLabel} data-slot=\"context-menu\" data-variant=\"macos\" className={cn(\"select-none\", className)} onKeyDown={onKeyDown}\n        style={{ width: width ?? m.width, boxSizing: \"border-box\", borderRadius: m.radius, overflow: \"hidden\", position: \"relative\", paddingTop: m.padding, paddingBottom: contextMenuMetrics.macos.paddingBottom, fontFamily: fontStack, color: \"var(--im-menu-text, #242526)\",\n          background: \"var(--im-menu-bg, rgba(247,248,251,0.92))\", backdropFilter: \"blur(30px) saturate(1.6)\", WebkitBackdropFilter: \"blur(30px) saturate(1.6)\",\n          boxShadow: \"0 0 0 0.5px var(--im-menu-border, #b1b1b1), 0 8px 24px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)\", ...style }}>\n        <style>{highlightStyle}</style>\n        {header}\n        {rows}\n        {/* The bright inner rim rides above the rows so a highlight cannot erase it. */}\n        <div aria-hidden=\"true\" data-slot=\"menu-rim\" style={{ position: \"absolute\", inset: 0, borderRadius: \"inherit\", pointerEvents: \"none\", boxShadow: \"inset 0 0 0 0.5px var(--im-menu-rim, rgba(255,255,255,0.7))\" }} />\n      </div>\n    );\n  }\n\n  const background = tint\n    ? `linear-gradient(to bottom, ${tint} 0px, ${tint} 30px, color-mix(in srgb, ${tint} 45%, var(--im-menu-bg, #edeff1)) 47px, color-mix(in srgb, ${tint} 22%, var(--im-menu-bg, #edeff1)) 64px, color-mix(in srgb, ${tint} 8%, var(--im-menu-bg, #edeff1)) 114px, var(--im-menu-bg, #edeff1))`\n    : \"var(--im-menu-glass, rgba(229,229,231,0.69))\";\n  const height = iosContextMenuHeight(items);\n  const outline = continuousRoundedRectPath(m.width, height, m.radius);\n  const shape = `path(\"${outline}\")`;\n  // A `backdrop-filter` is clipped to its element's border box and border radius, never to its\n  // `clip-path`: the glass's brightened backdrop therefore painted a hard square corner outside the\n  // menu's continuous one. Measured on the long-press scene at 3x, 1 device px inside the left edge\n  // and 20 below the top: #ffffff against the #cccbd0 the dimmed list continues past the corner, and\n  // #d9d7dd against #c7c6cb at the bottom right. A mask of the same path IS respected, so the glass\n  // carries the shape twice: the clip for its fill, the mask for the backdrop it filters.\n  const maskShape = `url(\"data:image/svg+xml,${encodeURIComponent(`<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${m.width}\" height=\"${height}\"><path d=\"${outline}\" fill=\"#000\"/></svg>`)}\")`;\n  return (\n    <div ref={root} role=\"menu\" aria-label={ariaLabel} data-slot=\"context-menu\" data-variant=\"ios\" className={cn(\"select-none\", className)} onKeyDown={onKeyDown}\n      style={{ width: m.width, height, boxSizing: \"border-box\", fontFamily: fontStack, position: \"relative\", ...style }}>\n      <style>{highlightStyle}</style>\n      <div aria-hidden=\"true\" data-slot=\"menu-shadow\" style={{ position: \"absolute\", inset: 0, transform: \"translateY(7px)\", filter: \"blur(11px)\", pointerEvents: \"none\" }}>\n        <div style={{ position: \"absolute\", inset: 0, clipPath: shape, background: \"var(--im-menu-shadow, rgba(0,0,0,0.16))\" }} />\n      </div>\n      <div aria-hidden=\"true\" data-slot=\"menu-glass\" style={{ position: \"absolute\", inset: 0, clipPath: shape, background,\n        WebkitMaskImage: maskShape, maskImage: maskShape, WebkitMaskRepeat: \"no-repeat\", maskRepeat: \"no-repeat\",\n        backdropFilter: \"var(--im-menu-glass-filter, blur(9px) brightness(1.32) saturate(1.35))\", WebkitBackdropFilter: \"var(--im-menu-glass-filter, blur(9px) brightness(1.32) saturate(1.35))\" }} />\n      <svg aria-hidden=\"true\" data-slot=\"menu-rim\" width={m.width} height={height} viewBox={`0 0 ${m.width} ${height}`} style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}>\n        <path d={outline} fill=\"none\" stroke=\"var(--im-glass-rim, rgba(255,255,255,0.55))\" strokeWidth={1} />\n      </svg>\n      {wash && <div aria-hidden=\"true\" data-slot=\"menu-wash\" style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", clipPath: shape, background: `linear-gradient(to bottom, transparent 34px, color-mix(in srgb, ${wash} 4.5%, transparent) 52px, color-mix(in srgb, ${wash} 2.5%, transparent) 60%, color-mix(in srgb, ${wash} 1%, transparent))` }} />}\n      {/* A pressed or focused row paints edge to edge, so it has to be cut by the menu's own corner:\n          clip the rows to the same continuous shape the glass uses instead of letting them square it off. */}\n      <div style={{ position: \"absolute\", inset: 0, boxSizing: \"border-box\", paddingBlock: m.padding, clipPath: shape }}>{header}{rows}</div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/context-menu.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-actions",
      "title": "Message actions",
      "description": "The iOS long-press overlay: dim and blur, lifted bubble, Tapback bar, and menu, with the measured transition.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/context-menu.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tapback-bar.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-actions.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { fontStack, type Direction, type Service } from \"@/components/imessage/tokens\";\nimport { BalloonTrail, TapbackGlyph, tapbackLabels, type TapbackType } from \"@/components/imessage/tapback\";\nimport { TapbackBar, tapbackBarMetrics, type TapbackSelection } from \"@/components/imessage/tapback-bar\";\nimport { ContextMenu, contextMenuMetrics, iosContextMenuHeight, iosMessageMenu, type ContextMenuItem } from \"@/components/imessage/context-menu\";\n\n/**\n * The iOS long-press overlay. Layout measured from `references/ios/captures/longpress-*.png`:\n * the pressed bubble is lifted about its trailing edge by a uniform scale that widens it by 26.07,\n * capped at 1.15. Three pressed bubbles fix that, each measured against its own unpressed copy in\n * `conv3-light.png`: \"Ok\" 49.62×40.08 → 57.15×45.96 hits the cap, while the wider two-liners take the\n * same 26: \"Every detail…\" 250.96×60.24 → 277.00×66.43 (1.1037 wide, 1.1027 tall) and \"Aaaa…\"\n * 280.63×60.24 → 306.74×65.82 (1.0930 / 1.0926). A height-driven rule would miss \"Aaaa…\" by 2 pt.\n * Each grows about its own centre, which rises 0.18 (0.135 for \"Ok\", 0.222 for \"Aaaa…\", and the \"Ok\"\n * glyph ink agrees at 0.17). The text rides the scale: that glyph ink also grows 1.148 × 1.132, so\n * `textScale` stays 1. The tapback pill sits 5 above the bubble (measured 5.09) and stretches from\n * the screen margin (10.83) to the bubble's trailing edge, the\n * emoji-picker bubble hangs from the pill with its centre 28 beside the bubble, and the 250-wide menu\n * starts 16.1 below the bubble's own bottom edge, tail included (the 6.8 hang scales with the lift).\n * Six frames give 16.07/16.11/16.07/16.09/16.11/16.2, reading each menu's top off its first row's\n * \"Copy\" ink rather than its washed-out glass edge. If the menu would pass 42 above the frame's\n * bottom (three frames clamp there, all with their menu top at 644.02) or the bar rise above 60, the\n * whole group shifts; incoming bubbles mirror everything (bar 16→391.17, picker to the right, menu\n * left-aligned). `topInset` is the one number here with no capture behind it: nothing in the nine\n * long-press frames pushes the bar high enough to clamp.\n *\n * Motion (60 fps frames in references/ios/motion/): dim from 0 ms; bubble lift + menu unfolding from\n * the bubble's corner over ≈130 ms; the pill expands from the picker circle over ≈200 ms; glyphs fade\n * in left to right with a 17 ms stagger. `progress` (0..1) scrubs the whole timeline.\n */\nexport const messageActionsTiming = { total: 600, exit: 220, dim: 150, lift: 130, menu: 130, picker: [60, 200], bar: [100, 320], glyphStart: 250, glyphStagger: 17, glyphDuration: 120 } as const;\n\nexport const messageActionsMetrics = {\n  /** The lift widens the bubble by this much, up to `liftMaxScale`; height follows the same factor. */\n  liftWidth: 26.07, liftMaxScale: 1.15, liftY: -0.18, barGap: 5, menuGap: 16.1, tailHang: 6.8, pickerBeside: 28, topInset: 60, bottomInset: 42,\n} as const;\n\nexport type Rect = { x: number; y: number; width: number; height: number };\n\n/** Takes the bubble's unscaled body: the lift is driven by its width, not its height. */\nexport function liftScale({ width }: Pick<Rect, \"width\">): number {\n  return Math.min(messageActionsMetrics.liftMaxScale, 1 + messageActionsMetrics.liftWidth / width);\n}\n\nexport type MessageActionsProps = {\n  /** The pressed bubble body's rect (unscaled), in the frame's coordinates. */\n  rect: Rect;\n  frame: { width: number; height: number };\n  direction?: Direction;\n  service?: Service;\n  /** Whether the pressed bubble has a tail (the menu clears it). */\n  tail?: boolean;\n  /** The bubble to lift; rendered at `rect` inside the overlay. */\n  children: ReactNode;\n  items?: ContextMenuItem[];\n  selected?: TapbackSelection;\n  /**\n   * The colour the menu's glass picks up from the message above it. Defaults to that message's own\n   * bubble fill. Pass null for a message that has no bubble to give it: an emoji-only message is a bare\n   * glyph, and a photo, a link card and a file card each carry their own surface instead.\n   *\n   * UNVERIFIED for those kinds. Every long-press capture in `references/ios/` is of a text bubble, so\n   * what native tints the glass with when nothing coloured sits behind it is not measured; plain glass\n   * is the conservative reading of \"the bubble behind it seen through the glass\".\n   */\n  wash?: string | null;\n  recent?: string[];\n  /** Who reacted, for the details popover shown when re-opening a message that already has your tapback. */\n  details?: { initials: string; name?: string };\n  onSelect?: (selection: TapbackSelection) => void;\n  onAction?: (id: string) => void;\n  onPickEmoji?: () => void;\n  onClose?: () => void;\n  /** Scrub position 0..1 of the entrance; omit to play it. */\n  progress?: number;\n  /** Flip to false to play the dismissal; `onExited` fires when it is over. */\n  open?: boolean;\n  onExited?: () => void;\n  /** Move focus into the tapback bar on open (default). */\n  autoFocus?: boolean;\n  /** Override the lift scale (default: `liftScale(rect)`, uniform). */\n  scale?: number | [number, number];\n  /** Counter-scale for the text inside the lifted bubble. Native scales it with the bubble, so 1. */\n  textScale?: number;\n  className?: string;\n  style?: CSSProperties;\n};\n\nexport function layoutMessageActions({ rect, frame, direction, scale, items, tail = false }: { rect: Rect; frame: { width: number; height: number }; direction: Direction; scale: [number, number]; items: ContextMenuItem[]; tail?: boolean }) {\n  const m = messageActionsMetrics;\n  const outgoing = direction === \"outgoing\";\n  const [sx, sy] = scale;\n  // The lift scales the bubble about its trailing edge and nudges it up by `liftY` (0.18, not the\n  // 0.55 this once carried; see the header for the three centres that fix it).\n  const lifted = { width: rect.width * sx, height: rect.height * sy, left: 0, top: rect.y + rect.height / 2 - (rect.height * sy) / 2 + m.liftY };\n  lifted.left = outgoing ? rect.x + rect.width - lifted.width : rect.x;\n  const barH = tapbackBarMetrics.ios.height;\n  const bar = { left: outgoing ? tapbackBarMetrics.ios.edgeInset : rect.x, right: outgoing ? rect.x + rect.width : frame.width - tapbackBarMetrics.ios.edgeInset, top: lifted.top - m.barGap - barH, height: barH };\n  const cm = contextMenuMetrics.ios;\n  const menuHeight = iosContextMenuHeight(items);\n  // The menu clears the bubble's own bottom edge, so a tail pushes it down by its scaled hang.\n  const menu = { left: outgoing ? rect.x + rect.width - cm.width : rect.x, top: lifted.top + lifted.height + m.menuGap + (tail ? m.tailHang * sy : 0), height: menuHeight };\n  // Keep the menu above the composer and the bar below the nav bar; the whole group moves together.\n  let shift = 0;\n  const menuBottom = menu.top + menuHeight;\n  if (menuBottom > frame.height - m.bottomInset) shift = frame.height - m.bottomInset - menuBottom;\n  if (bar.top + shift < m.topInset) shift = m.topInset - bar.top;\n  const pickerCenterX = outgoing ? lifted.left - m.pickerBeside : lifted.left + lifted.width + m.pickerBeside;\n  return { outgoing, lifted, bar, menu, shift, bubbleTranslate: shift + m.liftY, pickerX: pickerCenterX - bar.left };\n}\n\nexport function MessageActions({ rect, frame, direction = \"outgoing\", service = \"imessage\", tail = false, children, items = iosMessageMenu, selected, wash, recent, details, onSelect, onAction, onPickEmoji, onClose, progress, open = true, onExited, autoFocus = true, scale: scaleProp, textScale: textScaleProp, className, style }: MessageActionsProps) {\n  const root = useRef<HTMLDivElement>(null);\n  const scrub = useRef<((time: number | null) => void) | null>(null);\n  const id = useId().replace(/:/g, \"\");\n  const s = scaleProp ?? liftScale(rect);\n  const scale: [number, number] = typeof s === \"number\" ? [s, s] : s;\n  const textScale = textScaleProp ?? 1;\n  const L = layoutMessageActions({ rect, frame, direction, scale, items, tail });\n  const side = L.outgoing ? \"left\" : \"right\";\n\n  useEffect(() => {\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onClose?.(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [onClose]);\n\n  // The overlay is modal, so the message that was pressed gets focus back when the overlay gives it\n  // up (`autoFocus` goes false as the dismissal starts, while the overlay is still mounted).\n  // A passive effect, not a layout one: React restores the pre-commit focus at the end of the commit\n  // phase, so a layout effect's focus() is undone before the frame is painted.\n  const restoreTo = useRef<HTMLElement | null>(null);\n  // Captured in a layout effect (before the bar's own autoFocus, which is a passive effect in a\n  // child) and never overwritten with something inside the overlay, so a re-run cannot record the\n  // glyph the bar just focused as the thing to go back to.\n  useLayoutEffect(() => {\n    if (!autoFocus) return;\n    const active = document.activeElement;\n    if (!(active instanceof Node && root.current?.contains(active))) restoreTo.current = active as HTMLElement | null;\n  }, [autoFocus]);\n  useEffect(() => {\n    if (!autoFocus) return;\n    const overlay = root.current;\n    return () => {\n      const previous = restoreTo.current;\n      const active = document.activeElement;\n      const inside = active instanceof Node && overlay?.contains(active);\n      if (inside && previous?.isConnected) previous.focus({ preventScroll: true });\n    };\n  }, [autoFocus]);\n\n  /** Tab stays inside the overlay while it is open, the way a modal sheet does. */\n  function onRootKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    if (event.key !== \"Tab\") return;\n    const el = root.current;\n    if (!el) return;\n    const stops = Array.from(el.querySelectorAll<HTMLElement>('button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])')).filter(node => node.getClientRects().length > 0);\n    if (!stops.length) return;\n    const first = stops[0], last = stops[stops.length - 1];\n    const active = document.activeElement;\n    const inside = active instanceof Node && el.contains(active);\n    if (event.shiftKey ? active === first || !inside : active === last || !inside) {\n      event.preventDefault();\n      (event.shiftKey ? last : first).focus();\n    }\n  }\n\n  // Build the entrance timeline once with the Web Animations API so it can be played or scrubbed.\n  useLayoutEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const t = messageActionsTiming;\n    const q = <T extends Element>(slot: string) => Array.from(el.querySelectorAll<T>(`[data-slot=\"${slot}\"]`));\n    const one = (slot: string) => el.querySelector<HTMLElement>(`[data-slot=\"${slot}\"]`);\n    const list: Animation[] = [];\n    const add = (target: Element | null, keyframes: Keyframe[], options: KeyframeAnimationOptions) => { if (target) list.push(target.animate(keyframes, { fill: \"both\", ...options })); };\n    const spring = \"cubic-bezier(0.2, 0.95, 0.3, 1)\";\n    add(one(\"backdrop\"), [{ opacity: 0 }, { opacity: 1 }], { duration: t.dim, easing: \"ease-out\" });\n    add(one(\"lifted-bubble\"), [{ transform: \"translateY(0px) scale(1, 1)\" }, { transform: `translateY(${L.bubbleTranslate}px) scale(${scale[0]}, ${scale[1]})` }], { duration: t.lift, easing: spring });\n    // The counter-scale rides a registered custom property so a consumer can animate it; at the native\n    // default of 1 this keyframe is a no-op and the text simply scales with the bubble.\n    try { CSS.registerProperty({ name: \"--im-lift-text\", syntax: \"<number>\", inherits: true, initialValue: \"1\" }); } catch { /* already registered */ }\n    add(one(\"lifted-bubble\"), [{ \"--im-lift-text\": \"1\" } as Keyframe, { \"--im-lift-text\": String(textScale) } as Keyframe], { duration: t.lift, easing: spring });\n    add(one(\"context-menu\"), [{ opacity: 0, transform: \"scale(0.6)\" }, { opacity: 1, transform: \"scale(1)\" }], { duration: t.menu, easing: spring });\n    add(one(\"emoji-picker-bubble\"), [{ opacity: 0, transform: \"scale(0.35)\" }, { opacity: 1, transform: \"scale(1)\" }], { duration: t.picker[1] - t.picker[0], delay: t.picker[0], easing: spring });\n    const pill = one(\"tapback-pill\");\n    if (pill) {\n      const h = L.bar.height;\n      const pickerLeft = L.pickerX - 22, pickerRight = L.bar.right - L.bar.left - (L.pickerX + 22);\n      // The pill grows out of the picker circle, so it starts clipped to that circle on either side.\n      const start = `inset(${(h - 44) / 2}px ${Math.max(0, pickerRight)}px ${(h - 44) / 2}px ${Math.max(0, pickerLeft)}px round 22px)`;\n      add(pill, [{ clipPath: start, opacity: 0.6 }, { clipPath: `inset(0px 0px 0px 0px round ${h / 2}px)`, opacity: 1 }], { duration: t.bar[1] - t.bar[0], delay: t.bar[0], easing: spring });\n    }\n    q<HTMLElement>(\"tapback-option\").forEach((glyph, i) => add(glyph, [{ opacity: 0, transform: \"translateX(6px) scale(0.8)\" }, { opacity: 1, transform: \"translateX(0) scale(1)\" }], { duration: t.glyphDuration, delay: t.glyphStart + i * t.glyphStagger, easing: \"ease-out\" }));\n    add(one(\"tapback-details\"), [{ opacity: 0, transform: \"translateY(-8px) scale(0.9)\" }, { opacity: 1, transform: \"translateY(0) scale(1)\" }], { duration: 200, delay: 200, easing: spring });\n    // A held (paused or filling) animation on the menu promotes it to its own compositing layer, which\n    // cuts the backdrop its glass filters and leaves the panel flat and dark. Every layer's resting\n    // style already is the end state, so drop the animations once the timeline is over.\n    const settle = (a: Animation) => { try { a.cancel(); } catch { /* already gone */ } };\n    scrub.current = time => {\n      for (const a of list) {\n        if (time === null) { a.play(); a.finished.then(() => settle(a)).catch(() => {}); }\n        else if (time >= t.total) settle(a);\n        else { a.pause(); a.currentTime = time; }\n      }\n    };\n    return () => { list.forEach(a => a.cancel()); scrub.current = null; };\n    // The timeline depends on the final layout only; a new layout remounts via `key` upstream.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    if (progress !== undefined) scrub.current?.(Math.min(1, Math.max(0, progress)) * messageActionsTiming.total);\n    else if (reduced) scrub.current?.(messageActionsTiming.total);\n    else scrub.current?.(null);\n  }, [progress]);\n\n  // Dismissal. The entrance animations are cancelled once they settle (so the glass keeps its\n  // backdrop), so this is a fresh, shorter timeline that folds everything back toward the bubble.\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n  useEffect(() => {\n    if (open) return;\n    const overlay = root.current;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    if (!overlay || reduced) { exited.current?.(); return; }\n    const pick = (slot: string) => Array.from(overlay.querySelectorAll<HTMLElement>(`[data-slot=\"${slot}\"]`));\n    const shrink = [...pick(\"context-menu\"), ...pick(\"tapback-pill\"), ...pick(\"emoji-picker-bubble\"), ...pick(\"tapback-details\")];\n    const D = messageActionsTiming.exit;\n    const timing: KeyframeAnimationOptions = { duration: D, easing: \"cubic-bezier(0.4, 0, 1, 1)\", fill: \"both\" };\n    const running = [\n      ...pick(\"backdrop\").map(el => el.animate([{ opacity: 1 }, { opacity: 0 }], timing)),\n      ...shrink.map(el => el.animate([{ opacity: 1, transform: \"scale(1)\" }, { opacity: 0, transform: \"scale(0.72)\" }], timing)),\n      ...pick(\"lifted-bubble\").map(el => el.animate([{ transform: getComputedStyle(el).transform }, { transform: \"none\" }], { ...timing, easing: \"cubic-bezier(0.3, 0, 0.2, 1)\" })),\n    ];\n    let done = false;\n    const finish = () => { if (!done) { done = true; exited.current?.(); } };\n    if (!running.length) { finish(); return; }\n    Promise.allSettled(running.map(a => a.finished)).then(finish);\n    return () => running.forEach(a => { try { a.cancel(); } catch { /* already gone */ } });\n  }, [open]);\n\n  return (\n    // z-20 clears the reaction balloons the list paints at z-10, so they dim and blur with everything else.\n    <div ref={root} data-slot=\"message-actions\" data-direction={direction} role=\"dialog\" aria-modal=\"true\" aria-label=\"Message options\"\n      onKeyDown={onRootKeyDown} className={cn(\"absolute inset-0 z-20 select-none outline-none\", className)} style={{ fontFamily: fontStack, ...style }}>\n      {/* The lift is a transform, so the bubble's own shrink-to-fit measures its line boxes in scaled\n          screen pixels and would set a frame that is `scale` too wide. Pin it to the measured body. */}\n      <style>{`[data-actions=\"${id}\"] [data-slot=\"bubble\"] > span:last-child { display: inline-block; transform-origin: 50% 50%; transform: scale(var(--im-lift-text, 1)); }\n[data-actions=\"${id}\"] [data-slot=\"bubble-frame\"] { max-width: ${rect.width}px !important; }`}</style>\n      <div data-slot=\"backdrop\" aria-hidden=\"true\" onClick={onClose} style={{ position: \"absolute\", inset: 0, background: \"var(--im-dim, rgba(22,18,44,0.21))\" }} />\n      {/* The lifted bubble casts its own shadow onto the dimmed list. Fitted beside the \"Ok\" bubble in\n          `longpress-ok-light.png`, where nothing else contributes: the dim (#ceced2 over white) reads\n          194 at 0.7 pt out, 196 at 4, 198 at 7.3, 201 at 12.3 and 202 at 14, and 188 just under the body.\n          One tight layer carries the edge and one wide layer the falloff; both reproduce within 1/255.\n          `drop-shadow` rather than `box-shadow` so the tail and the reaction balloon cast it too. */}\n      <div data-slot=\"lifted-bubble\" data-actions={id} data-service={service} style={{ position: \"absolute\", left: rect.x, top: rect.y, width: rect.width, height: rect.height, transformOrigin: L.outgoing ? \"100% 50%\" : \"0% 50%\", transform: `translateY(${L.bubbleTranslate}px) scale(${scale[0]}, ${scale[1]})`, filter: \"drop-shadow(0 2px 8px rgba(0,0,0,0.07)) drop-shadow(0 6px 24px rgba(0,0,0,0.13))\", \"--im-lift-text\": String(textScale) } as CSSProperties}>\n        {children}\n      </div>\n      <TapbackBar layout=\"ios\" selected={selected} recent={recent} onSelect={onSelect} onPickEmoji={onPickEmoji} autoFocus={autoFocus}\n        pickerX={L.pickerX} pickerSide={side} width={L.bar.right - L.bar.left}\n        style={{ position: \"absolute\", left: L.bar.left, top: L.bar.top + L.shift }} />\n      <ContextMenu variant=\"ios\" items={items} onAction={onAction}\n        wash={wash === undefined ? (service === \"sms\" ? \"var(--im-green-bottom, #31c355)\" : L.outgoing ? \"var(--im-blue-bottom, #3583f6)\" : \"var(--im-gray-bottom, #e9e9eb)\") : wash ?? undefined}\n        style={{ position: \"absolute\", left: L.menu.left, top: L.menu.top + L.shift, transformOrigin: L.outgoing ? \"100% 0%\" : \"0% 0%\" }} />\n      {selected && details && <TapbackDetails selection={selected} initials={details.initials} name={details.name} style={{ position: \"absolute\", left: frame.width / 2 - tapbackDetailsMetrics.width / 2, top: tapbackDetailsMetrics.top }} />}\n    </div>\n  );\n}\n\n/**\n * The \"tapback details\" popover shown when a message that already carries your reaction is re-opened.\n * Measured on `longpress-ok-selected-light.png` (3x): a 124×121 glass card with continuous corners,\n * centred on the screen at y 66.26. Inside, a Ø49.76 white balloon whose trail points straight DOWN\n * (Ø11.13 at +27.34 and Ø5.18 at +38.0 from its centre) sits with its centre 33.57 below the card's\n * top, and the reactor's Ø30.66 avatar is centred 93.6 below it. The glyph ink is 26.3 wide.\n */\nexport const tapbackDetailsMetrics = {\n  width: 124, height: 121, radius: 30, top: 66.26,\n  balloon: { main: 49.76, medium: 11.13, small: 5.18, mediumOffset: [0, 27.34] as [number, number], smallOffset: [0, 38.0] as [number, number], glyph: 26.6, glyphOffsetY: 1.2 },\n  balloonCenterY: 33.57, avatar: 30.66, avatarCenterY: 93.6,\n} as const;\n\nexport function TapbackDetails({ selection, initials, name, style }: { selection: TapbackSelection; initials: string; name?: string; style?: CSSProperties }) {\n  const m = tapbackDetailsMetrics;\n  const g = m.balloon;\n  return (\n    <div data-slot=\"tapback-details\" role=\"group\" aria-label={`${name ?? initials} reacted with ${\"type\" in selection ? tapbackLabels[selection.type] : selection.emoji}`}\n      style={{ position: \"relative\", width: m.width, height: m.height, borderRadius: m.radius, background: \"var(--im-glass-solid, #ededef)\", boxShadow: \"var(--im-glass-shadow, 0 8px 28px rgba(0,0,0,0.10)), inset 0 0 0 0.5px var(--im-glass-rim, rgba(255,255,255,0.55))\", ...style }}>\n      <div data-slot=\"details-balloon\" style={{ position: \"absolute\", left: m.width / 2 - g.main / 2, top: m.balloonCenterY - g.main / 2, width: g.main, height: g.main, borderRadius: \"50%\", background: \"var(--im-details-balloon, #ffffff)\", display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>\n        <TapbackGlyph type={\"type\" in selection ? selection.type : undefined} emoji={\"emoji\" in selection ? selection.emoji : undefined} size={g.glyph} style={{ marginTop: 2 * g.glyphOffsetY }} />\n        <BalloonTrail geometry={g} side=\"left\" color=\"var(--im-details-balloon, #ffffff)\" />\n      </div>\n      <div aria-hidden=\"true\" data-slot=\"details-avatar\"\n        style={{ position: \"absolute\", left: m.width / 2 - m.avatar / 2, top: m.avatarCenterY - m.avatar / 2, width: m.avatar, height: m.avatar, borderRadius: \"50%\", background: \"linear-gradient(#a8bfe1, #7f8ec1)\", color: \"#fff\", fontSize: 12.5, fontWeight: 600, letterSpacing: 0.2, display: \"flex\", alignItems: \"center\", justifyContent: \"center\" }}>{initials}</div>\n    </div>\n  );\n}\n\nexport type { TapbackType };\n",
          "type": "registry:ui",
          "target": "components/imessage/message-actions.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "use-long-press",
      "title": "Long press hook",
      "description": "A cancelable 500 ms hold with keyboard, double-click, and right-click alternatives.",
      "files": [
        {
          "path": "registry/imessage/use-long-press.ts",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent } from \"react\";\n\nconst reducedMotionQuery = () => (typeof window === \"undefined\" ? null : window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") ?? null);\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = reducedMotionQuery();\n  query?.addEventListener(\"change\", onChange);\n  return () => query?.removeEventListener(\"change\", onChange);\n}\n/** True when the viewer prefers reduced motion; false during server rendering. */\nexport function useReducedMotion(): boolean {\n  return useSyncExternalStore(subscribeReducedMotion, () => reducedMotionQuery()?.matches ?? false, () => false);\n}\n\n/**\n * iOS-style long press (see references/SPEC.md \"iOS long-press\"): 500 ms hold with the bubble\n * scaling up slightly over the hold, cancelled by 8 px of movement. Right-click, double-click and\n * the keyboard (Enter, Space, Shift+F10, ContextMenu) open the same actions immediately.\n */\nexport type LongPressOptions = {\n  onLongPress: (origin: { x: number; y: number; source: \"press\" | \"contextmenu\" | \"dblclick\" | \"keyboard\" }) => void;\n  onCancel?: () => void;\n  /** Hold duration in ms (native ≈ 500). */\n  threshold?: number;\n  /** Pointer travel that cancels the press, in px (native 8). */\n  moveTolerance?: number;\n  /** Scale reached at the end of the hold (a subtle 1.03 native). */\n  holdScale?: number;\n  disabled?: boolean;\n};\n\nexport function useLongPress({ onLongPress, onCancel, threshold = 500, moveTolerance = 8, holdScale = 1.03, disabled = false }: LongPressOptions) {\n  const [holding, setHolding] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const origin = useRef({ x: 0, y: 0 });\n  const fired = useRef(false);\n  const reduced = useReducedMotion();\n\n  const clear = useCallback(() => { if (timer.current) clearTimeout(timer.current); timer.current = null; }, []);\n  const cancel = useCallback(() => { const was = timer.current !== null; clear(); setHolding(false); if (was) onCancel?.(); }, [clear, onCancel]);\n  useEffect(() => () => clear(), [clear]);\n\n  const onPointerDown = useCallback((event: PointerEvent<HTMLElement>) => {\n    if (disabled || event.button !== 0) return;\n    clear(); fired.current = false; origin.current = { x: event.clientX, y: event.clientY };\n    setHolding(true);\n    timer.current = setTimeout(() => {\n      timer.current = null; fired.current = true; setHolding(false);\n      onLongPress({ x: origin.current.x, y: origin.current.y, source: \"press\" });\n    }, threshold);\n  }, [disabled, clear, threshold, onLongPress]);\n\n  const onPointerMove = useCallback((event: PointerEvent<HTMLElement>) => {\n    if (timer.current && Math.hypot(event.clientX - origin.current.x, event.clientY - origin.current.y) > moveTolerance) cancel();\n  }, [moveTolerance, cancel]);\n\n  const onPointerUp = useCallback(() => { if (timer.current) cancel(); else setHolding(false); }, [cancel]);\n  const onContextMenu = useCallback((event: MouseEvent<HTMLElement>) => {\n    if (disabled) return;\n    event.preventDefault(); clear(); setHolding(false);\n    if (!fired.current) onLongPress({ x: event.clientX, y: event.clientY, source: \"contextmenu\" });\n    fired.current = false;\n  }, [disabled, clear, onLongPress]);\n  const onDoubleClick = useCallback((event: MouseEvent<HTMLElement>) => {\n    if (disabled) return;\n    if (!fired.current) onLongPress({ x: event.clientX, y: event.clientY, source: \"dblclick\" });\n    fired.current = false;\n  }, [disabled, onLongPress]);\n  const onKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {\n    if (disabled) return;\n    if (event.key === \"Enter\" || event.key === \" \" || event.key === \"ContextMenu\" || (event.key === \"F10\" && event.shiftKey)) {\n      event.preventDefault();\n      const rect = event.currentTarget.getBoundingClientRect();\n      onLongPress({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, source: \"keyboard\" });\n    }\n  }, [disabled, onLongPress]);\n\n  /** Apply to the pressed element: the slow scale-up over the hold, snapping back on cancel. */\n  const holdStyle: CSSProperties = reduced ? {} : {\n    transform: holding ? `scale(${holdScale})` : \"scale(1)\",\n    transition: holding ? `transform ${threshold}ms cubic-bezier(0.4, 0, 0.9, 0.6)` : \"transform 160ms ease-out\",\n    willChange: holding ? \"transform\" : undefined,\n  };\n\n  return {\n    holding,\n    holdStyle,\n    cancel,\n    handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: cancel, onPointerLeave: cancel, onContextMenu, onDoubleClick, onKeyDown },\n    /** Accessibility attributes for the pressable element. */\n    a11y: { role: \"button\" as const, tabIndex: 0, \"aria-haspopup\": \"menu\" as const },\n  };\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/use-long-press.ts"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-list",
      "title": "Message list",
      "description": "The scrolling log: clusters and tails, date headers, status labels, sender names, emoji-only rows, link cards, attachments, and the typing indicator.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/date-separator.json",
        "https://imessage.swerdlow.dev/r/ios-notices.json",
        "https://imessage.swerdlow.dev/r/link-preview.json",
        "https://imessage.swerdlow.dev/r/message-attachment.json",
        "https://imessage.swerdlow.dev/r/message-audio.json",
        "https://imessage.swerdlow.dev/r/message-bubble.json",
        "https://imessage.swerdlow.dev/r/message-effects.json",
        "https://imessage.swerdlow.dev/r/message-image.json",
        "https://imessage.swerdlow.dev/r/message-reply.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json",
        "https://imessage.swerdlow.dev/r/typing-indicator.json",
        "https://imessage.swerdlow.dev/r/use-screen-space.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-list.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, type Ref, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, emojiFontStack, fontStack, type Direction, type Service } from \"@/components/imessage/tokens\";\nimport { isEmojiOnly, MessageBubble, reactionOffsets } from \"@/components/imessage/message-bubble\";\nimport { DateSeparator, formatClockTime, formatDateLabel, type DateSeparatorVariant } from \"@/components/imessage/date-separator\";\nimport { LinkPreview } from \"@/components/imessage/link-preview\";\nimport { MessageAttachment } from \"@/components/imessage/message-attachment\";\nimport { MessageImages } from \"@/components/imessage/message-image\";\nimport { MessageAudio } from \"@/components/imessage/message-audio\";\nimport { InvisibleInk } from \"@/components/imessage/message-effects\";\nimport { ReplyCount, ReplyStub } from \"@/components/imessage/message-reply\";\nimport { FailedSendBadge, NotDelivered } from \"@/components/imessage/ios-notices\";\nimport { TypingIndicator } from \"@/components/imessage/typing-indicator\";\nimport { useBubbleScreenSpace } from \"@/components/imessage/use-screen-space\";\n\n/**\n * The scrolling message log: clusters consecutive same-sender messages (60 s window, tail on the last\n * bubble only), applies the measured group gaps and edge insets, inserts date headers after a gap of an\n * hour, places the delivery label under the last outgoing message, renders emoji-only messages as big\n * glyphs, link messages as link cards, group-chat sender names, and the typing indicator at the end.\n * Bubble fills are kept in screen space through `useBubbleScreenSpace`.\n */\nexport type MessageStatus = \"sending\" | \"sent\" | \"delivered\" | \"read\" | \"failed\";\nexport type MessageReaction = { type: string; byMe?: boolean; emoji?: string };\nexport type MessageKind = \"text\" | \"link\" | \"attachment\" | \"image\" | \"audio\" | \"typing\";\nexport type MessageLink = { url: string; title?: string; host?: string; image?: string };\nexport type MessageAttachmentInfo = { name: string; size?: string; href?: string };\n\nexport type Message = {\n  id: string;\n  text: string;\n  direction: Direction;\n  service?: Service;\n  sentAt: Date | number;\n  sender?: string;\n  senderInitials?: string;\n  status?: MessageStatus;\n  readAt?: Date | number;\n  edited?: boolean;\n  reactions?: MessageReaction[];\n  kind?: MessageKind;\n  link?: MessageLink;\n  attachments?: MessageAttachmentInfo[];\n  /** Photos or videos, when `kind` is \"image\". */\n  images?: Array<{ src: string; alt: string; width?: number; height?: number }>;\n  /** Voice message, when `kind` is \"audio\". */\n  audio?: { duration: number; peaks?: number[] };\n  /** The message this one replies to. */\n  replyTo?: { id: string; text: string; direction: Direction; service?: Service; sender?: string };\n  /** How many replies hang off this message. */\n  replyCount?: number;\n  /** Sent with a bubble effect. \"invisible-ink\" hides the message until it is revealed. */\n  effect?: \"slam\" | \"loud\" | \"gentle\" | \"invisible-ink\";\n  /** Force the tail on or off. Native draws it only on the last bubble of a cluster; use this to reproduce captures. */\n  tail?: boolean;\n  /** Override the gap above this message (px). Only for reproducing captures; the cluster rule decides otherwise. */\n  gapBefore?: number;\n};\n\nexport type MessageListHandle = {\n  scrollToBottom(behavior?: ScrollBehavior): void;\n  isNearBottom(): boolean;\n  /**\n   * Scroll a message into view and flash it. Returns false when that message is not in this list, so a\n   * shell can fall back to opening the conversation it does live in. `progress` (0..1) seeks the flash\n   * instead of playing it.\n   */\n  jumpTo(id: string, options?: { behavior?: ScrollBehavior; progress?: number }): boolean;\n  readonly element: HTMLDivElement | null;\n};\n\nexport type MessageListMetrics = {\n  /** Space above the first row and below the last (the shell adds the chrome's own insets). */\n  insetTop: number;\n  insetBottom: number;\n  /**\n   * Emoji-only messages: glyph size, line box, side padding, and how far the glyph sits below where Chrome\n   * places it in that box. macOS measured (ink 71.5 x 70.5 = 72pt glyph, 87.3pt line box, ink 4pt from the edge).\n   */\n  emojiSize: number;\n  emojiLineHeight: number;\n  emojiPadX: number;\n  emojiShift: number;\n  /**\n   * Extra space a tail adds under its own bubble when the next message still continues the cluster.\n   * A tail normally ends a cluster, so this only applies when a caller forces `tail` on a message\n   * that is not the last of its group. macOS lets the tail push the next bubble down by its hang;\n   * iOS has no capture of a mid-cluster tail, so it stays 0 there.\n   */\n  tailSpace: number;\n  /** Rows closer than this to the bottom keep auto-scrolling as messages arrive. */\n  nearBottom: number;\n};\n\nexport const messageListMetrics: Record<Platform, MessageListMetrics> = {\n  ios: { insetTop: 0, insetBottom: 0, emojiSize: 58, emojiLineHeight: 70.3, emojiPadX: 2.4, emojiShift: 0.8, tailSpace: 0, nearBottom: 72 },\n  macos: { insetTop: 0, insetBottom: 0, emojiSize: 72, emojiLineHeight: 87.3, emojiPadX: 3, emojiShift: 1, tailSpace: 4.76, nearBottom: 72 },\n};\n\nexport const clusterWindowMs = 60_000;\nexport const dateHeaderGapMs = 3_600_000;\n\n/**\n * Threads: opening one from the list, and jumping from a reply's quoted stub back to the message it\n * quotes.\n *\n * UNVERIFIED. Nothing in `references/` captures a thread or a jump, so the flash a jumped-to message\n * runs is built from the documented behaviour (the message pulses once and settles) and its numbers\n * are plausible, not measured. The two gesture thresholds are not invented: 500 ms and 8 px are the\n * measured hold and travel `use-long-press.ts` uses, repeated here because the list must not depend\n * on that file (it is not one of this component's registry dependencies).\n *\n * Precedence on a bubble that carries a thread as well as the press gestures, first rule that fires\n * wins:\n *\n * 1. A control inside the row takes its own click: the reply count, the stub's jump button, a link\n *    card, an attachment link, the Invisible Ink reveal. The row never second-guesses one of those.\n * 2. A press that reaches the 500 ms hold, a right click, or a double click belongs to the message\n *    actions (the shells bind those on the log itself), so the click that ends one opens no thread.\n *    A press that travelled more than 8 px is a scroll or a text drag, not a tap.\n * 3. What is left is a plain tap, and on a message that has replies it opens the thread, the way\n *    native does. Except in click-to-select mode: there a click selects the message (macOS,\n *    measured), and the reply count stays the way in.\n * 4. Keyboard: the log's arrow keys focus a row, and Enter or Space on it opens its thread. When the\n *    shell has claimed those keys for the actions menu (`messageActions`) they stay with the menu and\n *    the reply count button, which is its own tab stop, is the keyboard route into the thread.\n */\nexport const threadGesture = { hold: 500, moveTolerance: 8 } as const;\n\n/**\n * The flash a message runs when a jump lands on it. Seekable by construction: it is one Web Animations\n * animation on the row, so `document.getAnimations()` reaches it and `flash={{ id, progress }}` pauses\n * and seeks it instead of playing it. UNVERIFIED, see above.\n */\nexport const messageFlash = { duration: 480, peak: 110, scale: 1.045, dip: 0.4, reducedDuration: 220, reducedDip: 0.6 } as const;\n\n/** Same reading as `message-motion`'s, repeated because that file is not a dependency of this one. */\nfunction prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport type MessageListProps = Omit<ComponentProps<\"div\">, \"children\" | \"ref\"> & {\n  messages: Message[];\n  /** Show the typing indicator after the last message. Pass the sender's name for the accessible label. */\n  typing?: boolean | { sender?: string };\n  /** Group conversation: show sender names above incoming clusters. */\n  group?: boolean;\n  /** Reference time for \"Today\"/\"Yesterday\"; defaults to now. */\n  now?: Date | number;\n  /** The element that represents the device screen (for the screen-space bubble fill). Defaults to the list itself. */\n  frameRef?: RefObject<HTMLElement | null>;\n  platform?: Platform;\n  /** Service line shown above the first date header on iOS. Defaults to \"iMessage\", or \"Text Message\" for all-SMS threads. */\n  serviceLabel?: ReactNode | null;\n  /** Render tapback balloons for a message (see tapback.tsx). */\n  renderReactions?: (message: Message) => ReactNode;\n  /** Keep the newest message in view when messages arrive while scrolled near the bottom. */\n  autoScroll?: boolean;\n  /** Show the date header above the first message (native always does; captures of a scrolled list do not). */\n  firstDateHeader?: boolean;\n  /**\n   * The shell opens actions (the long-press overlay, the macOS context menu) on a message. Rows then\n   * advertise that popup to assistive technology; the log's arrow keys reach them either way.\n   */\n  messageActions?: boolean;\n  /**\n   * Ids of the messages a click has selected. Passing this (even empty) turns the rows into a\n   * multi-select listbox and paints the selection overlay on their bubbles. macOS only: iOS has no\n   * click-to-select, it has the checkbox select mode in `ios-select-mode.tsx`.\n   */\n  selectedIds?: readonly string[];\n  /**\n   * Open a message's thread. The reply count under a message calls it with \"reply-count\"; a plain tap\n   * on a message that has replies calls it with \"message\" (see `threadGesture` for the precedence\n   * against the press gestures). Without it the reply count stays the inert label it renders today.\n   */\n  onOpenThread?: (id: string, source: ThreadOpenSource) => void;\n  /**\n   * Jump to the message a reply quotes: activating the stub above a reply calls it. The list scrolls\n   * that message into view and flashes it whenever it is in this list; the callback fires either way,\n   * so a shell can close a thread or switch conversations first. A caller that answers by driving\n   * `flash` itself simply restarts the same flash.\n   */\n  onJumpToMessage?: (id: string) => void;\n  /**\n   * The message whose thread is open, if one is. Only the list can tell the reply count that it is\n   * expanded, so a shell that owns the thread state passes it back here for the announcement.\n   */\n  openThreadId?: string | null;\n  /**\n   * Scroll a message into view and flash it, declaratively. `progress` (0..1) pauses and seeks the\n   * flash rather than playing it, which is what makes a scenario checkpoint reproducible.\n   */\n  flash?: { id: string; progress?: number } | null;\n  /** Where a short conversation sits: under the header (\"top\", native iOS) or against the composer (\"bottom\"). */\n  anchor?: \"top\" | \"bottom\";\n  insetTop?: number;\n  insetBottom?: number;\n  ref?: Ref<MessageListHandle>;\n};\n\n/** Which affordance opened a thread: the count under a message, or the message itself. */\nexport type ThreadOpenSource = \"reply-count\" | \"message\";\n\ntype Row =\n  | { kind: \"date\"; key: string; date: number; service?: ReactNode; variant: DateSeparatorVariant }\n  | { kind: \"message\"; key: string; message: Message; tail: boolean; gap: number; showStatus: boolean; showSender: boolean; emoji: boolean }\n  | { kind: \"typing\"; key: string; gap: number; sender?: string };\n\nconst ms = (v: Date | number) => (typeof v === \"number\" ? v : v.getTime());\n\nfunction startOfDay(v: number) { const d = new Date(v); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); }\n\n/** \"Delivered\", \"Read 9:41 AM\", \"Read Yesterday\", \"Sent as Text Message\". */\nexport function statusLabel(message: Message, now: number): string | undefined {\n  switch (message.status) {\n    case \"delivered\": return \"Delivered\";\n    case \"read\": {\n      if (message.readAt === undefined) return \"Read\";\n      const at = ms(message.readAt);\n      if (startOfDay(at) === startOfDay(now)) return `Read ${formatClockTime(at)}`;\n      return `Read ${formatDateLabel(at, now).day}`;\n    }\n    case \"sent\": return message.service === \"sms\" ? \"Sent as Text Message\" : \"Sent\";\n    default: return undefined;\n  }\n}\n\nexport function buildRows(messages: Message[], options: { platform: Platform; now: number; group: boolean; serviceLabel: ReactNode | null; firstDateHeader: boolean; typing?: { sender?: string } | null }): Row[] {\n  const m = bubbleMetrics[options.platform];\n  const lm = messageListMetrics[options.platform];\n  const rows: Row[] = [];\n  // A tail hangs below its bubble and, on macOS, takes that space from the next bubble in the cluster.\n  // Measured body bottom to next body top: 3.24 / 2.93 / 3.49 / 2.74 with no tail above, 7.74 / 8.10 /\n  // 8.11 / 8.23 with one (conversation-pane-dark.png, -light.png, -dark-2.png, -light-partial.png).\n  let previousTail = false;\n  // The delivery label lives under the newest outgoing message that has one: a message still sending shows\n  // nothing, and the previous bubble keeps its \"Delivered\" until the new one is delivered.\n  const lastOutgoing = messages.reduce((found, message, index) => (message.direction === \"outgoing\" && message.kind !== \"link\" && message.kind !== \"typing\" && statusLabel(message, options.now) !== undefined ? index : found), -1);\n  for (let i = 0; i < messages.length; i++) {\n    const message = messages[i];\n    if (message.kind === \"typing\") continue;\n    const previous = i > 0 ? messages[i - 1] : undefined;\n    const next = i + 1 < messages.length ? messages[i + 1] : undefined;\n    const emoji = message.kind !== \"link\" && isEmojiOnly(message.text);\n    const t = ms(message.sentAt);\n    const needsHeader = previous ? t - ms(previous.sentAt) > dateHeaderGapMs : options.firstDateHeader;\n    // Only the header that opens the conversation carries the service name and its tight top gap; every\n    // later one is a one-line mid-list header with a gap of its own on both sides.\n    if (needsHeader) rows.push({ kind: \"date\", key: `date-${message.id}`, date: t, service: i === 0 ? options.serviceLabel : undefined, variant: i === 0 ? \"first\" : \"mid\" });\n    const continues = (a: Message | undefined, b: Message) =>\n      Boolean(a) && a!.direction === b.direction && (a!.sender ?? \"\") === (b.sender ?? \"\") && ms(b.sentAt) - ms(a!.sentAt) <= clusterWindowMs && ms(b.sentAt) - ms(a!.sentAt) >= 0 && !isEmojiOnly(a!.text) && !isEmojiOnly(b.text) && a!.kind !== \"typing\";\n    const inCluster = !needsHeader && continues(previous, message);\n    const nextInCluster = Boolean(next) && next!.kind !== \"typing\" && continues(message, next!) && ms(next!.sentAt) - t <= dateHeaderGapMs;\n    const tail = message.tail ?? (!emoji && message.kind !== \"link\" && !nextInCluster);\n    // `previousTail` is only ever true here for a caller-forced tail: a derived tail is exactly\n    // `!inCluster` for the row that follows, so the two can never both hold on their own.\n    const gap = message.gapBefore ?? (needsHeader || !previous ? 0 : inCluster ? m.gapInGroup + (previousTail ? lm.tailSpace : 0) : m.gapBetweenGroups);\n    previousTail = tail;\n    rows.push({\n      kind: \"message\", key: message.id, message, tail, gap, emoji,\n      showStatus: i === lastOutgoing,\n      showSender: options.group && message.direction === \"incoming\" && Boolean(message.sender) && !inCluster,\n    });\n  }\n  if (options.typing) rows.push({ kind: \"typing\", key: \"typing\", gap: rows.length ? m.gapBetweenGroups : 0, sender: options.typing.sender });\n  return rows;\n}\n\nfunction EmojiMessage({ message, platform }: { message: Message; platform: Platform }) {\n  const lm = messageListMetrics[platform];\n  const outgoing = message.direction === \"outgoing\";\n  return (\n    <div data-slot=\"message-bubble\" data-direction={message.direction} data-platform={platform} data-emoji-only=\"true\"\n      className={cn(\"flex min-w-0 flex-col\", outgoing ? \"items-end\" : \"items-start\")} style={{ width: \"100%\", fontFamily: fontStack }}>\n      <div data-slot=\"emoji\" style={{ fontSize: lm.emojiSize, lineHeight: `${lm.emojiLineHeight}px`, fontFamily: emojiFontStack, padding: `0 ${lm.emojiPadX}px`, whiteSpace: \"nowrap\", position: \"relative\", top: lm.emojiShift }}>\n        <span className=\"sr-only\">{outgoing ? \"You: \" : `${message.sender ?? \"Contact\"}: `}</span>{message.text.trim()}\n      </div>\n    </div>\n  );\n}\n\nexport function MessageList({\n  messages, typing = false, group = false, now, frameRef, platform: platformProp, serviceLabel, renderReactions, autoScroll = true,\n  firstDateHeader = true, messageActions = false, selectedIds, onOpenThread, onJumpToMessage, openThreadId, flash, anchor = \"top\", insetTop, insetBottom, ref, className, style, onScroll, onKeyDown, ...props\n}: MessageListProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const lm = messageListMetrics[platform];\n  const scroller = useRef<HTMLDivElement>(null);\n  const nearBottom = useRef(true);\n  // \"Today\"/\"Yesterday\" need a clock; read it once so rendering stays pure, and never let it fall behind the newest message.\n  const [mountedAt] = useState(() => Date.now());\n  const nowMs = now === undefined ? messages.reduce((latest, message) => Math.max(latest, ms(message.sentAt)), mountedAt) : ms(now);\n  const typingInfo = useMemo(() => (typing === false ? null : typing === true ? {} : typing), [typing]);\n  const service = serviceLabel === undefined ? (messages.length && messages.every(message => message.service === \"sms\") ? \"Text Message\" : \"iMessage\") : serviceLabel;\n  const rows = useMemo(() => buildRows(messages, { platform, now: nowMs, group, serviceLabel: service, firstDateHeader, typing: typingInfo }), [messages, platform, nowMs, group, service, firstDateHeader, typingInfo]);\n  // A listbox of rows only exists once the shell owns a selection; without it the log keeps the plain\n  // roles it has always had, so iOS and every uncontrolled consumer are untouched.\n  const selectable = selectedIds !== undefined;\n  const selection = useMemo(() => new Set(selectedIds ?? []), [selectedIds]);\n\n  useBubbleScreenSpace(scroller, frameRef);\n\n  const scrollToBottom = useCallback((behavior: ScrollBehavior = \"auto\") => {\n    const el = scroller.current;\n    if (el) el.scrollTo({ top: el.scrollHeight - el.clientHeight, behavior });\n  }, []);\n\n  // One flash at a time: a second jump cancels the first rather than compounding two transforms on\n  // two different rows.\n  const flashing = useRef<Animation | null>(null);\n  const jumpTo = useCallback((id: string, options?: { behavior?: ScrollBehavior; progress?: number }) => {\n    const el = scroller.current;\n    const row = el?.querySelector<HTMLElement>(`[data-slot=\"message-row\"][data-message-id=\"${CSS.escape(id)}\"]`);\n    if (!el || !row) return false;\n    const reduced = prefersReducedMotion();\n    const listBox = el.getBoundingClientRect();\n    const rowBox = row.getBoundingClientRect();\n    // Only scroll when the message is not already fully in view: native does not shuffle the log to\n    // recentre something the reader can see. Scroll this element rather than `scrollIntoView`, which\n    // would also scroll every ancestor, including the page the device frame sits on.\n    if (rowBox.top < listBox.top + 8 || rowBox.bottom > listBox.bottom - 8) {\n      const centred = el.scrollTop + (rowBox.top - listBox.top) - Math.max(0, (el.clientHeight - rowBox.height) / 2);\n      el.scrollTo({\n        top: Math.max(0, Math.min(centred, el.scrollHeight - el.clientHeight)),\n        // A smooth scroll runs on its own clock, so a seeked (scrubbed) jump lands instantly instead.\n        behavior: options?.progress !== undefined || reduced ? \"auto\" : options?.behavior ?? \"smooth\",\n      });\n    }\n    flashing.current?.cancel();\n    const F = messageFlash;\n    const duration = reduced ? F.reducedDuration : F.duration;\n    // The pulse grows from the message's own edge, the side the long-press lift grows from. Every\n    // keyframe carries the origin: a seeked animation never finishes, so nothing restores it later.\n    const transformOrigin = row.dataset.direction === \"outgoing\" ? \"right center\" : \"left center\";\n    const frames: Keyframe[] = reduced\n      ? [{ offset: 0, opacity: 1 }, { offset: 0.5, opacity: F.reducedDip }, { offset: 1, opacity: 1 }]\n      : [\n        { offset: 0, opacity: 1, transform: \"scale(1)\", transformOrigin, easing: \"ease-out\" },\n        { offset: F.peak / F.duration, opacity: F.dip, transform: `scale(${F.scale})`, transformOrigin, easing: \"ease-in-out\" },\n        { offset: 1, opacity: 1, transform: \"scale(1)\", transformOrigin },\n      ];\n    const animation = row.animate(frames, { duration, fill: \"both\", easing: \"linear\" });\n    flashing.current = animation;\n    if (options?.progress === undefined) {\n      void animation.finished.then(() => { if (flashing.current === animation) { animation.cancel(); flashing.current = null; } }).catch(() => { /* cancelled by the next jump */ });\n    } else {\n      animation.pause();\n      animation.currentTime = Math.max(0, Math.min(1, options.progress)) * duration;\n    }\n    return true;\n  }, []);\n  useImperativeHandle(ref, () => ({ scrollToBottom, isNearBottom: () => nearBottom.current, jumpTo, get element() { return scroller.current; } }), [scrollToBottom, jumpTo]);\n\n  /** The stub above a reply: tell the caller, then jump here if the quoted message is in this list. */\n  const jump = useCallback((id: string) => { onJumpToMessage?.(id); jumpTo(id); }, [onJumpToMessage, jumpTo]);\n  const messageIds = useMemo(() => new Set(messages.map(message => message.id)), [messages]);\n  const threadIds = useMemo(() => new Set(messages.filter(message => message.replyCount).map(message => message.id)), [messages]);\n\n  // A press that becomes a hold, a drag, or a second click belongs to the actions gesture, so the\n  // click that ends it must not also open a thread. `threadGesture` documents the whole order.\n  const press = useRef<{ x: number; y: number; at: number } | null>(null);\n  const plainTap = useCallback((event: MouseEvent<HTMLElement>) => {\n    const start = press.current;\n    press.current = null;\n    if (event.defaultPrevented || event.button !== 0 || event.detail > 1) return false;\n    if ((event.target as HTMLElement | null)?.closest?.('a, button, input, textarea, select, [role=\"button\"], [role=\"link\"], [contenteditable]')) return false;\n    if (start && (Math.hypot(event.clientX - start.x, event.clientY - start.y) > threadGesture.moveTolerance || event.timeStamp - start.at >= threadGesture.hold)) return false;\n    const selection = typeof window === \"undefined\" ? null : window.getSelection();\n    if (selection && !selection.isCollapsed && selection.anchorNode && event.currentTarget.contains(selection.anchorNode)) return false;\n    return true;\n  }, []);\n\n  const lastKey = rows.length ? rows[rows.length - 1].key : \"\";\n  useLayoutEffect(() => {\n    if (autoScroll && nearBottom.current) scrollToBottom();\n  }, [autoScroll, scrollToBottom, lastKey, rows.length]);\n\n  // Declared after the auto-scroll effect so the jump wins: layout effects run in order, and pinning\n  // the log to the bottom first would otherwise undo the scroll this one just made.\n  const flashId = flash?.id ?? null;\n  const flashProgress = flash?.progress;\n  useLayoutEffect(() => {\n    if (!flashId) return;\n    jumpTo(flashId, { progress: flashProgress });\n    return () => { flashing.current?.cancel(); flashing.current = null; };\n  }, [flashId, flashProgress, jumpTo]);\n\n  const typingLabel = typingInfo?.sender ? `${typingInfo.sender} is typing` : \"Someone is typing\";\n  const maxWidth = `calc((100% + ${2 * m.edgeInset}px) * ${m.maxWidthRatio})`;\n\n  /**\n   * The log is one tab stop and the arrow keys walk the messages inside it, so a keyboard can reach\n   * a single message without adding a tab stop per bubble. Reaching one is what makes its actions\n   * (the long-press overlay, the macOS context menu) available without a pointer.\n   */\n  function onListKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    // Enter or Space on a focused message opens its thread. A shell that binds those keys to the\n    // actions menu says so with `messageActions`, and then they stay with the menu: the reply count\n    // under the message is its own tab stop, so the thread is still reachable without a pointer.\n    if (onOpenThread && !selectable && !messageActions && (event.key === \"Enter\" || event.key === \" \")) {\n      const row = event.target as HTMLElement | null;\n      const id = row?.matches?.('[data-slot=\"message-row\"][data-message-id]') ? row.getAttribute(\"data-message-id\") : null;\n      if (id && threadIds.has(id)) { event.preventDefault(); onOpenThread(id, \"message\"); return; }\n    }\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const target = event.target as HTMLElement | null;\n    // Let a field, a slider or a menu inside the log keep its own arrow keys.\n    if (target && target !== event.currentTarget && target.closest(\"input, textarea, [role=slider], [role=menu]\")) return;\n    const rows = Array.from(scroller.current?.querySelectorAll<HTMLElement>('[data-slot=\"message-row\"][data-message-id]') ?? []);\n    if (!rows.length) return;\n    event.preventDefault();\n    const from = rows.findIndex(row => row === target || row.contains(target));\n    const next = event.key === \"Home\" ? 0\n      : event.key === \"End\" ? rows.length - 1\n        : from < 0 ? (event.key === \"ArrowDown\" ? 0 : rows.length - 1)\n          : Math.min(rows.length - 1, Math.max(0, from + (event.key === \"ArrowDown\" ? 1 : -1)));\n    rows[next]?.focus();\n  }\n\n  return (\n    <div ref={scroller} role=\"log\" aria-live=\"polite\" aria-relevant=\"additions text\" aria-label=\"Messages\" tabIndex={0}\n      data-slot=\"message-list\" data-platform={platform} onKeyDown={onListKeyDown}\n      className={cn(\"relative min-h-0 overflow-y-auto overscroll-contain focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\", anchor === \"bottom\" && \"flex flex-col\", className)}\n      style={{ fontFamily: fontStack, background: \"var(--im-bg)\", ...style }}\n      onScroll={event => {\n        const el = event.currentTarget;\n        nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < lm.nearBottom;\n        onScroll?.(event);\n      }} {...props}>\n      <div data-slot=\"message-list-content\" className=\"flex shrink-0 flex-col\" role={selectable ? \"listbox\" : undefined} aria-multiselectable={selectable || undefined} aria-label={selectable ? \"Messages\" : undefined}\n        style={{ padding: `${insetTop ?? lm.insetTop}px ${m.edgeInset}px ${insetBottom ?? lm.insetBottom}px`, marginTop: anchor === \"bottom\" ? \"auto\" : undefined }}>\n        {rows.map(row => {\n          if (row.kind === \"date\") return <DateSeparator key={row.key} date={row.date} now={nowMs} service={row.service} variant={row.variant} platform={platform} />;\n          if (row.kind === \"typing\") {\n            return (\n              <div key={row.key} data-slot=\"message-row\" data-typing=\"true\" className=\"flex items-start\" style={{ marginTop: row.gap }}>\n                <TypingIndicator label={row.sender ? `${row.sender} is typing` : typingLabel} platform={platform} />\n              </div>\n            );\n          }\n          const { message } = row;\n          const outgoing = message.direction === \"outgoing\";\n          const rowStyle: CSSProperties = { marginTop: row.gap };\n          let content: ReactNode;\n          // MessageBubble hangs its own reactions; every other kind needs them hung below.\n          let isTextBubble = false;\n          if (message.kind === \"link\" && message.link) {\n            const link = message.link;\n            content = <LinkPreview href={link.url} title={link.title} host={link.host} image={link.image} platform={platform} />;\n          } else if (message.kind === \"image\" && message.images?.length) {\n            content = <MessageImages images={message.images} direction={message.direction} tail={row.tail} platform={platform} />;\n          } else if (message.kind === \"audio\" && message.audio) {\n            content = <MessageAudio duration={message.audio.duration} peaks={message.audio.peaks} direction={message.direction} tail={row.tail} platform={platform} />;\n          } else if (message.kind === \"attachment\" && message.attachments?.length) {\n            content = (\n              <>\n                {message.attachments.map((file, index) => (\n                  <MessageAttachment key={file.name + index} name={file.name} size={file.size} href={file.href}\n                    direction={message.direction} tail={row.tail && index === message.attachments!.length - 1} platform={platform}\n                    style={index ? { marginTop: m.gapInGroup } : undefined} />\n                ))}\n              </>\n            );\n          } else if (row.emoji) {\n            content = <EmojiMessage message={message} platform={platform} />;\n          } else {\n            isTextBubble = true;\n            content = (\n              <MessageBubble direction={message.direction} service={message.service ?? \"imessage\"} tail={row.tail} platform={platform}\n                sender={row.showSender ? message.sender : undefined} status={row.showStatus ? statusLabel(message, nowMs) : undefined}\n                edited={message.edited} reactions={renderReactions?.(message)} emojiOnly={false} selected={selection.has(message.id)} maxWidth={maxWidth} style={{ width: \"100%\" }}>\n                {message.text}\n              </MessageBubble>\n            );\n          }\n          // Only the text branch hands its reactions to MessageBubble. Every other kind draws its own\n          // container, so without this a reaction applied to a photo, a link card, an audio row, a\n          // file card or a bare emoji is stored and never painted.\n          const reactions = renderReactions?.(message);\n          if (reactions && !isTextBubble) {\n            const offset = reactionOffsets[platform];\n            content = (\n              <div className=\"relative\" style={{ marginTop: offset.marginTop, display: \"flex\", flexDirection: \"column\", alignItems: outgoing ? \"flex-end\" : \"flex-start\" }}>\n                {content}\n                <div data-slot=\"reactions\" className=\"absolute z-10\" style={{ top: offset.top, [outgoing ? \"left\" : \"right\"]: offset.side }}>{reactions}</div>\n              </div>\n            );\n          }\n          if (message.effect === \"invisible-ink\") content = <InvisibleInk>{content}</InvisibleInk>;\n          if (message.status === \"failed\") {\n            content = (\n              <div className=\"flex items-center\" style={{ gap: 8, flexDirection: outgoing ? \"row\" : \"row-reverse\" }}>\n                <FailedSendBadge platform={platform} />\n                {content}\n              </div>\n            );\n          }\n          const quote = message.replyTo;\n          // The stub only becomes a control when the jump has somewhere to land: the quoted message is\n          // in this list, or the caller takes the jump itself. Otherwise it stays the plain quotation\n          // it renders today, rather than a button that does nothing.\n          const canJump = Boolean(quote) && (Boolean(onJumpToMessage) || messageIds.has(quote!.id));\n          // Rule 3 of `threadGesture`: a plain tap opens the thread, but not while a click selects.\n          const tapOpensThread = Boolean(onOpenThread) && Boolean(message.replyCount) && !selectable;\n          return (\n            // `tabIndex -1`: the row is not its own tab stop, the log's arrow keys focus it. That is\n            // what lets a keyboard open a message's actions, or its thread, without a pointer.\n            <div key={row.key} data-slot=\"message-row\" data-message-id={message.id} data-direction={message.direction} data-kind={message.kind ?? \"text\"}\n              data-selected={selection.has(message.id) ? \"true\" : undefined} role={selectable ? \"option\" : undefined} aria-selected={selectable ? selection.has(message.id) : undefined}\n              data-has-thread={message.replyCount ? \"true\" : undefined}\n              tabIndex={-1}\n              // The row advertises one popup, and the actions menu keeps it when a shell binds one:\n              // that is what Enter and Space do there. The reply count under the message carries the\n              // thread's own `aria-haspopup=\"dialog\"`, so a thread is announced either way.\n              aria-haspopup={messageActions ? \"menu\" : tapOpensThread ? \"dialog\" : undefined}\n              onPointerDown={tapOpensThread ? (event: PointerEvent<HTMLDivElement>) => { press.current = { x: event.clientX, y: event.clientY, at: event.timeStamp }; } : undefined}\n              onClick={tapOpensThread ? (event: MouseEvent<HTMLDivElement>) => { if (plainTap(event)) onOpenThread!(message.id, \"message\"); } : undefined}\n              data-effect={message.effect} className={cn(\"flex min-w-0 flex-col focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\", outgoing ? \"items-end\" : \"items-start\")}\n              style={tapOpensThread ? { ...rowStyle, cursor: \"pointer\" } : rowStyle}>\n              {quote && (canJump ? (\n                // A button with no box of its own: the stub keeps every measured edge, and the whole\n                // quotation is the hit target, the way a quoted stub behaves natively. The copy inside\n                // is hidden from assistive technology because this label already reads it out.\n                <button type=\"button\" data-slot=\"reply-jump\" data-target-id={quote.id}\n                  onClick={event => { event.stopPropagation(); jump(quote.id); }}\n                  aria-label={`Replying to ${quote.sender ?? (quote.direction === \"outgoing\" ? \"your message\" : \"their message\")}: ${quote.text}. Go to that message.`}\n                  className=\"flex w-full min-w-0 cursor-pointer flex-col focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n                  style={{ alignItems: \"inherit\", margin: 0, padding: 0, border: 0, background: \"transparent\", font: \"inherit\", color: \"inherit\", textAlign: \"inherit\", appearance: \"none\" }}>\n                  <ReplyStub quote={quote} aria-hidden=\"true\" platform={platform} />\n                </button>\n              ) : <ReplyStub quote={quote} platform={platform} />)}\n              {content}\n              {message.status === \"failed\" && <NotDelivered platform={platform} />}\n              {message.replyCount ? (onOpenThread ? (\n                // `display: contents` keeps the button exactly where it was in the layout while giving\n                // the click somewhere to stop: opening a thread should not also run the shell's\n                // click-to-select on the message behind it.\n                <span data-slot=\"thread-controls\" style={{ display: \"contents\" }} onClick={event => event.stopPropagation()}>\n                  <ReplyCount count={message.replyCount} platform={platform} expanded={openThreadId === undefined ? undefined : openThreadId === message.id}\n                    onOpen={() => onOpenThread(message.id, \"reply-count\")} />\n                </span>\n              ) : <ReplyCount count={message.replyCount} platform={platform} />) : null}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-list.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-status-bar",
      "title": "iOS status bar",
      "description": "Time, Dynamic Island, cellular, Wi-Fi, and battery exactly as iOS 26 draws them.",
      "files": [
        {
          "path": "registry/imessage/ios-status-bar.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 status bar for a 402pt-wide screen, measured from the iPhone 17 Pro simulator captures in\n * `references/ios/captures/` (conv3-light, conv2-dark, list-*, newmsg-light). All sizes are points\n * (= CSS px): 54 tall; time 17pt semibold with its cap height centered on y 32.67 and ink starting\n * at x 57.33; Dynamic Island x 138.33–263.67, y 14–50.67; four cellular dots (3.33 rounded squares, pitch 5.33)\n * from x 288.33 on y 35.33–38.67; wifi ink 17×12.33 at (315, 26.33), three annular sectors about\n * (8.5, 13) with a 40.8° half-angle, the innermost one tipped so the glyph ends at y 38.67 and not a\n * pixel lower; battery outline 25×13 at (339.33, 26) with a 21×9 fill and a 1.33×3.67 nub. Theme follows a `.dark` ancestor. A dimming overlay above the screen (see the\n * New Message sheet) darkens it the way iOS does, so it needs no dimmed variant of its own.\n */\nexport type IosStatusBarProps = ComponentProps<\"div\"> & {\n  /** Clock text. Captures show the real time; \"9:41\" is Apple's marketing default. */\n  time?: string;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst vars =\n  \"[--ios-sb-label:#000000] [--ios-sb-dot:#cccccc] [--ios-sb-battery:#999999] [--ios-sb-nub:#808080] \" +\n  \"dark:[--ios-sb-label:#ffffff] dark:[--ios-sb-dot:#333333] dark:[--ios-sb-battery:#666666] dark:[--ios-sb-nub:#808080]\";\n\n/** Chrome snaps box edges to whole CSS px, so third-point offsets are applied as transforms. */\nconst third = (x: number, y: number) => ({ transform: `translate(${x}px, ${y}px)` });\n\nexport function IosStatusBar({ time = \"9:41\", className, style, ...props }: IosStatusBarProps) {\n  return (\n    <div data-slot=\"ios-status-bar\" className={cn(\"relative h-[54px] w-full select-none\", vars, className)}\n      style={{ fontFamily: font, ...style }} {...props}>\n      <span data-slot=\"time\" className=\"absolute whitespace-nowrap\" style={{ left: 56, top: 24, ...third(1 / 3, 2 / 3), fontSize: 17, lineHeight: 1, fontWeight: 600, letterSpacing: 0, color: \"var(--ios-sb-label)\" }}>\n        {time}\n      </span>\n      <span aria-hidden=\"true\" data-slot=\"dynamic-island\" className=\"absolute rounded-full bg-black\" style={{ left: 138, top: 14, width: 125.3333, height: 36.6667, ...third(1 / 3, 0) }} />\n      <span aria-hidden=\"true\" data-slot=\"cellular\" className=\"absolute flex\" style={{ left: 288, top: 35, gap: 2, ...third(1 / 3, 1 / 3) }}>\n        {[0, 1, 2, 3].map(i => <span key={i} style={{ width: 3.3333, height: 3.3333, borderRadius: 1, background: \"var(--ios-sb-dot)\" }} />)}\n      </span>\n      <svg aria-hidden=\"true\" data-slot=\"wifi\" className=\"absolute\" style={{ left: 314, top: 25 }} width=\"19\" height=\"14.6667\" viewBox=\"-1 -1.3333 19 14.6667\" fill=\"var(--ios-sb-label)\">\n        <path d=\"M0.01 3.16A13 13 0 0 1 16.99 3.16L15.25 5.18A10.33 10.33 0 0 0 1.75 5.18Z\" />\n        <path d=\"M2.84 6.44A8.67 8.67 0 0 1 14.16 6.44L12.42 8.46A6 6 0 0 0 4.58 8.46Z\" />\n        <path d=\"M5.45 9.47A4.67 4.67 0 0 1 11.55 9.47L8.94 12.16A0.67 0.67 0 0 0 8.06 12.16Z\" />\n      </svg>\n      <span aria-hidden=\"true\" data-slot=\"battery\" className=\"absolute\" style={{ left: 339, top: 26, width: 25, height: 13, borderRadius: 4.3333, boxShadow: \"inset 0 0 0 1px var(--ios-sb-battery)\", ...third(1 / 3, 0) }}>\n        <span className=\"absolute\" style={{ left: 2, top: 2, width: 21, height: 9, borderRadius: 2.75, background: \"var(--ios-sb-label)\" }} />\n        <span className=\"absolute\" style={{ left: 26, top: 4.6667, width: 1.3333, height: 3.6667, borderRadius: \"0 1.3333px 1.3333px 0\", background: \"var(--ios-sb-nub)\" }} />\n      </span>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-status-bar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-nav-bar",
      "title": "iOS nav bar",
      "description": "The conversation nav bar with the glass back button, large avatar, and name pill, plus the list's large title.",
      "files": [
        {
          "path": "registry/imessage/ios-nav-bar.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 conversation nav bar (Liquid Glass), measured from `references/ios/captures/conv3-light.png`,\n * `conv2-dark.png` and `grouped-light.png` (all three agree). The bar sits directly under the 54pt\n * status bar and is 94pt tall: back button Ø44 centered (38, 84); avatar Ø60 at x 171–231, y 62–122\n * (center 201, 92) with initials 28pt semibold (ink 32.33 × 19.67 cap); name pill x 107.33–294.67\n * (187.33 wide) y 117.00–149.33 (32.33 tall, capsule radius 16.17, continuous corners) with 17pt bold\n * text (native ink width matches Chrome's 700, not 600) whose ink runs 121.33–272.33, then a\n * 4.67 × 12.67 chevron with a 2.6pt round stroke, ink 279.00–283.67.\n *\n * The **avatar paints over the pill**: its circle runs to y 122, 5pt below the pill's top edge, and\n * the pill's glass is behind it. What shows on the glass under the avatar is the avatar's own soft\n * shadow (offset 2, blur 4, 12%), measured at both x 180 (#ededed just below the circle) and x 201.\n *\n * Glass surfaces are 90% white with a backdrop blur and a soft drop shadow (capsules 0 6 36 spread 4\n * at 6.5%, circles 0 5 20 spread 6 at 5.5%, clipped at the midpoint of gaps between neighbors so they\n * do not add up, as on the device) in light; in dark #191919 under a 1pt specular rim that ramps\n * inward, measured over black on the back button's integer box as 12.6% → 9.6% → 6.1% white across\n * its three device rows. Shadows are painted on a layer beneath every surface so neighbors never\n * shade each other.\n *\n * Those captures pin the *composite*, not the fill. Both put a flat page behind every glass surface\n * here, so the light fill is pinned to white at any alpha and the dark fill only to the product\n * alpha × colour (0.9 × 28 = 25.2 = #191919). Halving both alphas while holding those products\n * (rgba(255,255,255,0.45) and rgba(56,56,56,0.45)) leaves the dark diff bit-identical at 0.31% and\n * the light one at 380 → 386 mismatched px of 361,800. So 90% is a choice, not a measurement, and it\n * is the number that decides how much of a bubble scrolled under the bar shows through. Unverified.\n *\n * The bar's own background is transparent and content scrolls under it, which `dateheader-mid-light.png`\n * and `dateheader-mid-dark.png` show is not what the device does: an incoming bubble crossing the bar is\n * pulled toward the page background across the bar's whole width, outside every glass surface. Down\n * x 95, the dark incoming bubble #262629 (38,38,41) reads (9,9,9) at y 105, (23,23,25) at y 140 and\n * (32,32,35) at y 160 where that bubble ends; the next one down, at x 30, is still (36,36,39) at y 175\n * and reaches (38,38,41) only at y 195, 47 below the bar. Light #e9e9eb (233,233,235) reads\n * (246,246,246) at y 105 and settles by y 150. The two ramps do not agree as one blend toward the page\n * colour, in sRGB or in linear light, so the mechanism is recorded and not built.\n *\n * Two sub-pixel offsets are transforms because Chrome quantizes paint, not layout: text baselines and\n * inline-SVG paint offsets snap to whole CSS px, so the name and the chevron each carry a 1/3-px\n * translate that the padding cannot express.\n */\nexport type IosNavBarProps = Omit<ComponentProps<\"header\">, \"children\"> & {\n  name: string;\n  initials?: string;\n  /** Replace the initials avatar (e.g. an <img>). */\n  avatar?: ReactNode;\n  onBack?: () => void;\n  onDetails?: () => void;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst vars =\n  \"[--ios-nav-label:#1a1919] [--ios-nav-chevron:#bdbdbd] [--ios-nav-glass:rgba(255,255,255,0.9)] [--ios-nav-rim:none] [--ios-nav-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-nav-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-nav-avatar-shadow:rgba(0,0,0,0.12)] \" +\n  \"[--ios-nav-avatar-top:#a9c2e1] [--ios-nav-avatar-bottom:#747fb9] \" +\n  \"dark:[--ios-nav-label:#f4f3f4] dark:[--ios-nav-chevron:#5d5d5d] dark:[--ios-nav-glass:rgba(28,28,28,0.9)] dark:[--ios-nav-rim:inset_0_0_0_0.3333px_rgba(255,255,255,0.0385),inset_0_0_0_0.6667px_rgba(255,255,255,0.032),inset_0_0_0_1px_rgba(255,255,255,0.061)] dark:[--ios-nav-shadow:none] dark:[--ios-nav-shadow-round:none] dark:[--ios-nav-avatar-shadow:rgba(0,0,0,0.3)] \" +\n  \"dark:[--ios-nav-avatar-top:#575368] dark:[--ios-nav-avatar-bottom:#302649]\";\n\n/** Apple's continuous corner (superellipse n≈2.2, measured on the pill). Browsers without corner-shape fall back to round. */\nconst capsule = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/**\n * Shadow layer (beneath every surface of the bar), then the blur, then the translucent surface. The\n * parent must be `relative` and its content `relative`.\n *\n * `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the\n * two shadows read as one (they never add up on the device).\n *\n * The blur rides its own span: `backdrop-filter` promotes an element to a composited layer whose\n * bounds Chrome snaps to whole CSS px, which would drag the pill's edges from x 107.33/294.67 out to\n * 107/295. Keeping the fill and the rim on an unfiltered span lets the surface paint on the exact\n * fractional box.\n */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] [corner-shape:inherit]\" style={{ boxShadow: round ? \"var(--ios-nav-shadow-round)\" : \"var(--ios-nav-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] [corner-shape:inherit]\" style={{ backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] [corner-shape:inherit]\" style={{ background: \"var(--ios-nav-glass)\", boxShadow: \"var(--ios-nav-rim)\" }} />\n    </>\n  );\n}\n\nexport function initialsOf(name: string): string {\n  return name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\nexport function IosNavBar({ name, initials, avatar, onBack, onDetails, className, style, ...props }: IosNavBarProps) {\n  const letters = initials ?? initialsOf(name);\n  return (\n    <header data-slot=\"ios-nav-bar\" className={cn(\"relative isolate h-[94px] w-full select-none\", vars, className)} style={{ fontFamily: font, ...style }} {...props}>\n      <button type=\"button\" data-slot=\"back\" aria-label=\"Back\" onClick={onBack}\n        className=\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\"\n        style={{ left: 16, top: 8, width: 44, height: 44 }}>\n        <GlassLayers round />\n        <svg aria-hidden=\"true\" className=\"relative\" width=\"44\" height=\"44\" viewBox=\"0 0 44 44\" fill=\"none\" stroke=\"var(--ios-nav-label)\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"M24.8 13.87 16.2 22.17 24.8 30.47\" />\n        </svg>\n      </button>\n      {/* Padding and gap are solved, not chosen: they place the 17pt text run and the chevron on the\n          measured ink positions while the pill's own box comes out 187.33 wide, centered on x 201. */}\n      <button type=\"button\" data-slot=\"title\" aria-label={`${name}, details`} onClick={onDetails}\n        className=\"absolute flex items-center whitespace-nowrap rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\"\n        style={{ left: \"50%\", top: 63, height: 32.3333, transform: \"translate(-50%, 0)\", padding: \"0.3333px 10.7188px 0 12.9531px\", gap: 6.1875, ...capsule }}>\n        <GlassLayers />\n        <span data-slot=\"name\" className=\"relative\" style={{ transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 700, letterSpacing: 0, color: \"var(--ios-nav-label)\" }}>{name}</span>\n        <svg aria-hidden=\"true\" className=\"relative\" width=\"8.6667\" height=\"16.6667\" viewBox=\"-2 -2 8.6667 16.6667\" style={{ margin: -2, transform: \"translateX(-0.3333px)\" }} fill=\"none\" stroke=\"var(--ios-nav-chevron)\" strokeWidth=\"2.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"M1.3 1.3 3.37 6.3333 1.3 11.37\" />\n        </svg>\n      </button>\n      <div aria-hidden=\"true\" data-slot=\"avatar\" className=\"absolute flex items-center justify-center overflow-hidden rounded-full text-white\"\n        style={{ left: \"calc(50% - 30px)\", top: 8, width: 60, height: 60, fontSize: 28, lineHeight: 1, fontWeight: 600, background: \"linear-gradient(var(--ios-nav-avatar-top), var(--ios-nav-avatar-bottom))\", boxShadow: \"0 2px 4px var(--ios-nav-avatar-shadow)\" }}>\n        {avatar ?? letters}\n      </div>\n    </header>\n  );\n}\n\n/**\n * The conversation list's large title (\"Messages\"): 34pt bold at x 16, cap height centered on\n * y 140 of the screen (baseline 152). Occupies the 114pt between the status bar and the first row.\n */\nexport function IosLargeTitle({ children, className, style, ...props }: ComponentProps<\"h1\">) {\n  return (\n    <h1 data-slot=\"ios-large-title\" className={cn(\"m-0 select-none [--ios-title:#000000] dark:[--ios-title:#ffffff]\", className)}\n      style={{ height: 114, paddingTop: 68, paddingLeft: 16, fontFamily: font, fontSize: 34, lineHeight: 1, fontWeight: 700, letterSpacing: 0, color: \"var(--ios-title)\", boxSizing: \"border-box\", ...style }} {...props}>\n      {children}\n    </h1>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-nav-bar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-composer",
      "title": "iOS composer",
      "description": "The glass message field with the plus button, microphone, auto-growing text, and the send pill.",
      "files": [
        {
          "path": "registry/imessage/ios-composer.tsx",
          "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 composer row, measured from `references/ios/captures/conv3-light.png`, `conv2-dark.png`,\n * `paste-check.png` and `newmsg-typed-disabled-send-light.png` (402pt screen). The row hugs the\n * bottom of the screen with 28pt margins, and both surfaces rest their bottom edge on y 846:\n *\n * - `+` glass circle Ø40 centered (48, 826), so its top edge is y 806. Glyph ink 15.33 square, and\n *   its bar integrates to 4.99 device px of coverage, i.e. 1.66 of stroke. The 1.6 drawn below\n *   rasterizes to the same 4.99, so the glyph lands on the capture pixel for pixel.\n * - Field x 80–374. It is **40.33** tall for one line, not 40: in `conv2-dark.png` its rim starts a\n *   device pixel above the `+` button's (y 805.67 against 806) while both end at 846. Each extra\n *   line adds exactly 20 (`newmsg-typed-disabled-send-light.png` two lines = 60.33, `paste-check.png`\n *   four lines = 100.33), so the extra third of a point is a one-off at the top edge, not per line.\n *   The box is laid out at that height, but Chromium rounds a painted box edge to the whole CSS\n *   pixel, so the rim still lands on 806 on the screen. Measured every way round: a fractional\n *   border-radius, a clip-path and a composited layer all snap the same. It is the one device pixel\n *   of the composer that a browser will not draw.\n * - The field's corner is a plain **circular** radius 20, not a continuous corner. Tracing the outer\n *   edge in `conv2-dark.png` (one line) and `paste-check.png` (four lines) and fitting a browser\n *   render to it: `corner-shape: round` sits within 0.06pt of native, `superellipse(1.14)` cuts the\n *   corner 0.55–0.78pt too tight all the way down it. At one line the field is a capsule anyway.\n * - Text 17pt on a 20pt line, 10 top and bottom over a 16pt left inset (placeholder ink x 97–167.67,\n *   ink top 819.33 in `conv3-light.png`, matched to the pixel). Caret #0088ff light / #0091ff dark.\n * - Mic: ink 11.87 × 17.85 at x 346.85–358.72, y 816.95–834.8 (capsule 6.03 × 11.65, U 11.87 across,\n *   every stroke 1.25). Colour #b4b8bf light / #636466 dark. It has its own outline rather than\n *   reusing `IosMicIcon`, which is the heavier weight the conversation list's search pill draws.\n * - Send pill 38×28 #0088ff, its right edge 6.33 in from the field's right edge and its bottom 6\n *   above the field's bottom, in a two-line and a four-line field alike, so it is bottom-anchored.\n *   White arrow: 2.41 stroke, apex (348.33, 819), arms out to ±5.88, stem down to y 832.7. The path\n *   is drawn a sixth of a point left and a third down of those, which is what lands the rendered ink\n *   on the capture: the pill's own box starts at x 329.67 and the browser rasterizes the SVG from a\n *   rounded origin.\n * - Glass: 90% white with a backdrop blur and a soft shadow (light), #191919 with a 1pt rim (dark);\n *   shadows are painted on a layer beneath both surfaces and clipped at the midpoint of the 12pt\n *   gap, so the field never shades the `+` button.\n *\n * Not measured: `maxLines` (no capture holds a field taller than four lines) and the gray send pill\n * a composer with no recipient shows (#ededee with a #b8b8bb arrow in\n * `newmsg-typed-disabled-send-light.png`), which this component does not render.\n */\nexport type IosComposerProps = Omit<ComponentProps<\"form\">, \"onSubmit\" | \"onChange\" | \"defaultValue\"> & {\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  onSend?: (message: string) => void | Promise<void>;\n  onAttach?: () => void;\n  /** Whether the attachments sheet (`IosPlusMenu`) is open; sets the `+` button's aria-expanded. */\n  attachExpanded?: boolean;\n  onMic?: () => void;\n  placeholder?: string;\n  disabled?: boolean;\n  /** Tallest the field grows before it scrolls, in lines. */\n  maxLines?: number;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\nconst LINE = 20;\nconst PAD_Y = 10;\n/** The field's top edge sits a device pixel above the `+` button's, so one line is 40.33 and not 40. */\nconst TOP_EDGE = 1 / 3;\n\nconst vars =\n  \"[--ios-cmp-glass:rgba(255,255,255,0.9)] [--ios-cmp-rim:none] [--ios-cmp-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-cmp-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-cmp-glyph:#1a1919] [--ios-cmp-text:#000000] [--ios-cmp-placeholder:#bdbdbd] [--ios-cmp-mic:#b4b8bf] [--ios-cmp-caret:#0088ff] \" +\n  \"dark:[--ios-cmp-glass:rgba(28,28,28,0.9)] dark:[--ios-cmp-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-cmp-shadow:none] dark:[--ios-cmp-shadow-round:none] dark:[--ios-cmp-glyph:#f4f3f4] dark:[--ios-cmp-text:#ffffff] dark:[--ios-cmp-placeholder:#5d5d5d] dark:[--ios-cmp-mic:#636466] dark:[--ios-cmp-caret:#0091ff]\";\n\n/** `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the two shadows read as one (they never add up on the device). */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]\" style={{ boxShadow: round ? \"var(--ios-cmp-shadow-round)\" : \"var(--ios-cmp-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit]\" style={{ background: \"var(--ios-cmp-glass)\", boxShadow: \"var(--ios-cmp-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n    </>\n  );\n}\n\n/**\n * SF \"mic\" as the conversation list's search pill draws it: a heavier weight than the composer's,\n * 1.60 of stroke on 18.33 of ink. Kept for `ios-conversation-list.tsx`, which is the only caller.\n */\nexport function IosMicIcon({ width = 12, height = 17.6667, color = \"currentColor\" }: { width?: number; height?: number; color?: string }) {\n  return (\n    <svg aria-hidden=\"true\" width={width} height={height} viewBox=\"0 0 12 17.6667\" fill=\"none\" stroke={color} strokeLinecap=\"round\">\n      <rect x=\"3.6667\" y=\"0.6667\" width=\"4.6667\" height=\"10.3333\" rx=\"2.3333\" strokeWidth=\"1.3333\" />\n      <path d=\"M0.75 6.3333V8.83A5.08 5.08 0 0 0 10.92 8.83V6.3333\" strokeWidth=\"1.5\" />\n      <path d=\"M5.83 14.5V16.3\" strokeWidth=\"1.5\" />\n      <path d=\"M2.3 16.9H9.4\" strokeWidth=\"1.3333\" />\n    </svg>\n  );\n}\n\n/**\n * The composer's mic, traced separately from `conv3-light.png` at 3x because iOS draws the symbol\n * lighter here than in the search pill: capsule 6.03 × 11.65, U 11.87 across, base bar 8.62, every\n * stroke 1.25. The viewBox is the ink box. One shared outline cannot serve both: drawing the\n * composer with the pill's leaves 81 mismatched pixels here against `conv3-light.png` where this\n * one leaves 0, and drawing the pill with this one takes its own 30pt window from 21 to 26.\n */\nfunction ComposerMicIcon() {\n  return (\n    <svg aria-hidden=\"true\" width=\"11.87\" height=\"17.85\" viewBox=\"0 0 11.87 17.85\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.25\" strokeLinecap=\"round\">\n      <rect x=\"3.535\" y=\"0.625\" width=\"4.78\" height=\"10.4\" rx=\"2.39\" />\n      <path d=\"M0.625 7.23V9.02A5.31 5.31 0 0 0 11.245 9.02V7.23\" />\n      <path d=\"M5.935 14.33V17.225\" />\n      <path d=\"M2.24 17.225H9.61\" />\n    </svg>\n  );\n}\n\nexport function IosComposer({\n  value, defaultValue = \"\", onChange, onSend, onAttach, attachExpanded, onMic, placeholder = \"iMessage\", disabled = false, maxLines = 8, className, style, ...props\n}: IosComposerProps) {\n  const [draft, setDraft] = useState(defaultValue);\n  const text = value ?? draft;\n  const textarea = useRef<HTMLTextAreaElement>(null);\n  const composing = useRef(false);\n  const sending = useRef(false);\n  const id = useId();\n  const hasText = text.trim().length > 0;\n\n  function update(next: string) {\n    if (value === undefined) setDraft(next);\n    onChange?.(next);\n  }\n\n  // Grow with the content, one 20pt line at a time, up to `maxLines`.\n  useLayoutEffect(() => {\n    const el = textarea.current;\n    if (!el) return;\n    el.style.height = \"0px\";\n    const max = maxLines * LINE + PAD_Y * 2;\n    const next = Math.max(LINE + PAD_Y * 2, Math.min(el.scrollHeight, max));\n    el.style.height = `${next}px`;\n    el.style.overflowY = el.scrollHeight > max ? \"auto\" : \"hidden\";\n  }, [text, maxLines]);\n\n  async function send() {\n    const message = text.trim();\n    if (!message || disabled || sending.current || composing.current) return;\n    sending.current = true;\n    try {\n      await onSend?.(message);\n      update(\"\");\n    } finally {\n      sending.current = false;\n      requestAnimationFrame(() => textarea.current?.focus());\n    }\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.key !== \"Enter\" || event.shiftKey) return;\n    if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 || composing.current) return;\n    event.preventDefault();\n    void send();\n  }\n\n  return (\n    <form data-slot=\"ios-composer\" aria-label=\"Send a message\" className={cn(\"relative isolate flex w-full items-end select-none\", vars, className)}\n      style={{ padding: \"0 28px 28px 28px\", fontFamily: font, ...style }} onSubmit={event => { event.preventDefault(); void send(); }} {...props}>\n      <button type=\"button\" data-slot=\"attach\" aria-label=\"Add attachment\" aria-haspopup=\"menu\" aria-expanded={attachExpanded} disabled={disabled} onClick={onAttach}\n        className=\"relative flex shrink-0 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50\"\n        style={{ width: 40, height: 40 }}>\n        <GlassLayers round clip=\"right\" />\n        <svg aria-hidden=\"true\" className=\"relative\" width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" fill=\"none\" stroke=\"var(--ios-cmp-glyph)\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n          <path d=\"M13.1 20H26.9M20 13.1V26.9\" />\n        </svg>\n      </button>\n      <div data-slot=\"field\" className=\"relative min-w-0 flex-1\" style={{ marginLeft: 12, paddingTop: TOP_EDGE, minHeight: LINE + PAD_Y * 2 + TOP_EDGE, borderRadius: 20 }}>\n        <GlassLayers clip=\"left\" />\n        <label htmlFor={id} className=\"sr-only\">Message</label>\n        <textarea ref={textarea} id={id} rows={1} value={text} disabled={disabled} placeholder={placeholder}\n          onChange={event => update(event.target.value)} onKeyDown={onKeyDown}\n          onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n          className=\"relative block w-full resize-none border-0 bg-transparent outline-none select-text placeholder:text-[var(--ios-cmp-placeholder)]\"\n          style={{ padding: `${PAD_Y}px 48px ${PAD_Y}px 16px`, fontFamily: font, fontSize: 17, lineHeight: `${LINE}px`, letterSpacing: 0, color: \"var(--ios-cmp-text)\", caretColor: \"var(--ios-cmp-caret)\", boxSizing: \"border-box\", margin: 0 }} />\n        {hasText ? (\n          <button type=\"submit\" data-slot=\"send\" aria-label=\"Send message\" disabled={disabled}\n            className=\"absolute flex items-center justify-center rounded-full bg-[#0088ff] text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500\"\n            style={{ right: 6.3333, bottom: 6, width: 38, height: 28 }}>\n            <svg aria-hidden=\"true\" width=\"38\" height=\"28\" viewBox=\"0 0 38 28\" fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M12.6333 12.6667 18.5 7.3333 24.3667 12.6667M18.5 7.3333V20.7\" />\n            </svg>\n          </button>\n        ) : (\n          <button type=\"button\" data-slot=\"mic\" aria-label=\"Record audio message\" disabled={disabled} onClick={onMic}\n            className=\"absolute flex items-center justify-center focus-visible:outline-2 focus-visible:outline-blue-500\"\n            /* 20×26 hit box; the insets centre the 11.87×17.85 glyph on its measured ink, x 346.85–358.72 and y 816.95–834.8 in a one-line field. */\n            style={{ right: 11.22, bottom: 7.13, width: 20, height: 26, color: \"var(--ios-cmp-mic)\" }}>\n            <ComposerMicIcon />\n          </button>\n        )}\n      </div>\n    </form>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-composer.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-conversation-list",
      "title": "iOS conversation list",
      "description": "The Messages list screen: large title, rows with avatars and previews, search pill, and compose button.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/ios-composer.json",
        "https://imessage.swerdlow.dev/r/ios-nav-bar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/ios-conversation-list.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { IosLargeTitle } from \"@/components/imessage/ios-nav-bar\";\nimport { IosMicIcon } from \"@/components/imessage/ios-composer\";\n\n/**\n * iOS 26 Messages list screen, measured from `references/ios/captures/list-light.png` and\n * `list-dark.png` (402×874). Large title 34pt bold at x 16 (baseline 152); rows 86.67 tall from\n * y 168: avatar Ø45 at x 26 (top +20) with 21pt semibold initials, name 17pt semibold at x 83 with\n * its cap height centered 22.33 below the row top, time 15pt secondary whose ink right edge sits at\n * x 364.0 (native formats it with a narrow no-break space, \"1:48 AM\"), chevron 7×12 at x 378.33,\n * preview 15pt secondary on a 20pt line (up to two lines), 1pt separator from x 83 to 386 at the\n * row bottom. Rows are placed with transforms so their third-point pitch is not snapped to whole pixels. Floating bottom bar: glass search pill x 28–314, y 798–846 (magnifier, \"Search\"\n * 17pt medium, mic) and a Ø48 glass compose button centered (350, 822). Glass shadows are painted\n * on a layer beneath both surfaces.\n *\n * Both bottom-bar surfaces are plain circular capsules: fitting the pill's left cap to\n * |u|^n + |v|^n = 1 over r 24 gives n = 2.045 (rmse 0.058 pt) and the compose circle n = 2.015, so\n * neither uses a continuous corner. Magnifier: ring outer diameter 13.49 centered (54.75, 820.22),\n * stroke 1.73; handle a 45deg stroke 2.47 wide ending at (63.17, 828.77). Its SVG box is placed on\n * a whole pixel (top 14) because Chrome snaps an SVG layer's origin down to the nearest CSS pixel,\n * so the ring and handle carry the sub-pixel offsets instead. Compose glyph: a rounded square\n * whose outer box is exactly 19x19 at (340, 813.33), stroke 1.83, outer corner radius 4.05;\n * pencil a 45deg stroke 2.10 wide. Avatar gradient endpoints are a least-squares fit down the\n * avatar's center column.\n *\n * Neither capture shows these, so they are not measured against pixels: a preview long enough to\n * wrap to two lines, and a name or preview long enough to truncate.\n *\n * Nor the glass fill's alpha. Both captures have the last row ending 400pt above the bottom bar, so\n * every point of both surfaces sits over the flat page: sampling their glass-only interiors (the pill\n * left of the magnifier, its mid span, right of the mic, and the compose circle either side of its\n * glyph) gives 255 in light and 25 in dark in the capture and in our render alike, mean signed error\n * 0.00 per channel on all five. That pins the light fill to white at any alpha and the dark fill only\n * to alpha × colour = 25. The 90% is carried over from the nav bar, where the same is true; see the\n * note there. It decides how much of a row scrolled under the bar shows through, which nothing here\n * measures.\n */\nexport type IosConversation = {\n  id: string;\n  name: string;\n  initials?: string;\n  preview: string;\n  time: string;\n  unread?: boolean;\n};\n\nexport type IosConversationListProps = Omit<ComponentProps<\"div\">, \"onSelect\"> & {\n  conversations: IosConversation[];\n  onSelect?: (conversation: IosConversation) => void;\n  onCompose?: () => void;\n  onSearch?: () => void;\n  title?: string;\n  /** Space reserved above the title for the status bar. */\n  topInset?: number;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\nconst ROW = 86.6667;\n/**\n * The unread dot, read out of ChatKit 26.5 rather than a capture: neither list capture contains an\n * unread row, so nothing here is measured against pixels. `-[CKUIBehaviorPhone unreadIndicatorImageViewSize]`\n * is {11, 11}, and `_calculateIndicatorFrameForSize:trailing:displayScale:insets:` puts it at\n * (`conversationListCellLeftMargin` 26 - 11) / 2 horizontally and centred on the row vertically.\n * `shouldUnreadIndicatorChangeOnSelection` is NO on iOS, so unlike macOS the dot never turns white.\n */\nconst UNREAD = 11;\nconst UNREAD_LEFT = 7.5;\n\nconst vars =\n  \"[--ios-list-bg:#ffffff] [--ios-list-label:#000000] [--ios-list-secondary:#8a8a8e] [--ios-list-chevron:#c5c5c7] [--ios-list-separator:#e8e8e8] \" +\n  \"[--ios-list-glass:rgba(255,255,255,0.9)] [--ios-list-rim:none] [--ios-list-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-list-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-list-glyph:#1a1919] [--ios-list-field:#8a8a8e] \" +\n  \"[--ios-list-avatar-top:#a9c2e1] [--ios-list-avatar-bottom:#747fb9] [--ios-list-unread:#0088ff] \" +\n  \"dark:[--ios-list-bg:#000000] dark:[--ios-list-label:#ffffff] dark:[--ios-list-secondary:#8d8d93] dark:[--ios-list-chevron:#464649] dark:[--ios-list-separator:#2a2a2c] dark:[--ios-list-unread:#0091ff] \" +\n  \"dark:[--ios-list-glass:rgba(28,28,28,0.9)] dark:[--ios-list-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-list-shadow:none] dark:[--ios-list-shadow-round:none] dark:[--ios-list-glyph:#f4f3f4] dark:[--ios-list-field:#97979d] \" +\n  \"dark:[--ios-list-avatar-top:#575368] dark:[--ios-list-avatar-bottom:#302649]\";\n\n/** `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the two shadows read as one (they never add up on the device). */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] [corner-shape:inherit]\" style={{ boxShadow: round ? \"var(--ios-list-shadow-round)\" : \"var(--ios-list-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] [corner-shape:inherit]\" style={{ background: \"var(--ios-list-glass)\", boxShadow: \"var(--ios-list-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n    </>\n  );\n}\n\nfunction initialsOf(name: string): string {\n  return name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\nexport function IosConversationList({ conversations, onSelect, onCompose, onSearch, title = \"Messages\", topInset = 54, className, style, ...props }: IosConversationListProps) {\n  return (\n    <div data-slot=\"ios-conversation-list\" className={cn(\"relative isolate h-full w-full overflow-hidden select-none\", vars, className)}\n      style={{ fontFamily: font, background: \"var(--ios-list-bg)\", ...style }} {...props}>\n      <div data-slot=\"scroll\" className=\"absolute inset-0 overflow-y-auto\" style={{ paddingTop: topInset, paddingBottom: 90 }}>\n        <IosLargeTitle>{title}</IosLargeTitle>\n        <ul data-slot=\"rows\" aria-label={title} className=\"relative m-0 list-none p-0\" style={{ height: conversations.length * ROW }}>\n          {conversations.map((conversation, index) => {\n            const letters = conversation.initials ?? initialsOf(conversation.name);\n            return (\n              <li key={conversation.id} data-slot=\"row\" className=\"absolute left-0 right-0 top-0\" style={{ height: ROW, transform: `translateY(${index * ROW}px)` }}>\n                <button type=\"button\" onClick={() => onSelect?.(conversation)} aria-label={`${conversation.unread ? \"Unread. \" : \"\"}${conversation.name}, ${conversation.time}, ${conversation.preview}`}\n                  className=\"absolute inset-0 w-full text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500\">\n                  {conversation.unread && <span aria-hidden=\"true\" data-slot=\"unread\" className=\"absolute rounded-full bg-[var(--ios-list-unread)]\" style={{ left: UNREAD_LEFT, top: (ROW - UNREAD) / 2, width: UNREAD, height: UNREAD }} />}\n                  <span aria-hidden=\"true\" data-slot=\"avatar\" className=\"absolute flex items-center justify-center overflow-hidden rounded-full text-white\"\n                    style={{ left: 26, top: 20, width: 45, height: 45, fontSize: 21, lineHeight: 1, fontWeight: 600, background: \"linear-gradient(var(--ios-list-avatar-top), var(--ios-list-avatar-bottom))\" }}>\n                    {letters}\n                  </span>\n                  <span aria-hidden=\"true\" data-slot=\"name\" className=\"absolute truncate\" style={{ left: 83, right: 96, top: 14, transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 600, letterSpacing: 0, color: \"var(--ios-list-label)\" }}>\n                    {conversation.name}\n                  </span>\n                  <span aria-hidden=\"true\" data-slot=\"time\" className=\"absolute whitespace-nowrap\" style={{ right: 37.1167, top: 15, transform: \"translateY(0.3333px)\", fontSize: 15, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-list-secondary)\" }}>\n                    {conversation.time}\n                  </span>\n                  <svg aria-hidden=\"true\" data-slot=\"chevron\" className=\"absolute\" style={{ left: 376, top: 15, transform: \"translateX(0.3333px)\" }} width=\"11\" height=\"16\" viewBox=\"-2 -2 11 16\" fill=\"none\" stroke=\"var(--ios-list-chevron)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                    <path d=\"M1 1 6 6 1 11\" />\n                  </svg>\n                  <span aria-hidden=\"true\" data-slot=\"preview\" className=\"absolute overflow-hidden\" style={{ left: 83, right: 34, top: 33, transform: \"translateY(-0.3333px)\", fontSize: 15, lineHeight: \"20px\", letterSpacing: 0, color: \"var(--ios-list-secondary)\", display: \"-webkit-box\", WebkitLineClamp: 2, WebkitBoxOrient: \"vertical\" } as CSSProperties}>\n                    {conversation.preview}\n                  </span>\n                  <span aria-hidden=\"true\" data-slot=\"separator\" className=\"absolute\" style={{ left: 83, right: 16, bottom: 0, height: 1, transform: \"translateY(-0.3333px)\", background: \"var(--ios-list-separator)\" }} />\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n      <div data-slot=\"bottom-bar\" className=\"absolute flex items-end\" style={{ left: 28, right: 28, bottom: 28, height: 48 }}>\n        <button type=\"button\" data-slot=\"search\" aria-label=\"Search\" onClick={onSearch}\n          className=\"relative h-full min-w-0 flex-1 rounded-full text-left focus-visible:outline-2 focus-visible:outline-blue-500\">\n          <GlassLayers clip=\"right\" />\n          <svg aria-hidden=\"true\" className=\"absolute\" style={{ left: 19, top: 14 }} width=\"18.3333\" height=\"18.6667\" viewBox=\"-1 -1 18.3333 18.6667\" fill=\"none\" stroke=\"var(--ios-list-field)\" strokeLinecap=\"round\">\n            <circle cx=\"6.745\" cy=\"7.219\" r=\"5.883\" strokeWidth=\"1.725\" />\n            <path d=\"M11.4 12.01 15.17 15.78\" strokeWidth=\"2.467\" />\n          </svg>\n          <span className=\"absolute\" style={{ left: 46.8, top: 15.5, transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 500, letterSpacing: 0, color: \"var(--ios-list-field)\" }}>Search</span>\n          <span aria-hidden=\"true\" className=\"absolute\" style={{ right: 23.3333, top: 14.6667, color: \"var(--ios-list-field)\", display: \"flex\" }}>\n            <IosMicIcon width={12.6667} height={18.3333} />\n          </span>\n        </button>\n        <button type=\"button\" data-slot=\"compose\" aria-label=\"New message\" onClick={onCompose}\n          className=\"relative shrink-0 rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\" style={{ marginLeft: 12, width: 48, height: 48 }}>\n          <GlassLayers round clip=\"left\" />\n          <svg aria-hidden=\"true\" className=\"absolute\" style={{ left: 14, top: 13 }} width=\"21.3333\" height=\"24\" viewBox=\"0 -2.3333 21.3333 24\" fill=\"none\" stroke=\"var(--ios-list-glyph)\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M15 0.9167H4.1167A3.2 3.2 0 0 0 0.9167 4.1167V14.8833A3.2 3.2 0 0 0 4.1167 18.0833H14.8833A3.2 3.2 0 0 0 18.0833 14.8833V4\" strokeWidth=\"1.8333\" />\n            <path d=\"M8.427 10.623 17.45 1.6\" strokeWidth=\"2.1\" />\n            <circle cx=\"20\" cy=\"-1\" r=\"0.6\" strokeWidth=\"1.4\" />\n          </svg>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-conversation-list.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-new-message-sheet",
      "title": "iOS new message sheet",
      "description": "The New Message sheet with its close button and To field.",
      "files": [
        {
          "path": "registry/imessage/ios-new-message-sheet.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 \"New Message\" sheet, measured from `references/ios/captures/newmsg-light.png` (402×874).\n * Rendered as an overlay inside the screen frame: the presenting screen is dimmed by 20% black\n * (white becomes #cccccc; the status bar stays visible under the dim, as on the device); the sheet\n * starts at y 62 with 38pt round top corners. Title 17pt bold with its cap height centered on\n * y 100; X glass button Ø44 centered (364, 100) with a 17.33pt cross; \"To:\" field x 16–386,\n * y 142–190 (continuous corners, radius 24) with 15pt text and a Ø27.67 #e7e7e8 \"+\" button centered\n * (360.67, 167.17). `children` render at the sheet's bottom (the capture shows the composer there).\n *\n * The \"To:\" field is not the same glass as the X button: the button's fill reads a flat #ffffff over\n * the sheet while the field reads #fdfdfd under a 0.67pt pure-white rim inset on all four sides\n * (measured on every edge of `newmsg-light.png`), which is why it carries its own fill and rim vars.\n * Dark values are standard system colors, not measured.\n *\n * Sampling the flat interiors of `newmsg-light.png` against our render agrees to within 0.02 of a\n * level per channel on all four surfaces: the dim over the presenting screen 204, the X button's glass\n * 255 either side of its cross, the \"To:\" field 253, and the sheet's own fill 255. What that cannot\n * see is the X button's alpha. Its only backdrop in the capture is the flat #ffffff sheet, so\n * rgba(255,255,255,a) composites to #ffffff for every a, and the capture pins the fill to white while\n * leaving 0.9 unverified. The \"To:\" field carries a flat #fdfdfd instead of a translucent fill for the\n * same reason: over this sheet the two are indistinguishable.\n */\nexport type IosNewMessageSheetProps = Omit<ComponentProps<\"div\">, \"onChange\"> & {\n  title?: string;\n  value?: string;\n  onChange?: (value: string) => void;\n  onClose?: () => void;\n  onAddContact?: () => void;\n  /** Draw a static caret in the \"To:\" field (the capture shows one although the field is idle). */\n  caret?: boolean;\n  children?: ReactNode;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst vars =\n  \"[--ios-nm-dim:rgba(0,0,0,0.2)] [--ios-nm-sheet:#ffffff] [--ios-nm-label:#000000] [--ios-nm-glyph:#1a1919] [--ios-nm-glass:rgba(255,255,255,0.9)] [--ios-nm-rim:none] [--ios-nm-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-nm-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] \" +\n  \"[--ios-nm-to:#8a8a8a] [--ios-nm-plus:#e7e7e8] [--ios-nm-plus-glyph:#000000] [--ios-nm-caret:#ced8fa] \" +\n  \"dark:[--ios-nm-dim:rgba(0,0,0,0.5)] dark:[--ios-nm-sheet:#1c1c1e] dark:[--ios-nm-label:#ffffff] dark:[--ios-nm-glyph:#f4f3f4] dark:[--ios-nm-glass:rgba(44,44,46,0.9)] dark:[--ios-nm-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-nm-shadow:none] dark:[--ios-nm-shadow-round:none] \" +\n  \"dark:[--ios-nm-to:#8d8d93] dark:[--ios-nm-plus:#3a3a3c] dark:[--ios-nm-plus-glyph:#ffffff] dark:[--ios-nm-caret:#3a4a7a]\";\n\n/** Apple's continuous corner (superellipse n≈2.2). Browsers without corner-shape fall back to round. */\nconst capsule = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/** `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the two shadows read as one (they never add up on the device). */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] [corner-shape:inherit]\" style={{ boxShadow: round ? \"var(--ios-nm-shadow-round)\" : \"var(--ios-nm-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] [corner-shape:inherit]\" style={{ background: \"var(--ios-nm-glass)\", boxShadow: \"var(--ios-nm-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n    </>\n  );\n}\n\nexport function IosNewMessageSheet({ title = \"New Message\", value, onChange, onClose, onAddContact, caret = false, children, className, style, ...props }: IosNewMessageSheetProps) {\n  const id = useId();\n  // Escape dismisses the sheet, the same as the close button.\n  useEffect(() => {\n    if (!onClose) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onClose(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [onClose]);\n  return (\n    <div data-slot=\"ios-new-message-sheet\" role=\"dialog\" aria-modal=\"true\" aria-labelledby={`${id}-title`}\n      className={cn(\"absolute inset-0 select-none\", vars, className)} style={{ fontFamily: font, background: \"var(--ios-nm-dim)\", ...style }} {...props}>\n      <div data-slot=\"sheet\" className=\"absolute isolate\" style={{ top: 62, left: 0, right: 0, bottom: 0, borderRadius: \"38px 38px 0 0\", background: \"var(--ios-nm-sheet)\" }}>\n        <h2 id={`${id}-title`} data-slot=\"title\" className=\"absolute m-0 text-center\" style={{ left: 0, right: 0, top: 29.6667, fontSize: 17, lineHeight: 1, fontWeight: 700, letterSpacing: 0, color: \"var(--ios-nm-label)\" }}>\n          {title}\n        </h2>\n        <button type=\"button\" data-slot=\"close\" aria-label=\"Close\" onClick={onClose}\n          className=\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\"\n          style={{ right: 16, top: 16, width: 44, height: 44 }}>\n          <GlassLayers round />\n          <svg aria-hidden=\"true\" className=\"relative\" width=\"44\" height=\"44\" viewBox=\"0 0 44 44\" fill=\"none\" stroke=\"var(--ios-nm-glyph)\" strokeWidth=\"2.1\" strokeLinecap=\"round\">\n            <path d=\"M14.05 14.72 29.28 29.95M29.28 14.72 14.05 29.95\" />\n          </svg>\n        </button>\n        <div data-slot=\"to-field\" className=\"absolute flex items-center rounded-full [--ios-nm-glass:#fdfdfd] [--ios-nm-rim:inset_0_0_0_0.6667px_#ffffff] dark:[--ios-nm-glass:rgba(44,44,46,0.9)] dark:[--ios-nm-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)]\" style={{ left: 16, right: 16, top: 80, height: 48, paddingLeft: 14, ...capsule }}>\n          <GlassLayers />\n          <label htmlFor={`${id}-to`} data-slot=\"to-label\" className=\"relative\" style={{ transform: \"translateY(-0.6667px)\", fontSize: 15, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-nm-to)\" }}>To:</label>\n          {caret && <span aria-hidden=\"true\" data-slot=\"caret\" className=\"relative origin-left\" style={{ marginLeft: 7, width: 2, height: 20, transform: \"translateX(-0.3333px) scaleX(0.8333)\", background: \"var(--ios-nm-caret)\", borderRadius: 1 }} />}\n          <input id={`${id}-to`} type=\"text\" autoComplete=\"off\" value={value} onChange={event => onChange?.(event.target.value)}\n            className=\"relative min-w-0 flex-1 border-0 bg-transparent outline-none select-text\"\n            style={{ marginLeft: caret ? 2 : 6, marginRight: 48, height: 20, padding: 0, fontFamily: font, fontSize: 15, lineHeight: \"20px\", color: \"var(--ios-nm-label)\", caretColor: \"#0088ff\" }} />\n          <button type=\"button\" data-slot=\"add\" aria-label=\"Add contact\" onClick={onAddContact}\n            className=\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\"\n            style={{ right: 11.6667, top: 11.3333, width: 27.6667, height: 27.6667, background: \"var(--ios-nm-plus)\" }}>\n            <svg aria-hidden=\"true\" width=\"27.6667\" height=\"27.6667\" viewBox=\"0 0 27.6667 27.6667\" fill=\"none\" stroke=\"var(--ios-nm-plus-glyph)\" strokeWidth=\"2.2\" strokeLinecap=\"round\">\n              <path d=\"M8.27 13.83H19.4M13.83 8.27V19.4\" />\n            </svg>\n          </button>\n        </div>\n        {children && <div data-slot=\"footer\" className=\"absolute\" style={{ left: 0, right: 0, bottom: 0 }}>{children}</div>}\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-new-message-sheet.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "palette",
      "title": "Palette",
      "description": "Emits the measured light and dark palette as CSS variables for one platform.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/palette.tsx",
          "content": "import { palettes, paletteVars } from \"@/components/imessage/tokens\";\nimport { tapbackVars } from \"@/components/imessage/tapback\";\nimport type { Platform } from \"@/components/imessage/platform\";\n\nfunction block(selector: string, vars: Record<string, string>) {\n  return `${selector}{${Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(\";\")}}`;\n}\n\n/**\n * Emits the measured palette as CSS custom properties for one platform, in both themes, scoped to\n * `[data-im-platform=\"ios\"|\"macos\"]`. Dark values apply under a `.dark` ancestor (the same rule the\n * rest of the registry uses). Render it once per app shell; it is tiny and idempotent.\n *\n * It emits `tapbackVars` too. Those are the measured glass, dim and menu colours the long-press\n * overlay and both context menus read, and until now only the tapback lab spread them, so every\n * shell and every harness scenario silently fell back to the hardcoded light-iOS defaults baked into\n * the components. A dark long-press menu was drawing light-theme glass because of it.\n */\nexport function PaletteStyle({ platform }: { platform: Platform }) {\n  const scope = `[data-im-platform=\"${platform}\"]`;\n  const css =\n    block(scope, { ...paletteVars(palettes[platform].light), ...tapbackVars(\"light\", platform) }) +\n    block(`.dark ${scope}`, { ...paletteVars(palettes[platform].dark), ...tapbackVars(\"dark\", platform) });\n  return <style data-slot=\"palette\" data-platform={platform}>{css}</style>;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/palette.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-messages-app",
      "title": "iOS Messages app",
      "description": "The complete iOS 26 Messages app: list, conversation, New Message sheet, and the long-press overlay.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/ios-composer.json",
        "https://imessage.swerdlow.dev/r/ios-conversation-list.json",
        "https://imessage.swerdlow.dev/r/ios-effects-picker.json",
        "https://imessage.swerdlow.dev/r/ios-nav-bar.json",
        "https://imessage.swerdlow.dev/r/ios-new-message-sheet.json",
        "https://imessage.swerdlow.dev/r/ios-status-bar.json",
        "https://imessage.swerdlow.dev/r/link-preview.json",
        "https://imessage.swerdlow.dev/r/message-actions.json",
        "https://imessage.swerdlow.dev/r/message-attachment.json",
        "https://imessage.swerdlow.dev/r/message-audio.json",
        "https://imessage.swerdlow.dev/r/message-bubble.json",
        "https://imessage.swerdlow.dev/r/message-image.json",
        "https://imessage.swerdlow.dev/r/message-list.json",
        "https://imessage.swerdlow.dev/r/message-motion.json",
        "https://imessage.swerdlow.dev/r/message-reply.json",
        "https://imessage.swerdlow.dev/r/palette.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tapback-bar.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/ios-messages-app.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { PlatformProvider } from \"@/components/imessage/platform\";\nimport { PaletteStyle } from \"@/components/imessage/palette\";\nimport { IosStatusBar } from \"@/components/imessage/ios-status-bar\";\nimport { IosNavBar } from \"@/components/imessage/ios-nav-bar\";\nimport { IosComposer } from \"@/components/imessage/ios-composer\";\nimport { IosConversationList, type IosConversation } from \"@/components/imessage/ios-conversation-list\";\nimport { IosNewMessageSheet } from \"@/components/imessage/ios-new-message-sheet\";\nimport { MessageList, messageListMetrics, type Message, type MessageListHandle } from \"@/components/imessage/message-list\";\nimport { isEmojiOnly, MessageBubble } from \"@/components/imessage/message-bubble\";\nimport { LinkPreview } from \"@/components/imessage/link-preview\";\nimport { MessageAttachment } from \"@/components/imessage/message-attachment\";\nimport { MessageAudio } from \"@/components/imessage/message-audio\";\nimport { MessageImages } from \"@/components/imessage/message-image\";\nimport { bubbleMetrics, emojiFontStack, fontStack } from \"@/components/imessage/tokens\";\nimport { ReplyThread, replyThreadMetrics, replyThreadMotion } from \"@/components/imessage/message-reply\";\nimport { Tapback, type TapbackType } from \"@/components/imessage/tapback\";\nimport { MessageActions, type Rect } from \"@/components/imessage/message-actions\";\nimport { useArrivalAnimation, type ArrivalAnimation } from \"@/components/imessage/message-motion\";\nimport { IosEffectsPicker, type EffectsPickerSelection } from \"@/components/imessage/ios-effects-picker\";\nimport type { TapbackSelection } from \"@/components/imessage/tapback-bar\";\n\n/**\n * `listTop` puts the first date header's ink at y 172.67 (conv3-light.png). `listBottom` is the composer's\n * 68pt band plus the 28pt it keeps clear: in the one scrolled iOS capture (dateheader-mid-light.png) the\n * last row's ink bottom is at 778 with the composer field starting at 806.\n */\nexport const iosScreen = { width: 402, height: 874, statusBar: 54, navBar: 94, listTop: 169.5, composer: 68, listBottom: 96 } as const;\n\nexport type IosScreen = \"list\" | \"conversation\" | \"new-message\";\n\n/**\n * Moving between screens. A conversation is pushed in from the trailing edge while the list follows\n * it part of the way out and dims under it; going back plays that backwards; the New Message sheet\n * comes up from the bottom while its dim fades in.\n *\n * UNVERIFIED. Nothing in `references/` captures a screen change, so not one of these numbers is\n * measured. They are kept in the family of the motion that is: the send flight that settles by\n * ~520 ms, the 380 ms long-press entrance, the 260 ms effects screen and its 320 ms move. Replace\n * them from a recording before describing any of it as measured.\n *\n * Each one is a Web Animations timeline rather than a transition or a rAF loop, so\n * `screenTransition.progress` can seek a frame instead of playing it, and `document.getAnimations()`\n * reaches it (which is how the harness freezes a checkpoint).\n */\nexport const iosScreenTransition = {\n  push: 350,\n  pop: 320,\n  present: 400,\n  dismiss: 280,\n  /** How far the screen underneath follows the one on top, as a share of the screen width. */\n  parallax: 0.3,\n  /** Black over the screen that slid back, at the end of the push. */\n  dim: 0.14,\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n} as const;\n\nexport type IosScreenTransitionKind = \"push\" | \"pop\" | \"present\" | \"dismiss\";\n\n/**\n * Which transition takes the app from one screen to another. The list and a conversation are a\n * navigation stack; the sheet is presented over whichever of them is showing. Going from the sheet\n * straight into a conversation (a recipient was chosen) pushes from the list underneath it, so the\n * sheet leaves with the screen it was presented over.\n */\nexport function screenTransitionKind(from: IosScreen, to: IosScreen): IosScreenTransitionKind | null {\n  if (from === to) return null;\n  if (to === \"new-message\") return \"present\";\n  if (from === \"new-message\" && to === \"list\") return \"dismiss\";\n  return to === \"conversation\" ? \"push\" : \"pop\";\n}\n\nexport type IosMessagesAppProps = {\n  width?: number;\n  height?: number;\n  /** Status bar clock. */\n  time?: string;\n  screen?: IosScreen;\n  conversations?: IosConversation[];\n  contact: { name: string; initials?: string };\n  group?: boolean;\n  messages: Message[];\n  typing?: boolean | { sender?: string };\n  /** Reference time for \"Today\"/\"Yesterday\". */\n  now?: Date | number;\n  composer?: { value?: string; placeholder?: string; disabled?: boolean; onChange?: (value: string) => void; onSend?: (text: string) => void | Promise<void>; onAttach?: () => void };\n  /**\n   * Scrub a screen change instead of playing it: the app is on `screen`, arriving from `from`, and\n   * `progress` (0..1) seeks the push, the pop or the sheet. Leave it out and the app runs the\n   * transition itself whenever `screen` changes, which is what an application wants; a harness that\n   * renders one frame at a time has nothing to observe changing and states it instead.\n   */\n  screenTransition?: { from: IosScreen; progress: number } | null;\n  onBack?: () => void;\n  onSelectConversation?: (conversation: IosConversation) => void;\n  onCompose?: () => void;\n  onCloseNewMessage?: () => void;\n  onDetails?: () => void;\n  /**\n   * The open thread: the message whose replies are showing. The conversation blurs behind it. Its\n   * members are the message itself and every message replying to it, so the caller only names the\n   * root. `progress` (0..1) seeks the entrance rather than playing it.\n   */\n  thread?: { rootId: string; progress?: number } | null;\n  /** The reply count under a message was activated. */\n  onOpenThread?: (id: string) => void;\n  /** The thread was dismissed. The overlay stays up for its exit after `thread` clears. */\n  onCloseThread?: () => void;\n  /**\n   * That message was just sent: the composer's text row becomes its bubble and flies to its slot.\n   * With `progress` the animation is seeked to `progress * duration` instead of played.\n   */\n  sendAnimation?: ArrivalAnimation | null;\n  /** That message just arrived: it pops in from the typing indicator's position. */\n  receiveAnimation?: ArrivalAnimation | null;\n  /** Called when a played (not seeked) send animation finishes. */\n  onSendAnimationEnd?: () => void;\n  /** Open the long-press overlay on a message; `progress` scrubs the entrance (0–1). */\n  longPress?: { id: string; progress?: number } | null;\n  onLongPress?: (id: string) => void;\n  onLongPressClose?: () => void;\n  onTapback?: (id: string, selection: TapbackSelection) => void;\n  onMenuAction?: (id: string, action: string) => void;\n  /**\n   * The \"Send with effect\" screen. iOS opens it on a press and hold of the send button, which is what\n   * `onEffectsPickerOpen` reports; pass the state back here to show it.\n   */\n  effectsPicker?: { tab?: \"bubble\" | \"screen\"; selection?: EffectsPickerSelection; draft?: string; progress?: number } | null;\n  onEffectsPickerOpen?: (draft: string) => void;\n  onEffectsTabChange?: (tab: \"bubble\" | \"screen\") => void;\n  onEffectSelect?: (selection: EffectsPickerSelection) => void;\n  onSendWithEffect?: (text: string, selection: EffectsPickerSelection) => void;\n  onEffectsPickerClose?: () => void;\n  /** Extra overlays (details screen, plus menu, selection toolbar) rendered above everything. */\n  overlay?: ReactNode;\n  /** Render reactions for a message; defaults to the message's `reactions` as Tapback balloons. */\n  renderReactions?: (message: Message) => ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  /** Exposes the device frame element (for screen-space measurements and overlays). */\n  frameRef?: RefObject<HTMLDivElement | null>;\n};\n\nexport function defaultReactions(message: Message): ReactNode {\n  if (!message.reactions?.length) return undefined;\n  const outgoing = message.direction === \"outgoing\";\n  return (\n    <div data-slot=\"reaction-stack\" className=\"flex\" style={{ gap: 2 }}>\n      {message.reactions.map((reaction, index) => (\n        <Tapback key={index} reaction={reaction.emoji ? undefined : (reaction.type as TapbackType)} emoji={reaction.emoji} own={reaction.byMe ?? true} side={outgoing ? \"left\" : \"right\"} />\n      ))}\n    </div>\n  );\n}\n\n/**\n * A message's body box, whichever kind it is. Every kind draws its own slot and only a text or audio\n * message has a `bubble`, so measuring that alone left an emoji-only message, a photo, a link card and\n * a file card with no rect at all, and the long-press overlay never opened on any of them.\n * `ios-select-mode.tsx`, `ios-swipe-times.tsx` and `message-motion.tsx` measure the same way.\n */\nconst messageBodySelector = ['[data-slot=\"bubble\"]', '[data-slot=\"emoji\"]', '[data-slot=\"image-grid\"]', '[data-slot=\"link-preview\"]', '[data-slot=\"message-attachment\"]'].join(\", \");\n\n/**\n * What the lifted copy replaces, so the original is not drawn twice. One entry per body above, taking\n * the whole wrapper where the body has one (`bubble-frame` carries the tail and the balloons,\n * `message-images` carries the photo tail), plus the delivery label, which the overlay does not lift.\n */\nconst messageBodyHiddenSlots = [\"bubble-frame\", \"emoji\", \"message-images\", \"link-preview\", \"message-attachment\", \"status\"];\n\n/**\n * A quoted reply stub draws a scaled copy of the quoted message, tail and all, above the message that\n * replies. It is the row's first `[data-slot=\"bubble\"]`, so the overlay used to lift the quotation\n * instead of the message: measured on the harness's `reply` scene, the stub's 157.97×30.39 box at\n * y 570.36 instead of the message's own 133.44×40 at y 605.08.\n */\nconst insideQuotedStub = (element: HTMLElement) => Boolean(element.closest(\"[data-stub]\"));\n\n/**\n * The whole iOS 26 Messages app in a 402×874 frame: status bar, the conversation list, the conversation\n * screen (nav bar, message log, composer), the New Message sheet, and the long-press overlay.\n * Data and navigation stay with the caller; this component only draws state.\n */\nexport function IosMessagesApp({\n  width = iosScreen.width, height = iosScreen.height, time = \"9:41\", screen = \"conversation\", conversations = [], contact, group = false,\n  messages, typing = false, now, composer, screenTransition, onBack, onSelectConversation, onCompose, onCloseNewMessage, onDetails,\n  thread, onOpenThread, onCloseThread,\n  sendAnimation, receiveAnimation, onSendAnimationEnd,\n  longPress, onLongPress, onLongPressClose, onTapback, onMenuAction,\n  effectsPicker, onEffectsPickerOpen, onEffectsTabChange, onEffectSelect, onSendWithEffect, onEffectsPickerClose,\n  overlay, renderReactions = defaultReactions, className, style, frameRef,\n}: IosMessagesAppProps) {\n  const localFrame = useRef<HTMLDivElement>(null);\n  const frame = frameRef ?? localFrame;\n  const list = useRef<MessageListHandle>(null);\n  const [pressedRect, setPressedRect] = useState<{ id: string; rect: Rect; tail: boolean } | null>(null);\n  const pressed = longPress ? messages.find(message => message.id === longPress.id) : undefined;\n  const pressedId = pressed?.id;\n  // The overlay stays mounted for its exit timeline after `longPress` clears. Deriving this during\n  // render rather than in an effect matters: an effect would leave one committed frame with the\n  // overlay already gone, and the dismissal would never be seen.\n  const [seenPressedId, setSeenPressedId] = useState<string | null>(pressedId ?? null);\n  const [closing, setClosing] = useState<string | null>(null);\n  if (seenPressedId !== (pressedId ?? null)) {\n    setSeenPressedId(pressedId ?? null);\n    setClosing(pressedId ? null : seenPressedId);\n  }\n  const overlayId = pressedId ?? closing;\n  const overlayMessage = overlayId ? messages.find(message => message.id === overlayId) : undefined;\n\n  // Same derive-during-render rule as the long-press overlay: the effects screen has to outlive the\n  // prop that opened it or its exit never gets a committed frame to run in.\n  const pickerOpen = effectsPicker != null;\n  const [seenPickerOpen, setSeenPickerOpen] = useState(pickerOpen);\n  const [pickerClosing, setPickerClosing] = useState(false);\n  const [capturedDraft, setCapturedDraft] = useState(\"\");\n  if (seenPickerOpen !== pickerOpen) {\n    setSeenPickerOpen(pickerOpen);\n    setPickerClosing(!pickerOpen && seenPickerOpen);\n  }\n  const draft = effectsPicker?.draft ?? composer?.value ?? capturedDraft;\n\n  // A screen change is a transition, not a cut, so the screen that is leaving has to outlive the\n  // prop that dismissed it. Same rule as the two overlays above: the previous screen is captured\n  // during render, because an effect would leave one committed frame with it already unmounted and\n  // the push out would never run.\n  const [seenScreen, setSeenScreen] = useState<IosScreen>(screen);\n  const [nav, setNav] = useState<{ from: IosScreen; to: IosScreen; run: number } | null>(null);\n  if (seenScreen !== screen) {\n    setSeenScreen(screen);\n    setNav(screenTransition ? null : current => ({ from: seenScreen, to: screen, run: (current?.run ?? 0) + 1 }));\n  }\n  // A stated transition replaces a derived one. Leaving the derived one behind would replay it the\n  // moment the caller stopped stating transitions.\n  if (screenTransition && nav) setNav(null);\n  const moving = screenTransition\n    ? { from: screenTransition.from, kind: screenTransitionKind(screenTransition.from, screen), run: -1, progress: screenTransition.progress }\n    : nav\n      ? { from: nav.from, kind: screenTransitionKind(nav.from, nav.to), run: nav.run, progress: undefined }\n      : null;\n  const kind = moving?.kind ?? null;\n  const from = kind ? moving!.from : null;\n  const showList = screen === \"list\" || screen === \"new-message\" || from === \"list\" || from === \"new-message\";\n  const showConversation = screen === \"conversation\" || from === \"conversation\";\n  const showSheet = screen === \"new-message\" || kind === \"dismiss\";\n  const listLayer = useRef<HTMLDivElement>(null);\n  const conversationLayer = useRef<HTMLDivElement>(null);\n  const screenDim = useRef<HTMLDivElement>(null);\n  const movingRun = moving?.run;\n  const movingProgress = moving?.progress;\n  useLayoutEffect(() => {\n    if (!kind) return;\n    const t = iosScreenTransition;\n    const duration = t[kind];\n    const running: Animation[] = [];\n    const play = (element: Element | null | undefined, keyframes: Keyframe[]) => {\n      if (element) running.push(element.animate(keyframes, { duration, easing: t.ease, fill: \"both\" }));\n    };\n    // A pop is the push played backwards and a dismissal is the sheet's presentation played\n    // backwards, so each pair is written once, in the forward direction, and the frames are swapped.\n    const forward = kind === \"push\" || kind === \"present\";\n    const order = (a: Keyframe, b: Keyframe) => (forward ? [a, b] : [b, a]);\n    if (kind === \"push\" || kind === \"pop\") {\n      play(conversationLayer.current, order({ transform: \"translateX(100%)\" }, { transform: \"translateX(0%)\" }));\n      play(listLayer.current, order({ transform: \"translateX(0%)\" }, { transform: `translateX(${-100 * t.parallax}%)` }));\n      play(screenDim.current, order({ opacity: 0 }, { opacity: t.dim }));\n    } else {\n      // The sheet paints its own dim on its root and holds the panel inside it, so the panel slides\n      // and the dim fades separately. Reading the resting colour keeps the fade theme-correct\n      // without this file knowing what the sheet's dim is.\n      const sheet = frame.current?.querySelector<HTMLElement>('[data-slot=\"ios-new-message-sheet\"]');\n      const panel = sheet?.querySelector<HTMLElement>('[data-slot=\"sheet\"]');\n      const dim = sheet ? getComputedStyle(sheet).backgroundColor : \"rgba(0, 0, 0, 0)\";\n      play(panel, order({ transform: \"translateY(100%)\" }, { transform: \"translateY(0%)\" }));\n      play(sheet, order({ backgroundColor: \"rgba(0, 0, 0, 0)\" }, { backgroundColor: dim }));\n    }\n    if (movingProgress !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      const at = Math.max(0, Math.min(1, movingProgress)) * duration;\n      for (const animation of running) { animation.pause(); animation.currentTime = at; }\n      return () => { for (const animation of running) animation.cancel(); };\n    }\n    const last = running[running.length - 1];\n    const done = () => setNav(null);\n    last?.addEventListener(\"finish\", done);\n    // Reduced motion cuts to the destination: the same timelines, jumped to their end, so the screen\n    // that is leaving still unmounts through the same finish.\n    if (typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) {\n      for (const animation of running) animation.finish();\n    }\n    return () => {\n      last?.removeEventListener(\"finish\", done);\n      for (const animation of running) animation.cancel();\n    };\n  }, [kind, from, movingRun, movingProgress, frame]);\n\n  // The thread overlay follows the same derive-during-render rule so its exit gets a frame to run in.\n  const threadRootId = thread?.rootId ?? null;\n  const [seenThread, setSeenThread] = useState<string | null>(threadRootId);\n  const [threadClosing, setThreadClosing] = useState<string | null>(null);\n  if (seenThread !== threadRootId) {\n    setSeenThread(threadRootId);\n    setThreadClosing(threadRootId ? null : seenThread);\n  }\n  const threadId = threadRootId ?? threadClosing;\n  const threadRoot = threadId ? messages.find(message => message.id === threadId) : undefined;\n  const threadReplies = threadId ? messages.filter(message => message.replyTo?.id === threadId) : [];\n  const threadOpen = threadRootId !== null;\n  const threadShown = Boolean(threadRoot);\n  const threadProgress = thread?.progress;\n  // The thread's blur belongs to the conversation, not to the overlay. `ReplyThread` blurs either a\n  // copy of the conversation passed as `backdrop` or, with neither, whatever `backdrop-filter`\n  // samples, and it documents why the second is unreliable in a capture. This is the third way: the\n  // conversation is already its own layer here, so blurring that layer is a real filter with no\n  // second copy of the log in the DOM (a copy would duplicate every `data-message-id`, which the\n  // long-press measurement below looks up by). `blur={0}` on the overlay leaves the job here, and\n  // the ramp copies the overlay's own: the dim's duration, seeked on the entrance's clock.\n  useLayoutEffect(() => {\n    const element = conversationLayer.current;\n    if (!element || !threadShown) return;\n    const blur = replyThreadMetrics.ios.blur;\n    const animation = threadOpen\n      ? element.animate([{ filter: \"blur(0px)\" }, { filter: `blur(${blur}px)` }], { duration: replyThreadMotion.dim, easing: \"ease-out\", fill: \"both\" })\n      : element.animate([{ filter: `blur(${blur}px)` }, { filter: \"blur(0px)\" }], { duration: replyThreadMotion.exit, easing: replyThreadMotion.exitEase, fill: \"both\" });\n    if (threadOpen && threadProgress !== undefined) {\n      animation.pause();\n      animation.currentTime = Math.max(0, Math.min(1, threadProgress)) * replyThreadMotion.enter;\n    } else if (typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) {\n      // The overlay jumps to its settled frame under reduced motion; the blur goes with it.\n      animation.finish();\n    }\n    return () => animation.cancel();\n  }, [threadShown, threadOpen, threadProgress]);\n\n  useArrivalAnimation({ frame, send: sendAnimation, receive: receiveAnimation, onSendEnd: onSendAnimationEnd });\n\n  // Measure the pressed bubble's body inside the frame so the overlay can lift a copy of it in place.\n  // The first render after `longPress` opens has no layout yet, so measure on the next frame; the\n  // observer keeps the rect right when the list scrolls, wraps, or fonts settle.\n  useLayoutEffect(() => {\n    if (!pressedId) return;\n    let raf = 0;\n    const measure = () => {\n      const root = frame.current;\n      const row = root?.querySelector<HTMLElement>(`[data-message-id=\"${pressedId}\"]`);\n      if (!root || !row) return;\n      // Every body this message drew, minus anything inside its quoted stub. A file message can carry\n      // several cards, so take the union rather than the first: the lift covers all of them.\n      const bodies = Array.from(row.querySelectorAll<HTMLElement>(messageBodySelector)).filter(element => !insideQuotedStub(element));\n      if (!bodies.length) return;\n      const f = root.getBoundingClientRect();\n      const boxes = bodies.map(element => element.getBoundingClientRect());\n      const left = Math.min(...boxes.map(b => b.left)), top = Math.min(...boxes.map(b => b.top));\n      const rect = { x: left - f.left, y: top - f.top, width: Math.max(...boxes.map(b => b.right)) - left, height: Math.max(...boxes.map(b => b.bottom)) - top };\n      // Read the tail off the message rather than assuming one: a bubble in the middle of a cluster\n      // has none, and an emoji-only message has no bubble to hang one from.\n      const tail = Array.from(row.querySelectorAll<HTMLElement>('[data-slot=\"tail\"]')).some(element => !insideQuotedStub(element));\n      setPressedRect(current => (current?.id === pressedId && current.tail === tail && Math.abs(current.rect.x - rect.x) < 0.5 && Math.abs(current.rect.y - rect.y) < 0.5 && Math.abs(current.rect.width - rect.width) < 0.5 ? current : { id: pressedId, rect, tail }));\n    };\n    const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); };\n    // Measure synchronously so the overlay is on screen at the first paint after the press. Deferring\n    // it to a frame makes the opened menu appear one frame late, which is invisible to a person but\n    // makes a screenshot of the opening checkpoint depend on timing.\n    measure();\n    const observer = new ResizeObserver(schedule);\n    if (frame.current) observer.observe(frame.current);\n    document.fonts?.ready.then(schedule).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); };\n  }, [pressedId, frame, messages]);\n  const pressedBody = pressedRect && pressedRect.id === overlayId ? pressedRect : null;\n\n  return (\n    <PlatformProvider platform=\"ios\">\n      <PaletteStyle platform=\"ios\" />\n      <div ref={frame} data-slot=\"ios-messages-app\" data-im-platform=\"ios\" data-screen={screen} data-transition={kind ?? undefined}\n        className={cn(\"relative isolate overflow-hidden select-none\", className)}\n        style={{ width, height, background: \"var(--im-bg)\", color: \"var(--im-incoming-text)\", fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style }}>\n        {/* Each screen is its own layer so a transition has something to move. At rest a layer\n            carries no transform: a transform node makes Chrome snap its descendants to whole CSS px,\n            which would move text by a fraction of a point against the captures. */}\n        {showList && (\n          <div ref={listLayer} data-slot=\"screen-list\" className=\"absolute inset-0\" style={{ pointerEvents: screen === \"conversation\" ? \"none\" : undefined }}>\n            <IosConversationList conversations={conversations} onSelect={onSelectConversation} onCompose={onCompose} topInset={iosScreen.statusBar} className=\"absolute inset-0\" />\n            {(kind === \"push\" || kind === \"pop\") && <div ref={screenDim} aria-hidden=\"true\" data-slot=\"screen-dim\" className=\"pointer-events-none absolute inset-0\" style={{ background: \"#000000\", opacity: 0 }} />}\n          </div>\n        )}\n        {showConversation && (\n          <div ref={conversationLayer} data-slot=\"screen-conversation\" className=\"absolute inset-0\" style={{ pointerEvents: screen === \"conversation\" ? undefined : \"none\" }}>\n            <MessageList ref={list} frameRef={frame} messages={messages} typing={typing} group={group} now={now} anchor=\"top\"\n              insetTop={iosScreen.listTop} insetBottom={iosScreen.listBottom} renderReactions={renderReactions} messageActions={Boolean(onLongPress)}\n              onOpenThread={onOpenThread} className=\"absolute inset-0\" />\n            <IosNavBar name={contact.name} initials={contact.initials} onBack={onBack} onDetails={onDetails} className=\"absolute left-0\" style={{ top: iosScreen.statusBar }} />\n            <IosComposer className=\"absolute bottom-0 left-0\" value={composer?.value} placeholder={composer?.placeholder} disabled={composer?.disabled}\n              onChange={composer?.onChange} onSend={composer?.onSend} onAttach={composer?.onAttach} />\n            {onLongPress && <LongPressLayer frame={frame} onLongPress={onLongPress} />}\n            {onEffectsPickerOpen && (\n              <SendHoldLayer\n                frame={frame}\n                onHold={value => {\n                  setCapturedDraft(value);\n                  onEffectsPickerOpen(value);\n                }}\n              />\n            )}\n          </div>\n        )}\n        {(pickerOpen || pickerClosing) && (\n          <IosEffectsPicker\n            tab={effectsPicker?.tab}\n            selection={effectsPicker?.selection ?? null}\n            open={pickerOpen}\n            progress={effectsPicker?.progress}\n            onExited={() => setPickerClosing(false)}\n            onTabChange={onEffectsTabChange}\n            onSelect={onEffectSelect}\n            onSend={selection => onSendWithEffect?.(draft, selection)}\n            onClose={onEffectsPickerClose}\n            preview={<MessageBubble direction=\"outgoing\" tail>{draft}</MessageBubble>}\n          />\n        )}\n        {/* The thread the reply count opened: the message it hangs off, then every message replying\n            to it, over the conversation layer the effect above blurs. Its bubbles are laid out by\n            the overlay rather than by the log, so they carry no screen-space fill position and take\n            the mid-screen colour; no capture shows a thread, so that is provisional with the rest. */}\n        {threadRoot && (\n          <ReplyThread open={threadOpen} progress={threadProgress} onExited={() => setThreadClosing(null)} onClose={onCloseThread} blur={0}\n            root={<ThreadRow message={threadRoot} tail />}>\n            {threadReplies.map((message, index) => (\n              // The measured cluster rule: only the last bubble of a run by one sender is tailed.\n              <ThreadRow key={message.id} message={message} tail={index === threadReplies.length - 1 || threadReplies[index + 1].direction !== message.direction} />\n            ))}\n          </ReplyThread>\n        )}\n        {/* The status bar stays crisp above the effects screen, the way it does natively. */}\n        <IosStatusBar time={time} className=\"absolute left-0 top-0\" />\n        {showSheet && (\n          <IosNewMessageSheet onClose={onCloseNewMessage} caret style={{ pointerEvents: screen === \"new-message\" ? undefined : \"none\" }}>\n            <IosComposer placeholder=\"\" value={composer?.value} onChange={composer?.onChange} onSend={composer?.onSend} />\n          </IosNewMessageSheet>\n        )}\n        {/* The overlay lifts a copy of the pressed message at the same spot, so hide the original. */}\n        {overlayMessage && pressedBody && <style>{messageBodyHiddenSlots.map(slot => `[data-message-id=\"${overlayMessage.id}\"] [data-slot=\"${slot}\"]`).join(\",\")}{\"{visibility:hidden}\"}</style>}\n        {overlayMessage && pressedBody && (\n          <MessageActions rect={pressedBody.rect} frame={{ width, height }} direction={overlayMessage.direction} service={overlayMessage.service ?? \"imessage\"}\n            progress={longPress?.progress} autoFocus={longPress?.progress === undefined && !closing}\n            open={!closing} onExited={() => setClosing(null)}\n            // The menu's glass picks up the bubble behind it, and a message with no bubble has none to\n            // give: an emoji-only message, a photo, a link card and a file card all take the plain glass.\n            wash={liftsABubble(overlayMessage) ? undefined : null}\n            selected={overlayMessage.reactions?.find(r => r.byMe) ? (overlayMessage.reactions.find(r => r.byMe)!.emoji ? { emoji: overlayMessage.reactions.find(r => r.byMe)!.emoji! } : { type: overlayMessage.reactions.find(r => r.byMe)!.type as TapbackType }) : undefined}\n            onSelect={selection => onTapback?.(overlayMessage.id, selection)} onAction={action => onMenuAction?.(overlayMessage.id, action)} onClose={onLongPressClose}>\n            <LiftedMessage message={overlayMessage} tail={pressedBody.tail} screenBottom={pressedBody.rect.y + pressedBody.rect.height} />\n          </MessageActions>\n        )}\n        {overlay}\n      </div>\n    </PlatformProvider>\n  );\n}\n\n/**\n * Which of the log's row shapes a message takes. `message-list.tsx` picks in exactly this order, so a\n * photo whose caption happens to be one emoji is still a photo, and this has to agree with it or the\n * overlay would lift something the log never drew.\n */\ntype MessageShape = \"link\" | \"image\" | \"audio\" | \"attachment\" | \"emoji\" | \"bubble\";\n\nfunction messageShape(message: Message): MessageShape {\n  if (message.kind === \"link\" && message.link) return \"link\";\n  if (message.kind === \"image\" && message.images?.length) return \"image\";\n  if (message.kind === \"audio\" && message.audio) return \"audio\";\n  if (message.kind === \"attachment\" && message.attachments?.length) return \"attachment\";\n  return message.kind !== \"link\" && isEmojiOnly(message.text) ? \"emoji\" : \"bubble\";\n}\n\n/** Only these two shapes draw a coloured bubble; the rest carry their own surface, or none at all. */\nfunction liftsABubble(message: Message): boolean {\n  const shape = messageShape(message);\n  return shape === \"bubble\" || shape === \"audio\";\n}\n\n/**\n * The copy the long-press overlay lifts. It has to be the same thing the log drew, so it is built the\n * same way `message-list.tsx` builds a row: a photo message lifts its photos, a file message its cards,\n * an emoji-only message a bare glyph at the log's emoji metrics, and only text and audio lift a bubble.\n * Lifting `message.text` as a bubble for every kind put a balloon reading \"Photos\" where the picture is.\n *\n * The glyph drops the log's `emojiShift`: the shift moves the whole element box, the overlay measured\n * the box after it moved, and the copy is placed at that measured box, so re-applying it would double it.\n */\nfunction LiftedMessage({ message, tail, screenBottom }: { message: Message; tail: boolean; screenBottom: number }) {\n  const outgoing = message.direction === \"outgoing\";\n  switch (messageShape(message)) {\n    case \"link\":\n      return <LinkPreview href={message.link!.url} title={message.link!.title} host={message.link!.host} image={message.link!.image} />;\n    case \"image\":\n      return <MessageImages images={message.images!} direction={message.direction} tail={tail} />;\n    case \"audio\":\n      return <MessageAudio duration={message.audio!.duration} peaks={message.audio!.peaks} direction={message.direction} tail={tail} />;\n    case \"attachment\":\n      return (\n        <>\n          {message.attachments!.map((file, index) => (\n            <MessageAttachment key={file.name + index} name={file.name} size={file.size} href={file.href}\n              direction={message.direction} tail={tail && index === message.attachments!.length - 1}\n              style={index ? { marginTop: bubbleMetrics.ios.gapInGroup } : undefined} />\n          ))}\n        </>\n      );\n    case \"emoji\":\n      return (\n        <div data-slot=\"message-bubble\" data-direction={message.direction} data-platform=\"ios\" data-emoji-only=\"true\"\n          className={cn(\"flex min-w-0 flex-col\", outgoing ? \"items-end\" : \"items-start\")} style={{ width: \"100%\", fontFamily: fontStack }}>\n          <div data-slot=\"emoji\" style={{ fontSize: messageListMetrics.ios.emojiSize, lineHeight: `${messageListMetrics.ios.emojiLineHeight}px`, fontFamily: emojiFontStack, padding: `0 ${messageListMetrics.ios.emojiPadX}px`, whiteSpace: \"nowrap\" }}>\n            <span className=\"sr-only\">{outgoing ? \"You: \" : `${message.sender ?? \"Contact\"}: `}</span>{message.text.trim()}\n          </div>\n        </div>\n      );\n    default:\n      return <MessageBubble direction={message.direction} service={message.service ?? \"imessage\"} tail={tail} screenBottom={screenBottom}>{message.text}</MessageBubble>;\n  }\n}\n\n/** One message inside the thread overlay, on its own edge. */\nfunction ThreadRow({ message, tail }: { message: Message; tail: boolean }) {\n  return (\n    <div data-slot=\"thread-row\" data-direction={message.direction} className={cn(\"flex w-full\", message.direction === \"outgoing\" ? \"justify-end\" : \"justify-start\")}>\n      <MessageBubble direction={message.direction} service={message.service ?? \"imessage\"} tail={tail}>{message.text}</MessageBubble>\n    </div>\n  );\n}\n\n/**\n * Turns a press and hold on the composer's send button into `onHold(draft)`, and swallows the click\n * that a pointer release would otherwise fire so the message is not also sent.\n */\nfunction SendHoldLayer({ frame, onHold }: { frame: RefObject<HTMLDivElement | null>; onHold: (draft: string) => void }) {\n  const latest = useRef(onHold);\n  useEffect(() => { latest.current = onHold; }, [onHold]);\n  useEffect(() => {\n    const composer = frame.current?.querySelector<HTMLElement>('[data-slot=\"ios-composer\"]');\n    if (!composer) return;\n    // Delegate from the form, not the button: the send button only exists while the field has text,\n    // so a listener bound to it at mount would never see the composer's first message.\n    const sendAt = (target: EventTarget | null) => (target as HTMLElement | null)?.closest?.('[data-slot=\"send\"]') ?? null;\n    const draft = () => composer.querySelector<HTMLTextAreaElement>(\"textarea\")?.value ?? \"\";\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let held = false;\n    const cancel = () => { if (timer) clearTimeout(timer); timer = null; };\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.button !== 0 || !sendAt(event.target)) return;\n      held = false;\n      cancel();\n      timer = setTimeout(() => { cancel(); held = true; latest.current(draft()); }, 500);\n    };\n    const onClick = (event: MouseEvent) => {\n      if (!held || !sendAt(event.target)) return;\n      // The hold already opened the effects screen; do not also send the message.\n      event.preventDefault();\n      event.stopPropagation();\n      held = false;\n    };\n    const onContextMenu = (event: MouseEvent) => {\n      if (!sendAt(event.target)) return;\n      event.preventDefault();\n      cancel();\n      latest.current(draft());\n    };\n    composer.addEventListener(\"pointerdown\", onPointerDown);\n    composer.addEventListener(\"pointerup\", cancel);\n    composer.addEventListener(\"pointercancel\", cancel);\n    composer.addEventListener(\"pointerleave\", cancel);\n    composer.addEventListener(\"click\", onClick, true);\n    composer.addEventListener(\"contextmenu\", onContextMenu);\n    return () => {\n      cancel();\n      composer.removeEventListener(\"pointerdown\", onPointerDown);\n      composer.removeEventListener(\"pointerup\", cancel);\n      composer.removeEventListener(\"pointercancel\", cancel);\n      composer.removeEventListener(\"pointerleave\", cancel);\n      composer.removeEventListener(\"click\", onClick, true);\n      composer.removeEventListener(\"contextmenu\", onContextMenu);\n    };\n  }, [frame]);\n  return null;\n}\n\n/** Turns a long press (or right-click / double-click) on any bubble in the frame into `onLongPress(id)`. */\nfunction LongPressLayer({ frame, onLongPress }: { frame: RefObject<HTMLDivElement | null>; onLongPress: (id: string) => void }) {\n  const latest = useRef(onLongPress);\n  useEffect(() => { latest.current = onLongPress; }, [onLongPress]);\n  // Bind from an effect, not from a ref callback: React attaches a child's ref before its ancestors',\n  // so `frame.current` is still null while this layer's ref runs and the listeners would never land.\n  // The effect also removes them again, so a remounted log is not left with a stale binding.\n  useEffect(() => {\n    const log = frame.current?.querySelector<HTMLElement>('[data-slot=\"message-list\"]');\n    if (!log) return;\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let target: string | null = null;\n    let origin = { x: 0, y: 0 };\n    const cancel = () => { if (timer) clearTimeout(timer); timer = null; target = null; };\n    const idAt = (element: EventTarget | null) => (element as HTMLElement | null)?.closest?.(\"[data-message-id]\")?.getAttribute(\"data-message-id\") ?? null;\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.button !== 0) return;\n      const id = idAt(event.target);\n      if (!id) return;\n      cancel(); target = id; origin = { x: event.clientX, y: event.clientY };\n      timer = setTimeout(() => { const pressedId = target; cancel(); if (pressedId) latest.current(pressedId); }, 500);\n    };\n    const onPointerMove = (event: PointerEvent) => { if (timer && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel(); };\n    const onContextMenu = (event: MouseEvent) => { const id = idAt(event.target); if (id) { event.preventDefault(); cancel(); latest.current(id); } };\n    const onDoubleClick = (event: MouseEvent) => { const id = idAt(event.target); if (id) latest.current(id); };\n    // The keyboard equivalent of the hold: the log's arrow keys focus a message, and these keys open\n    // its actions. Without this the overlay would be reachable by pointer only.\n    const onKeyDown = (event: KeyboardEvent) => {\n      const row = event.target as HTMLElement | null;\n      if (!row?.matches?.('[data-slot=\"message-row\"][data-message-id]')) return;\n      if (event.key !== \"Enter\" && event.key !== \" \" && event.key !== \"ContextMenu\" && !(event.key === \"F10\" && event.shiftKey)) return;\n      const id = idAt(row);\n      if (!id) return;\n      event.preventDefault();\n      cancel();\n      latest.current(id);\n    };\n    log.addEventListener(\"pointerdown\", onPointerDown);\n    log.addEventListener(\"pointermove\", onPointerMove);\n    log.addEventListener(\"pointerup\", cancel);\n    log.addEventListener(\"pointercancel\", cancel);\n    log.addEventListener(\"contextmenu\", onContextMenu);\n    log.addEventListener(\"dblclick\", onDoubleClick);\n    log.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancel();\n      log.removeEventListener(\"pointerdown\", onPointerDown);\n      log.removeEventListener(\"pointermove\", onPointerMove);\n      log.removeEventListener(\"pointerup\", cancel);\n      log.removeEventListener(\"pointercancel\", cancel);\n      log.removeEventListener(\"contextmenu\", onContextMenu);\n      log.removeEventListener(\"dblclick\", onDoubleClick);\n      log.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [frame]);\n  return <div data-slot=\"long-press-layer\" className=\"absolute inset-0\" style={{ pointerEvents: \"none\" }} />;\n}\n",
          "type": "registry:component",
          "target": "components/imessage/ios-messages-app.tsx"
        }
      ],
      "type": "registry:block"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-messages-app",
      "title": "macOS Messages app",
      "description": "The complete macOS 26 Messages window: sidebar, header, message log, composer, and the right-click menu.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/context-menu.json",
        "https://imessage.swerdlow.dev/r/ios-messages-app.json",
        "https://imessage.swerdlow.dev/r/macos-composer.json",
        "https://imessage.swerdlow.dev/r/macos-header.json",
        "https://imessage.swerdlow.dev/r/macos-plus-menu.json",
        "https://imessage.swerdlow.dev/r/macos-sidebar.json",
        "https://imessage.swerdlow.dev/r/macos-window.json",
        "https://imessage.swerdlow.dev/r/message-list.json",
        "https://imessage.swerdlow.dev/r/message-motion.json",
        "https://imessage.swerdlow.dev/r/palette.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback-bar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/macos-messages-app.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { PlatformProvider } from \"@/components/imessage/platform\";\nimport { PaletteStyle } from \"@/components/imessage/palette\";\nimport { MacWindow, macWindowMetrics } from \"@/components/imessage/macos-window\";\nimport { MacSidebar, macSidebarMetrics, type SidebarConversation } from \"@/components/imessage/macos-sidebar\";\nimport { MacHeader, macHeaderMetrics } from \"@/components/imessage/macos-header\";\nimport { MacComposer } from \"@/components/imessage/macos-composer\";\nimport { MessageList, type Message, type MessageListHandle } from \"@/components/imessage/message-list\";\nimport { ContextMenu, macosMessageMenu } from \"@/components/imessage/context-menu\";\nimport { TapbackBar, type TapbackSelection } from \"@/components/imessage/tapback-bar\";\nimport { MacPlusMenu } from \"@/components/imessage/macos-plus-menu\";\nimport { defaultReactions } from \"@/components/imessage/ios-messages-app\";\nimport { prefersReducedMotion, useArrivalAnimation, type ArrivalAnimation } from \"@/components/imessage/message-motion\";\n\n/**\n * The pane's log is anchored to the bottom. `listBottom` 58.2 puts \"Delivered\" ink at y 573 with the\n * composer field starting at 596, matching conversation-pane-dark-2.png; the 52 it replaces pushed the\n * whole log 5pt down (8.6% pixel mismatch against that capture instead of 2.8%).\n */\nexport const macScreen = { width: macWindowMetrics.width, height: macWindowMetrics.height, sidebar: macWindowMetrics.sidebarWidth, listTop: 84.3, listBottom: 58.2 } as const;\n\n/**\n * The window's own transitions: switching conversation, and the two popovers appearing.\n *\n * **NOT MEASURED.** `references/` holds no capture or recording of a macOS conversation switch, of the\n * \"+\" popover opening, or of a window losing key, so every number below is chosen to sit in the family\n * of the macOS motion that *is* measured: the 183 ms menu dissolve of\n * `tapback-apply-frames-100-123.png`, the 120 ms dismissal `context-menu.tsx` already ships, and the\n * 110 ms tapback rise. They sit at the short end of that range on purpose, because a switch moves\n * nothing but the pane's content: the window, the sidebar and the composer all stay where they are.\n * The one number here that came from a capture is the inactive panel fill below.\n */\nexport const macTransitions = {\n  /** Conversation switch: the pane dissolves while the arriving content rises `shift` points into place. */\n  conversation: { duration: 140, shift: 6, dissolve: 0.62 },\n  /** The sidebar's selected row travels to its new place; its text crosses to the selected colour sooner. */\n  selection: { duration: 140, text: 110 },\n  /**\n   * The context menu appearing. Its dismissal is the 120 ms `context-menu.tsx` already owns, and the\n   * plus popover owns both ends of its own presentation (`macPlusMenuMetrics.motion`), which grows\n   * from this same 0.96 on this same curve.\n   */\n  menu: { open: 110, scale: 0.96 },\n} as const;\n\n/**\n * Rules the app needs on elements it does not own: a sidebar row (`macos-sidebar.tsx`), the header it\n * clones while a conversation is leaving (`macos-header.tsx`), and the chrome of a window that is not\n * key. Everything is keyed on this component's own root or on a node only this file renders, so a\n * second app on the same page is untouched.\n *\n * The inactive window is half measured: **#292929** is the panel fill of an inactive dark window, read\n * off a full-window frame held outside the repo (SPEC, \"Still unverified\"), and the rest is AppKit's\n * convention rather than a capture: an inactive window draws its toolbar glyphs at about half strength,\n * and its accents (the traffic lights and the selected row) already go neutral in `macos-window.tsx`\n * and `macos-sidebar.tsx`. No capture of an inactive *light* window exists, so light keeps its fill.\n */\nconst macAppStyles = `\n[data-im-platform=\"macos\"][data-switching=\"true\"] [data-slot=\"sidebar-row\"][data-selected=\"true\"] > button{background-color:transparent!important}\n[data-slot=\"header-outgoing\"] [data-slot=\"header-glass\"],[data-slot=\"header-outgoing\"] [data-slot=\"compose-button\"],[data-slot=\"header-outgoing\"] [data-slot=\"video-button\"]{display:none}\n:where(.dark,.dark *) [data-slot=\"macos-messages-app\"][data-active=\"false\"] [data-slot=\"mac-sidebar\"]:not(:where([data-preview-theme=\"light\"] *)){--sb-fill:#292929}\n[data-slot=\"macos-messages-app\"][data-active=\"false\"] :is([data-slot=\"compose-button\"],[data-slot=\"video-button\"],[data-slot=\"attach-button\"],[data-slot=\"emoji-button\"],[data-slot=\"sidebar-options\"]){opacity:0.5}\n`;\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\n\n/**\n * Click-to-select, the way a Mac list behaves: a plain click replaces the selection, cmd toggles one\n * message, and shift extends from the anchor, which is whichever message a plain or cmd click last\n * touched. `order` is the conversation in display order.\n */\nexport function nextMessageSelection(order: readonly string[], selected: readonly string[], id: string, modifiers: { shiftKey?: boolean; metaKey?: boolean }, anchor: string | null): string[] {\n  if (modifiers.metaKey) return selected.includes(id) ? selected.filter(other => other !== id) : [...selected, id];\n  if (modifiers.shiftKey) {\n    const from = order.indexOf(anchor ?? id);\n    const to = order.indexOf(id);\n    if (from < 0 || to < 0) return [id];\n    return order.slice(Math.min(from, to), Math.max(from, to) + 1);\n  }\n  return [id];\n}\n\nexport type MacMessagesAppProps = {\n  width?: number;\n  height?: number;\n  /** Key window: colored traffic lights and the blue selection. */\n  active?: boolean;\n  conversations?: SidebarConversation[];\n  selectedId?: string;\n  onSelectConversation?: (id: string) => void;\n  contact: { name: string; initials?: string; photo?: string };\n  group?: boolean;\n  messages: Message[];\n  typing?: boolean | { sender?: string };\n  now?: Date | number;\n  composer?: { value?: string; disabled?: boolean; onChange?: (value: string) => void; onSend?: (text: string) => void | Promise<void>; onAttach?: () => void; onEmoji?: () => void; onAudio?: () => void };\n  onCompose?: () => void;\n  onVideoCall?: () => void;\n  onDetails?: () => void;\n  /**\n   * That message was just sent: the composer's text row becomes its bubble and flies to its slot.\n   * With `progress` the animation is seeked to `progress * duration` instead of played.\n   */\n  sendAnimation?: ArrivalAnimation | null;\n  /** That message just arrived: it pops in from the typing indicator's position. */\n  receiveAnimation?: ArrivalAnimation | null;\n  /** Called when a played (not seeked) send animation finishes. */\n  onSendAnimationEnd?: () => void;\n  /**\n   * Messages a click has selected, newest state owned by the caller. Passing this (even empty) turns\n   * on click-to-select in the pane: a click selects one message, cmd toggles, shift extends, a click\n   * on anything else in the pane clears, and so does Escape. iOS has no equivalent; its multi-select\n   * is the checkbox mode in `ios-select-mode.tsx`.\n   */\n  selectedMessageIds?: readonly string[];\n  /** The selection a click, a right click or an arrow key just produced. `id` is the message it acted on. */\n  onSelectMessage?: (ids: string[], context: { id: string | null; shiftKey: boolean; metaKey: boolean }) => void;\n  /** Right-click menu anchored at a point inside the pane (pane coordinates). */\n  contextMenu?: { id: string; x: number; y: number } | null;\n  onContextMenu?: (id: string, x: number, y: number) => void;\n  onContextMenuClose?: () => void;\n  onTapback?: (id: string, selection: TapbackSelection) => void;\n  onMenuAction?: (id: string, action: string) => void;\n  /** The plus-button popover. */\n  plusMenu?: boolean;\n  onPlusMenuSelect?: (id: string) => void;\n  onPlusMenuClose?: () => void;\n  /**\n   * The conversation switch a change of `selectedId` starts: the pane crossfades, the header's name\n   * pill crosses with it and the sidebar's selected row travels. With `progress` (0 to 1) it is seeked\n   * to that fraction of `macTransitions.conversation.duration` and paused instead of played, which is\n   * what makes a checkpoint of it reproducible; a seeked switch stays on that frame until the next one.\n   */\n  conversationTransition?: { progress?: number } | null;\n  /**\n   * The same, for the two popovers: it seeks the context menu's appearance (`macTransitions.menu`) and\n   * whichever end of the plus popover's own presentation is running (`macPlusMenuMetrics.motion`).\n   */\n  menuTransition?: { progress?: number } | null;\n  footer?: string;\n  renderReactions?: (message: Message) => ReactNode;\n  overlay?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  frameRef?: RefObject<HTMLDivElement | null>;\n};\n\n/** What the pane was drawing before a switch, kept mounted so the two conversations can cross. */\ntype OutgoingPane = {\n  messages: Message[];\n  contact: MacMessagesAppProps[\"contact\"];\n  group: boolean;\n  /** Indices of the row it left and the row it landed on, when both are unpinned sidebar rows. */\n  rows: { from: number; to: number } | null;\n};\n\n/**\n * The whole macOS 26 Messages window: sidebar, header, message log, composer, and the right-click menu.\n * The caller owns data and navigation; this component only draws state.\n *\n * Changing `selectedId` crossfades the pane and travels the sidebar's selected row; the plus and context\n * menus fade in and out. Every one of those timings is unmeasured, see `macTransitions`.\n */\nexport function MacMessagesApp({\n  width = macScreen.width, height = macScreen.height, active = true, conversations = [], selectedId, onSelectConversation, contact, group = false,\n  messages, typing = false, now, composer, onCompose, onVideoCall, onDetails, sendAnimation, receiveAnimation, onSendAnimationEnd,\n  selectedMessageIds, onSelectMessage, contextMenu, onContextMenu, onContextMenuClose, onTapback, onMenuAction,\n  plusMenu = false, onPlusMenuSelect, onPlusMenuClose, conversationTransition, menuTransition,\n  footer, renderReactions = defaultReactions, overlay, className, style, frameRef,\n}: MacMessagesAppProps) {\n  const shell = useRef<HTMLDivElement>(null);\n  const localPane = useRef<HTMLDivElement>(null);\n  const pane = frameRef ?? localPane;\n  const list = useRef<MessageListHandle>(null);\n  // A popover that is already open on the app's first commit did not just appear, so it is not\n  // animated: the same rule a Tapback balloon follows for a reaction that predates the session. It is\n  // also what keeps a scenario checkpoint of an open menu a still frame instead of a mid-fade one.\n  const mounted = useRef(false);\n  useEffect(() => { mounted.current = true; }, []);\n  // Keep the menu mounted through its dismissal; derived during render so no committed frame is\n  // missing it (an effect would drop it for a frame and the fade would never be seen).\n  const [seenMenu, setSeenMenu] = useState<typeof contextMenu>(contextMenu ?? null);\n  const [closingMenu, setClosingMenu] = useState<typeof contextMenu>(null);\n  // A menu opened from the keyboard takes focus; one opened by a right click does not, which is what\n  // the platform does and what keeps the pointer path's rendering identical.\n  const [keyboardMenu, setKeyboardMenu] = useState(false);\n  if ((seenMenu?.id ?? null) !== (contextMenu?.id ?? null)) {\n    setSeenMenu(contextMenu ?? null);\n    setClosingMenu(contextMenu ? null : seenMenu);\n  }\n  const menu = contextMenu ?? closingMenu;\n  const target = menu ? messages.find(message => message.id === menu.id) : undefined;\n  // A native menu closes on Escape and on a click anywhere outside it, whether or not it holds focus.\n  const closeMenu = useRef(onContextMenuClose);\n  useEffect(() => { closeMenu.current = onContextMenuClose; }, [onContextMenuClose]);\n  useEffect(() => {\n    if (!contextMenu) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); closeMenu.current?.(); } };\n    const onPointer = (event: PointerEvent) => {\n      if (!(event.target as HTMLElement | null)?.closest?.('[data-slot=\"context-menu\"]')) closeMenu.current?.();\n    };\n    document.addEventListener(\"keydown\", onKey, true);\n    // Capture on the next tick so the right-click that opened it does not immediately close it.\n    const timer = setTimeout(() => document.addEventListener(\"pointerdown\", onPointer, true), 0);\n    return () => { document.removeEventListener(\"keydown\", onKey, true); clearTimeout(timer); document.removeEventListener(\"pointerdown\", onPointer, true); };\n  }, [contextMenu]);\n\n  // `MacPlusMenu` owns the popover's presentation, but it can only play the dismissal while it is\n  // still in the tree, so the app keeps it mounted until `onExited`. Derived during render for the\n  // same reason the context menu's closing state is: an effect would leave one committed frame with\n  // the popover already gone, and the dismissal would never be seen.\n  const [seenPlusMenu, setSeenPlusMenu] = useState(plusMenu);\n  const [closingPlusMenu, setClosingPlusMenu] = useState(false);\n  if (seenPlusMenu !== plusMenu) {\n    setSeenPlusMenu(plusMenu);\n    setClosingPlusMenu(!plusMenu && seenPlusMenu);\n  }\n  const menuProgress = menuTransition?.progress;\n\n  // The context menu fades and grows out of the pointer. Only its appearance is animated here, because\n  // `context-menu.tsx` already owns the dismissal; the transform origin set here is the corner that\n  // dismissal then folds back into.\n  const openMenuId = contextMenu?.id ?? null;\n  useLayoutEffect(() => {\n    if (!openMenuId || !mounted.current || prefersReducedMotion()) return;\n    const element = pane.current?.querySelector<HTMLElement>('[data-slot=\"context-menu\"]');\n    if (!element) return;\n    element.style.transformOrigin = \"left top\";\n    const animation = element.animate(\n      [{ opacity: 0, transform: `scale(${macTransitions.menu.scale})` }, { opacity: 1, transform: \"scale(1)\" }],\n      { duration: macTransitions.menu.open, easing: \"cubic-bezier(0.2, 0.8, 0.3, 1)\", fill: \"both\" },\n    );\n    if (menuProgress !== undefined) { animation.pause(); animation.currentTime = clamp01(menuProgress) * macTransitions.menu.open; }\n    return () => { try { animation.cancel(); } catch { /* already gone */ } };\n  }, [openMenuId, pane, menuProgress]);\n\n  // Selection is off until the caller owns it, and it draws even without a handler so a screenshot of\n  // a fixed selection needs no interaction. The anchor is what a shift-click extends from.\n  const selecting = selectedMessageIds !== undefined;\n  const anchorId = useRef<string | null>(null);\n  function select(id: string | null, modifiers: { shiftKey: boolean; metaKey: boolean }) {\n    if (!selecting || !onSelectMessage) return;\n    if (id === null) { anchorId.current = null; onSelectMessage!([], { id: null, ...modifiers }); return; }\n    const next = nextMessageSelection(messages.map(message => message.id), selectedMessageIds!, id, modifiers, anchorId.current);\n    if (!modifiers.shiftKey) anchorId.current = id;\n    onSelectMessage!(next, { id, ...modifiers });\n  }\n  const mine = target?.reactions?.find(reaction => reaction.byMe);\n  const selected: TapbackSelection | undefined = mine ? (mine.emoji ? { emoji: mine.emoji } : { type: mine.type as never }) : undefined;\n  useArrivalAnimation({ frame: pane, send: sendAnimation, receive: receiveAnimation, onSendEnd: onSendAnimationEnd });\n\n  /**\n   * The conversation the pane is leaving. It cannot be derived during render the way the menus'\n   * closing state is, because by then `messages` and `contact` are already the new conversation's and\n   * the old ones live in a ref, which a render may not read. A **layout** effect is the one place that\n   * can hold them: React flushes the state it sets before the browser paints, so the frame where the\n   * arriving conversation is alone in the pane is never shown. A passive effect would lose exactly\n   * that frame, which is why nothing here uses one.\n   */\n  const committed = useRef({ id: selectedId, messages, contact, group });\n  const [outgoingPane, setOutgoingPane] = useState<OutgoingPane | null>(null);\n  useLayoutEffect(() => {\n    const before = committed.current;\n    committed.current = { id: selectedId, messages, contact, group };\n    // Opening the first conversation is an arrival, not a switch: there is nothing to cross with.\n    if (before.id === selectedId || before.id === undefined || selectedId === undefined) return;\n    const rows = conversations.filter(conversation => !conversation.pinned);\n    const from = rows.findIndex(row => row.id === before.id);\n    const to = rows.findIndex(row => row.id === selectedId);\n    setOutgoingPane({ messages: before.messages, contact: before.contact, group: before.group, rows: from >= 0 && to >= 0 && from !== to ? { from, to } : null });\n  }, [selectedId, messages, contact, group, conversations]);\n\n  const switchProgress = conversationTransition?.progress;\n  useLayoutEffect(() => {\n    if (!outgoingPane) return;\n    const root = pane.current;\n    const clear = () => setOutgoingPane(current => (current === outgoingPane ? null : current));\n    if (!root || prefersReducedMotion()) { clear(); return; }\n    const D = macTransitions.conversation.duration;\n    const animations: Animation[] = [];\n    const add = (element: Element | null | undefined, frames: Keyframe[], options: KeyframeAnimationOptions) => {\n      if (element) animations.push(element.animate(frames, { fill: \"both\", ...options }));\n    };\n    // The pane's content and the header's name pill cross together; the composer, the header's glass\n    // and its two buttons belong to the window rather than to the conversation, so they hold still.\n    const contacts = Array.from(root.querySelectorAll<HTMLElement>('[data-slot=\"header-contact\"]'));\n    const enter: Keyframe[] = [{ opacity: 0, transform: `translateY(${macTransitions.conversation.shift}px)` }, { opacity: 1, transform: \"translateY(0px)\" }];\n    const leave: Keyframe[] = [{ opacity: 1, offset: 0 }, { opacity: 0, offset: macTransitions.conversation.dissolve }, { opacity: 0, offset: 1 }];\n    const arriving = { duration: D, easing: \"cubic-bezier(0.25, 0.8, 0.3, 1)\" };\n    const leaving = { duration: D, easing: \"linear\" };\n    add(root.querySelector('[data-slot=\"pane-content\"]'), enter, arriving);\n    add(contacts.find(element => !element.closest('[data-slot=\"header-outgoing\"]')), enter, arriving);\n    add(root.querySelector('[data-slot=\"pane-outgoing\"]'), leave, leaving);\n    add(contacts.find(element => element.closest('[data-slot=\"header-outgoing\"]')), leave, leaving);\n\n    // The sidebar's selection is one highlight that moves, not two that swap: the row it leaves drops\n    // its own fill, the row it lands on has its fill suppressed by `macAppStyles` for as long as this\n    // runs, and this chip travels between them. It is inserted as the list's first child so the rows'\n    // avatars and text keep painting over it, the way the real fill does.\n    let chip: HTMLElement | null = null;\n    const travel = outgoingPane.rows;\n    const list = travel ? shell.current?.querySelector<HTMLElement>('[data-slot=\"sidebar-rows\"]') : null;\n    const items = list?.querySelectorAll<HTMLElement>('[data-slot=\"sidebar-row\"]');\n    const fromRow = travel && items ? items[travel.from] : undefined;\n    const toRow = travel && items ? items[travel.to] : undefined;\n    if (travel && list && fromRow && toRow) {\n      // Geometry comes from the sidebar's own metrics, not from the DOM: `offsetTop` and\n      // `offsetHeight` are integers, and a row is 80.5 tall, so a chip built from them would sit half\n      // a point off the row it is standing in for. Rows stack from the list's top with no gaps.\n      const row = macSidebarMetrics.row;\n      chip = document.createElement(\"div\");\n      chip.dataset.slot = \"sidebar-selection-travel\";\n      chip.setAttribute(\"aria-hidden\", \"true\");\n      Object.assign(chip.style, {\n        position: \"absolute\", left: \"0px\", top: `${travel.from * row.height}px`,\n        width: `${row.width}px`, height: `${row.height}px`, borderRadius: `${row.radius}px`,\n        background: active ? \"#3478f6\" : getComputedStyle(list).getPropertyValue(\"--sb-inactive\").trim() || \"#3a3a3a\",\n        // A transient element must never become a scroll anchor: scrubbing rebuilds it every frame.\n        pointerEvents: \"none\", overflowAnchor: \"none\",\n      } satisfies Partial<CSSStyleDeclaration>);\n      // The same continuous corner `macos-sidebar.tsx` gives the row it is standing in for.\n      if (CSS.supports?.(\"corner-shape: superellipse(1.4)\")) { chip.style.borderRadius = \"10px\"; chip.style.setProperty(\"corner-shape\", \"superellipse(1.4)\"); }\n      list.insertBefore(chip, list.firstChild);\n      // `top`, not a transform: a transformed layer rasterizes at its own subpixel offset and lands a\n      // device pixel above the row it is standing in for, which shows at both ends of the travel.\n      add(chip, [{ top: `${(travel.from * row.height).toFixed(2)}px` }, { top: `${(travel.to * row.height).toFixed(2)}px` }],\n        { duration: macTransitions.selection.duration, easing: arriving.easing });\n      // The row text crosses with the fill, and a little sooner, so the name is already white by the\n      // time the highlight is under it. Driven here rather than by a CSS transition so that the whole\n      // switch answers to one `progress`. An inactive window's selected row keeps the plain colours\n      // (`macos-sidebar.tsx`), so there is nothing to cross then.\n      const ink = getComputedStyle(list);\n      const plain = { name: ink.getPropertyValue(\"--sb-name\").trim() || \"#000000\", secondary: ink.getPropertyValue(\"--sb-secondary\").trim() || \"#6e6e6d\" };\n      const chosen = active ? { name: \"#ffffff\", secondary: \"#d6e4fd\" } : plain;\n      const crossText = (row: HTMLElement, toSelected: boolean) => {\n        for (const [slot, from, to] of [\n          ['[data-slot=\"row-name\"]', plain.name, chosen.name],\n          ['[data-slot=\"row-time\"]', plain.secondary, chosen.secondary],\n          ['[data-slot=\"row-preview\"]', plain.secondary, chosen.secondary],\n        ] as const) {\n          add(row.querySelector(slot), toSelected ? [{ color: from }, { color: to }] : [{ color: to }, { color: from }],\n            { duration: macTransitions.selection.text, easing: \"linear\" });\n        }\n      };\n      crossText(fromRow, false);\n      crossText(toRow, true);\n    }\n\n    if (switchProgress === undefined) void Promise.all(animations.map(animation => animation.finished.catch(() => undefined))).then(clear);\n    else { const t = clamp01(switchProgress) * D; animations.forEach(animation => { animation.pause(); animation.currentTime = t; }); }\n    return () => {\n      chip?.remove();\n      animations.forEach(animation => { try { animation.cancel(); } catch { /* already gone */ } });\n    };\n  }, [outgoingPane, pane, switchProgress, active]);\n\n  return (\n    <PlatformProvider platform=\"macos\">\n      <PaletteStyle platform=\"macos\" />\n      <div ref={shell} data-im-platform=\"macos\" data-switching={outgoingPane?.rows ? \"true\" : undefined} className={cn(\"relative\", className)} style={{ width, height, ...style }}>\n        <style>{macAppStyles}</style>\n        <MacWindow width={width} height={height} active={active} data-slot=\"macos-messages-app\"\n          sidebar={<MacSidebar conversations={conversations} selectedId={selectedId} onSelect={onSelectConversation} active={active} footer={footer} className=\"absolute inset-0\" />}\n          content={\n            <div ref={pane} data-slot=\"pane\" className=\"absolute inset-0 overflow-hidden\" style={{ background: \"var(--im-bg)\" }}\n              // A single click selects the message it lands on and deselects the rest; a click on the\n              // rest of the pane clears. Right-clicking selects first, then opens the menu.\n              onClick={event => {\n                const hit = (event.target as HTMLElement).closest?.('[data-message-id], [data-slot=\"context-menu\"]');\n                // The menu acts on the message it opened over, so using it must not clear the selection.\n                if (hit?.getAttribute(\"data-slot\") === \"context-menu\") return;\n                select(hit?.getAttribute(\"data-message-id\") ?? null, { shiftKey: event.shiftKey, metaKey: event.metaKey });\n              }}\n              onContextMenu={event => {\n                const id = (event.target as HTMLElement).closest?.(\"[data-message-id]\")?.getAttribute(\"data-message-id\");\n                if (id) select(id, { shiftKey: false, metaKey: false });\n                if (!id || !onContextMenu || !pane.current) return;\n                event.preventDefault();\n                setKeyboardMenu(false);\n                const rect = pane.current.getBoundingClientRect();\n                onContextMenu(id, event.clientX - rect.left, event.clientY - rect.top);\n              }}\n              // The keyboard equivalent of the right click: the log's arrow keys focus a message and\n              // these keys open its menu, anchored to the message rather than to a pointer.\n              onKeyDown={event => {\n                if (selecting && onSelectMessage) {\n                  // Escape only clears once nothing else has claimed it, so an open menu still closes first.\n                  if (event.key === \"Escape\" && !event.defaultPrevented && selectedMessageIds!.length) { event.preventDefault(); select(null, { shiftKey: false, metaKey: false }); return; }\n                  // The log's own arrow keys have already moved focus by the time this bubbles up (and\n                  // have called preventDefault on the way), so following the focused row here is what\n                  // makes selection reachable without a pointer.\n                  if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\" || event.key === \"Home\" || event.key === \"End\") {\n                    const focused = (document.activeElement as HTMLElement | null)?.closest?.(\"[data-message-id]\")?.getAttribute(\"data-message-id\");\n                    if (focused) select(focused, { shiftKey: event.shiftKey, metaKey: false });\n                  }\n                }\n                const row = event.target as HTMLElement;\n                if (!row.matches?.('[data-slot=\"message-row\"][data-message-id]')) return;\n                if (event.key !== \"Enter\" && event.key !== \" \" && event.key !== \"ContextMenu\" && !(event.key === \"F10\" && event.shiftKey)) return;\n                const id = row.getAttribute(\"data-message-id\");\n                if (!id || !onContextMenu || !pane.current) return;\n                event.preventDefault();\n                setKeyboardMenu(true);\n                const rect = pane.current.getBoundingClientRect();\n                const at = row.getBoundingClientRect();\n                onContextMenu(id, at.left + at.width / 2 - rect.left, at.bottom - rect.top);\n              }}>\n              <div data-slot=\"pane-content\" className=\"absolute inset-0\">\n                <MessageList ref={list} frameRef={pane} messages={messages} typing={typing} group={group} now={now} anchor=\"bottom\" selectedIds={selectedMessageIds}\n                  insetTop={macScreen.listTop} insetBottom={macScreen.listBottom} renderReactions={renderReactions} messageActions={Boolean(onContextMenu)} className=\"absolute inset-0\" />\n              </div>\n              {/* The conversation that is leaving, under the header's glass so it is washed like the one\n                  arriving. It carries no composer and nothing interactive: it is a picture for 140 ms. */}\n              {outgoingPane && (\n                <div data-slot=\"pane-outgoing\" aria-hidden=\"true\" inert className=\"pointer-events-none absolute inset-0\">\n                  <MessageList frameRef={pane} messages={outgoingPane.messages} group={outgoingPane.group} now={now} anchor=\"bottom\"\n                    insetTop={macScreen.listTop} insetBottom={macScreen.listBottom} renderReactions={renderReactions} className=\"absolute inset-0\" />\n                </div>\n              )}\n              <MacHeader name={contact.name} initials={contact.initials} photo={contact.photo} onCompose={onCompose} onVideoCall={onVideoCall} onOpenDetails={onDetails} className=\"absolute left-0 top-0 w-full\" style={{ height: macHeaderMetrics.height }} />\n              {/* The name pill it is leaving, over the live header so the two cross where the real one\n                  sits. `macAppStyles` drops this copy's glass and buttons: only the contact is leaving. */}\n              {outgoingPane && (\n                <div data-slot=\"header-outgoing\" aria-hidden=\"true\" inert className=\"pointer-events-none absolute left-0 top-0 w-full\">\n                  <MacHeader name={outgoingPane.contact.name} initials={outgoingPane.contact.initials} photo={outgoingPane.contact.photo} style={{ height: macHeaderMetrics.height }} />\n                </div>\n              )}\n              <MacComposer className=\"absolute bottom-0 left-0 w-full\" value={composer?.value} disabled={composer?.disabled} onChange={composer?.onChange}\n                onSend={composer?.onSend ?? (() => {})} onAttach={composer?.onAttach} onEmoji={composer?.onEmoji} onAudio={composer?.onAudio} />\n              {target && menu && (\n                <ContextMenu variant=\"macos\" items={macosMessageMenu} open={!closingMenu} onExited={() => setClosingMenu(null)} autoFocus={keyboardMenu}\n                  style={{ position: \"absolute\", left: Math.min(menu.x, width - macScreen.sidebar - 310), top: Math.min(menu.y, height - 300), zIndex: 30 }}\n                  onAction={action => onMenuAction?.(target.id, action)} onClose={onContextMenuClose}\n                  header={<TapbackBar layout=\"macos\" selected={selected} onSelect={selection => onTapback?.(target.id, selection)} />} />\n              )}\n              {overlay}\n            </div>\n          } />\n        {/* Kept in the tree while it is leaving: `MacPlusMenu` plays its own dismissal and says when\n            it is over. A seeked one never reports, so a scrubbed frame holds. */}\n        {(plusMenu || closingPlusMenu) && (\n          <MacPlusMenu open={plusMenu} progress={menuProgress} onExited={() => setClosingPlusMenu(false)}\n            onSelect={onPlusMenuSelect} onClose={onPlusMenuClose} style={{ position: \"absolute\", zIndex: 40 }} left={macScreen.sidebar + 9} top={626} />\n        )}\n      </div>\n    </PlatformProvider>\n  );\n}\n",
          "type": "registry:component",
          "target": "components/imessage/macos-messages-app.tsx"
        }
      ],
      "type": "registry:block"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-plus-menu",
      "title": "macOS plus menu",
      "description": "The popover that opens from the composer's plus button: Photos, Stickers, Genmoji, Image Playground, #images, Message Effects.",
      "files": [
        {
          "path": "registry/imessage/macos-plus-menu.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ComponentProps, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * The popover that opens downward from the composer's \"+\" button in macOS 26 Messages.\n * Measured from `references/macos/captures/plus-menu-dark-2x.png` / `plus-menu-light-2x.png` (window\n * x 330–530, y 580–850 at 2x), in pane coordinates:\n * - box: left 9 (flush with the \"+\" button), top 626 (it overlaps the button's bottom 3 pt), 175×214,\n *   corner radius 12 (circle fit R 24 px, rmse 1.2 px). It hangs 200 pt below the window's bottom edge.\n * - edge: a 1 pt bright inset rim (dark ≈ #61696e over #252728, light ≈ #f8fcfe) and a 0.5 pt dark outline\n *   outside it (dark #050505, light ≈ #b8b8b8).\n * - fill: translucent glass, dark ≈ #252728, light ≈ #edeff0 (blurred content shows through).\n * - rows: 5 pt top/bottom padding, six rows 34 tall; Ø20 app icon at x 20, 13 pt label at x 50\n *   (\"Photos\" ink 81 px, \"Image Playground\" 213 px at 2x: untracked, unlike the composer text),\n *   dark #dddddd / light #242424.\n *\n * THE MOTION IS NOT MEASURED. No capture in `references/` records this popover opening or closing,\n * so nothing about its timing can be read off a frame. What *is* measured is the pose at each end,\n * which is the part that decides whether it reads as native: it grows out of the \"+\" button's own\n * corner, because the captures put the menu's left edge on the button's left edge (both at pane x 9)\n * and its top edge 3 pt inside the button's bottom, and it lands on the settled box above with the\n * transform back at exactly 1 and nothing left to settle. Every number in `motion` is borrowed from\n * a transition the kit already ships, or invented:\n * - exit: 120 ms on cubic-bezier(0.4, 0, 1, 1). Copied from `context-menu.tsx`'s macOS dismissal,\n *   which is SPEC's \"macOS menu dismiss ... fade and settle back, ~120 ms\".\n * - enter: cubic-bezier(0.2, 0.8, 0.3, 1), the curve `macos-messages-app.tsx` opens the macOS\n *   context menu on, itself a softened form of the long-press menu's cubic-bezier(0.2, 0.95, 0.3, 1)\n *   spring in `message-actions.tsx`.\n * - the 160 ms the entrance takes is invented. It is longer than the exit on purpose: an AppKit menu\n *   leaves faster than it arrives.\n * - the 0.96 it grows from is `macTransitions.menu.scale`, the same scale the app shell's popovers\n *   grow from, so the two macOS popovers move alike. Which corner it grows from is measured; how\n *   much it grows is not.\n */\nexport const macPlusMenuMetrics = {\n  left: 9,\n  top: 626,\n  width: 175,\n  height: 214,\n  radius: 12,\n  paddingY: 5,\n  row: { height: 34, iconLeft: 20, iconSize: 20, labelLeft: 50, fontSize: 13, letterSpacing: 0 },\n  /** Presentation motion. Unverified: see the note above for where each number came from. */\n  motion: {\n    enter: 160,\n    exit: 120,\n    enterEase: \"cubic-bezier(0.2, 0.8, 0.3, 1)\",\n    exitEase: \"cubic-bezier(0.4, 0, 1, 1)\",\n    /** How small it is at the anchor corner, at each end of the fade. */\n    scale: 0.96,\n  },\n};\n\nfunction clamp01(value: number) {\n  return Math.max(0, Math.min(1, value));\n}\n\nconst reducedMotionQuery = () => (typeof window === \"undefined\" ? null : window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") ?? null);\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = reducedMotionQuery();\n  query?.addEventListener(\"change\", onChange);\n  return () => query?.removeEventListener(\"change\", onChange);\n}\n/**\n * True when the viewer asked for less motion; false while server rendering. `use-long-press.ts` has\n * the same hook, and this file keeps its own copy so the menu stays a registry item with no\n * dependency of its own.\n */\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReducedMotion, () => reducedMotionQuery()?.matches ?? false, () => false);\n}\n\n/**\n * The hover highlight is painted from here rather than from a `hover:` class so it can be gated on\n * the menu's own state. While the box is still growing or shrinking the rows slide under a\n * stationary cursor, and a plain `:hover` would strobe from row to row; AppKit does not highlight\n * anything until the menu is up. Keyboard focus is not gated: it never moves under the pointer.\n */\nconst rowHighlightCss = `[data-slot=\"plus-menu\"] [data-slot=\"plus-menu-item\"]{transition:background-color 60ms linear}\n[data-slot=\"plus-menu\"][data-state=\"open\"] [data-slot=\"plus-menu-item\"]:hover{background:var(--pm-hover)}\n[data-slot=\"plus-menu\"] [data-slot=\"plus-menu-item\"]:focus-visible{background:var(--pm-hover)}\n@media (prefers-reduced-motion: reduce){[data-slot=\"plus-menu\"] [data-slot=\"plus-menu-item\"]{transition:none}}`;\n\nexport type PlusMenuItem = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n};\n\nexport const defaultPlusMenuItems: PlusMenuItem[] = [\n  { id: \"photos\", label: \"Photos\" },\n  { id: \"stickers\", label: \"Stickers\" },\n  { id: \"genmoji\", label: \"Genmoji\" },\n  { id: \"image-playground\", label: \"Image Playground\" },\n  { id: \"images\", label: \"#images\" },\n  { id: \"message-effects\", label: \"Message Effects\" },\n];\n\nexport type MacPlusMenuProps = Omit<ComponentProps<\"div\">, \"onSelect\"> & {\n  /** False plays the dismissal; the menu stays mounted until it is over and then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  /**\n   * Seek the presentation to this fraction (0..1) instead of playing it, which is what the harness\n   * does. It seeks whichever direction `open` selects, and a seeked dismissal never fires `onExited`:\n   * scrubbing a timeline is not a dismissal, and the frame has to be the same on every run.\n   */\n  progress?: number;\n  items?: PlusMenuItem[];\n  onSelect?: (id: string) => void;\n  onClose?: () => void;\n  /** Position of the menu's top-left corner in the containing block, in px. Defaults to the measured spot. */\n  left?: number;\n  top?: number;\n  /** Focus the first item when the menu opens (keyboard users). */\n  autoFocus?: boolean;\n};\n\nexport function MacPlusMenu({ open = true, onExited, progress, items = defaultPlusMenuItems, onSelect, onClose, left = macPlusMenuMetrics.left, top = macPlusMenuMetrics.top, autoFocus = false, className, style, ...props }: MacPlusMenuProps) {\n  const m = macPlusMenuMetrics;\n  const id = useId();\n  const list = useRef<HTMLDivElement>(null);\n  const reduced = usePrefersReducedMotion();\n\n  /**\n   * The menu has to outlive the prop that dismissed it or the dismissal never gets a frame to run\n   * in. `shown` is therefore derived here, DURING RENDER, and not in an effect: an effect would\n   * leave one committed frame with the element already unmounted and the exit would never play.\n   * `ios-messages-app.tsx` derives its overlays the same way.\n   */\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [shown, setShown] = useState(open);\n  const [settled, setSettled] = useState(open && reduced);\n  if (seenOpen !== open) {\n    setSeenOpen(open);\n    if (open) { setShown(true); setSettled(reduced); }\n    // Under reduced motion there is no dismissal to wait for, so the menu goes in the same frame.\n    else if (reduced) setShown(false);\n  }\n  const closing = shown && !open;\n  const state = closing ? \"closing\" : reduced || settled || (progress !== undefined && clamp01(progress) === 1) ? \"open\" : \"entering\";\n\n  /**\n   * Natively this is a separate popover window, so it hangs 200 pt below the app window onto the\n   * desktop, which is the placement the captures measure. Inside a fixed device frame there is no\n   * desktop, so it flips above the button when it would be cut off, which is what a real popover\n   * does at the bottom of a screen. What decides that is the first ancestor that actually hides its\n   * overflow, not the offsetParent: the offsetParent is the window's own box, and a menu that hangs\n   * past it is exactly what the captures show.\n   */\n  const [flipped, setFlipped] = useState(false);\n  useLayoutEffect(() => {\n    const parent = list.current?.offsetParent as HTMLElement | null;\n    if (!shown || !parent) return;\n    const bottomIfBelow = parent.getBoundingClientRect().top + parent.clientTop + top + m.height;\n    let limit = typeof window === \"undefined\" ? Number.POSITIVE_INFINITY : window.innerHeight;\n    for (let frame: HTMLElement | null = parent; frame; frame = frame.parentElement) {\n      if (getComputedStyle(frame).overflowY === \"visible\") continue;\n      limit = Math.min(limit, frame.getBoundingClientRect().bottom);\n      break;\n    }\n    setFlipped(bottomIfBelow > limit + 0.5);\n  }, [shown, top, m.height]);\n  const resolvedTop = flipped ? top - (m.height + 24) : top;\n\n  // Callbacks live in refs so a caller passing inline arrows cannot re-arm the listeners, or the\n  // dismissal, on every render.\n  const exited = useRef(onExited);\n  const close = useRef(onClose);\n  useEffect(() => { exited.current = onExited; close.current = onClose; });\n\n  /**\n   * The presentation. It grows out of the \"+\" button's corner, so the origin follows the flip: the\n   * menu's top-left corner is the button's own left edge just below it, and a flipped menu grows up\n   * from its bottom-left instead. Both ends are the measured layout; only the curve between them is\n   * invented. Under `prefers-reduced-motion` no animation is created at all, so the menu is simply\n   * there and simply gone.\n   */\n  useEffect(() => {\n    const element = list.current;\n    if (!element || !shown) return;\n    const t = m.motion;\n    if (open) {\n      if (reduced) return;\n      const entrance = element.animate(\n        [{ opacity: 0, transform: `scale(${t.scale})` }, { opacity: 1, transform: \"scale(1)\" }],\n        { duration: t.enter, easing: t.enterEase, fill: \"both\" },\n      );\n      if (progress !== undefined) {\n        // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n        entrance.pause();\n        entrance.currentTime = clamp01(progress) * t.enter;\n        return () => entrance.cancel();\n      }\n      const done = () => setSettled(true);\n      entrance.addEventListener(\"finish\", done);\n      return () => { entrance.removeEventListener(\"finish\", done); entrance.cancel(); };\n    }\n    const exit = element.animate(\n      [{ opacity: 1, transform: \"scale(1)\" }, { opacity: 0, transform: `scale(${t.scale})` }],\n      { duration: t.exit, easing: t.exitEase, fill: \"both\" },\n    );\n    if (progress !== undefined) {\n      exit.pause();\n      exit.currentTime = clamp01(progress) * t.exit;\n      return () => exit.cancel();\n    }\n    const gone = () => setShown(false);\n    exit.addEventListener(\"finish\", gone);\n    return () => {\n      exit.removeEventListener(\"finish\", gone);\n      // Reopened mid-dismissal: drop the fold so it cannot hold a stale pose under the entrance.\n      if (exit.playState !== \"finished\") exit.cancel();\n    };\n  }, [shown, open, progress, reduced, m.motion]);\n\n  // One place fires `onExited`, whether the menu left on the animation or under reduced motion.\n  const wasShown = useRef(shown);\n  useEffect(() => {\n    if (wasShown.current && !shown) exited.current?.();\n    wasShown.current = shown;\n  }, [shown]);\n\n  useEffect(() => {\n    // A scrubbed entrance must not move the caret: the harness seeks frames, it does not open menus.\n    if (!open || !autoFocus || progress !== undefined) return;\n    list.current?.querySelector<HTMLButtonElement>('[role=\"menuitem\"]')?.focus();\n  }, [open, autoFocus, progress]);\n\n  /**\n   * A menu is dismissed by Escape from wherever focus happens to be, and by a click anywhere outside\n   * it. The control that opened it is the exception: it owns the toggle, so closing here as well\n   * would close the menu and let its own click reopen it in the same gesture.\n   */\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      // Nothing to dismiss to: leave the key for whatever else is listening.\n      if (event.key !== \"Escape\" || !close.current) return;\n      event.preventDefault();\n      close.current();\n    };\n    const onPointerDown = (event: globalThis.PointerEvent) => {\n      const target = event.target as Node | null;\n      if (!close.current || !target || list.current?.contains(target)) return;\n      if (target instanceof Element && target.closest('[data-slot=\"attach-button\"], [aria-haspopup=\"menu\"]')) return;\n      close.current();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n    };\n  }, [open]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const buttons = Array.from(list.current?.querySelectorAll<HTMLButtonElement>('[role=\"menuitem\"]') ?? []);\n    const index = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      const step = event.key === \"ArrowDown\" ? 1 : -1;\n      buttons[(index + step + buttons.length) % buttons.length]?.focus();\n    }\n    if (event.key === \"Home\") { event.preventDefault(); buttons[0]?.focus(); }\n    if (event.key === \"End\") { event.preventDefault(); buttons[buttons.length - 1]?.focus(); }\n  }\n\n  if (!shown) return null;\n\n  return (\n    <div\n      ref={list}\n      data-slot=\"plus-menu\"\n      data-placement={flipped ? \"above\" : \"below\"}\n      data-state={state}\n      role=\"menu\"\n      aria-labelledby={`${id}-label`}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"absolute z-40 select-none bg-[var(--pm-fill)] text-[var(--pm-text)] shadow-[var(--pm-edge)]\",\n        \"[--pm-edge:inset_0_0_0_1px_rgba(255,255,255,0.8),0_0_0_0.5px_rgba(0,0,0,0.28),0_4px_16px_rgba(0,0,0,0.12)] [--pm-fill:rgba(238,240,241,0.92)] [--pm-hover:rgba(0,0,0,0.06)] [--pm-text:#242424]\",\n        \"dark:[--pm-edge:inset_0_0_0_1px_rgba(255,255,255,0.28),0_0_0_0.5px_rgba(0,0,0,0.85),0_4px_16px_rgba(0,0,0,0.35)] dark:[--pm-fill:rgba(37,39,40,0.94)] dark:[--pm-hover:rgba(255,255,255,0.08)] dark:[--pm-text:#dddddd]\",\n        className,\n      )}\n      style={{\n        left, top: resolvedTop, width: m.width, minHeight: m.height, borderRadius: m.radius, padding: `${m.paddingY}px 0`,\n        // The rows are full-bleed, so a hovered first or last row paints a square corner about 2.25 pt\n        // past the menu's rounded one unless the menu clips them. The clip is in the menu's own box,\n        // so the scale rides on top of it and the corner stays round mid-animation.\n        overflow: \"hidden\",\n        // The corner the menu grows out of, which is the corner it is anchored by: below the \"+\"\n        // button it is the top-left, and a flipped menu grows up from the bottom-left instead.\n        transformOrigin: flipped ? \"0% 100%\" : \"0% 0%\",\n        // Nothing is clickable while it is folding away, so a dismissal cannot pick a row by accident.\n        pointerEvents: closing ? \"none\" : undefined,\n        backdropFilter: \"blur(30px)\", WebkitBackdropFilter: \"blur(30px)\",\n        fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style,\n      }}\n      {...props}\n    >\n      <style>{rowHighlightCss}</style>\n      <span id={`${id}-label`} className=\"sr-only\">Attachments</span>\n      {items.map(item => (\n        <button\n          key={item.id}\n          type=\"button\"\n          role=\"menuitem\"\n          data-slot=\"plus-menu-item\"\n          data-item={item.id}\n          onClick={() => { onSelect?.(item.id); onClose?.(); }}\n          className=\"relative block w-full bg-transparent p-0 text-left outline-none\"\n          style={{ height: m.row.height }}\n        >\n          <span aria-hidden=\"true\" className=\"absolute\" style={{ left: m.row.iconLeft, top: (m.row.height - m.row.iconSize) / 2, width: m.row.iconSize, height: m.row.iconSize }}>\n            {item.icon ?? <PlusMenuIcon id={item.id} size={m.row.iconSize} />}\n          </span>\n          <span data-slot=\"plus-menu-label\" className=\"absolute whitespace-nowrap\" style={{ left: m.row.labelLeft, top: 0, lineHeight: `${m.row.height}px`, fontSize: m.row.fontSize, letterSpacing: m.row.letterSpacing }}>{item.label}</span>\n        </button>\n      ))}\n    </div>\n  );\n}\n\n/** Small look-alikes of the native app icons, drawn as SVG in a 20×20 box. */\nexport function PlusMenuIcon({ id, size = 20 }: { id: string; size?: number }) {\n  const common = { width: size, height: size, viewBox: \"0 0 20 20\", \"aria-hidden\": true as const, className: \"block\" };\n  switch (id) {\n    case \"photos\": {\n      const petals = [\"#f7c945\", \"#f59a3b\", \"#ef5a5a\", \"#e8558f\", \"#a765d4\", \"#4f8ef0\", \"#3fbbd0\", \"#78c45a\"];\n      return (\n        <svg {...common}>\n          <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"#ffffff\" />\n          <circle cx=\"10\" cy=\"10\" r=\"9.4\" fill=\"none\" stroke=\"rgba(0,0,0,0.08)\" strokeWidth=\"0.6\" />\n          {petals.map((color, index) => (\n            <ellipse key={color} cx=\"10\" cy=\"6.7\" rx=\"2.2\" ry=\"3.4\" fill={color} fillOpacity=\"0.85\" transform={`rotate(${index * 45} 10 10)`} />\n          ))}\n        </svg>\n      );\n    }\n    case \"stickers\":\n      return (\n        <svg {...common}>\n          <defs>\n            <linearGradient id=\"pm-sticker\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stopColor=\"#d7c9f6\" /><stop offset=\"0.55\" stopColor=\"#a7b6f0\" /><stop offset=\"1\" stopColor=\"#8fc4ef\" /></linearGradient>\n            <linearGradient id=\"pm-sticker-peel\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stopColor=\"#ffffff\" /><stop offset=\"1\" stopColor=\"#c9cff8\" /></linearGradient>\n          </defs>\n          <path d=\"M10 .5a9.5 9.5 0 1 0 9.5 9.5c0-.4 0-.8-.1-1.2A8.6 8.6 0 0 1 11.2.6C10.8.5 10.4.5 10 .5Z\" fill=\"url(#pm-sticker)\" />\n          <path d=\"M11.2.6a8.6 8.6 0 0 0 8.2 8.2A9.5 9.5 0 0 0 11.2.6Z\" fill=\"url(#pm-sticker-peel)\" />\n        </svg>\n      );\n    case \"genmoji\":\n      return (\n        <svg {...common}>\n          <defs>\n            <linearGradient id=\"pm-genmoji\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stopColor=\"#f6a94b\" /><stop offset=\"0.5\" stopColor=\"#ee5f8a\" /><stop offset=\"1\" stopColor=\"#7c6df0\" /></linearGradient>\n          </defs>\n          <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"url(#pm-genmoji)\" />\n          <circle cx=\"9.3\" cy=\"10.6\" r=\"5\" fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"1.3\" />\n          <circle cx=\"7.5\" cy=\"9.4\" r=\"0.8\" fill=\"#ffffff\" />\n          <circle cx=\"11.1\" cy=\"9.4\" r=\"0.8\" fill=\"#ffffff\" />\n          <path d=\"M6.6 11.9h5.4a2.7 2.7 0 0 1-5.4 0Z\" fill=\"#ffffff\" />\n          <path d=\"M15.2 3.6v3.4M13.5 5.3h3.4\" stroke=\"#ffffff\" strokeWidth=\"1.2\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    case \"image-playground\":\n      return (\n        <svg {...common}>\n          <defs>\n            <linearGradient id=\"pm-playground\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stopColor=\"#f26d6d\" /><stop offset=\"0.5\" stopColor=\"#8f7cf2\" /><stop offset=\"1\" stopColor=\"#4fc3f0\" /></linearGradient>\n          </defs>\n          <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"#141414\" />\n          <circle cx=\"10\" cy=\"10\" r=\"8.3\" fill=\"none\" stroke=\"#2c2c2c\" strokeWidth=\"1.2\" />\n          <circle cx=\"10\" cy=\"10\" r=\"3.6\" fill=\"none\" stroke=\"url(#pm-playground)\" strokeWidth=\"1.2\" />\n          {[0, 60, 120, 180, 240, 300].map(angle => (\n            <circle key={angle} cx=\"10\" cy=\"6.4\" r=\"1\" fill=\"url(#pm-playground)\" transform={`rotate(${angle} 10 10)`} />\n          ))}\n        </svg>\n      );\n    case \"images\":\n      return (\n        <svg {...common}>\n          <defs>\n            <linearGradient id=\"pm-images\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stopColor=\"#f26b76\" /><stop offset=\"1\" stopColor=\"#e83e50\" /></linearGradient>\n          </defs>\n          <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"url(#pm-images)\" />\n          <circle cx=\"9\" cy=\"9\" r=\"4.6\" fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"1.3\" />\n          <path d=\"M5.2 7.6h7.6M5.2 10.4h7.6M9 4.4v9.2M6.7 4.9c-1.3 2.6-1.3 5.6 0 8.2M11.3 4.9c1.3 2.6 1.3 5.6 0 8.2\" stroke=\"#ffffff\" strokeWidth=\"0.8\" fill=\"none\" />\n          <path d=\"M12.4 12.4 15.4 15.4\" stroke=\"#ffffff\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    case \"message-effects\":\n      return (\n        <svg {...common}>\n          <circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"#f5b53f\" />\n          <path d=\"M8.9 11.1 14 6\" stroke=\"#ffffff\" strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n          <path d=\"M6.8 4.6v2.2M4.6 6.8h2.2M5.2 5.2l1.5 1.5M9.6 4.6l-.6 2.1M4.6 9.6l2.1-.6M5.3 12.2l1.6-1.5\" stroke=\"#ffffff\" strokeWidth=\"1.1\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    default:\n      return <svg {...common}><circle cx=\"10\" cy=\"10\" r=\"10\" fill=\"#8e8e93\" /></svg>;\n  }\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-plus-menu.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "macos-details",
      "title": "macOS conversation details",
      "description": "The inspector pane that slides in from the right of the window: contact, call actions, shared photos, links and documents, and the conversation options.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/macos-details.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar } from \"@/components/imessage/avatar\";\n\n/**\n * macOS 26 conversation details: the inspector column that slides in from the trailing edge of the\n * Messages window.\n *\n * **NO CAPTURE IN THIS REPO SHOWS THIS PANE.** Every `references/macos/captures/*.png` is a crop of\n * window x 330-960 of a window with no inspector open, so nothing here was measured off a frame.\n * What is below comes from three places, and each number says which:\n *\n * 1. **The framework.** macOS Messages is a Mac Catalyst app on ChatKit\n *    (`/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework`, 26.5 on macOS 26.5.2\n *    build 25F84). Read by dlopening it from a `arm64-apple-ios26.0-macabi` binary, swizzling\n *    `-[UIDevice userInterfaceIdiom]` to return 5 (Mac) so `+[CKUIBehavior sharedBehaviors]` vends\n *    `CKUIBehaviorMac`, then reading the selectors named against each value. The same probe returns\n *    `balloonTextFont` 13 pt, `transcriptMessageStatusDateFont` 9 pt, `balloonContiguousSpace` 3,\n *    `conversationListContactImageDiameter` 40, `defaultConversationListWidth` 320 and\n *    `_transcriptBackgroundColor` #ffffff / #1e1e1e, all of which SPEC has already measured off a\n *    capture and all of which agree, so the probe is reading genuine Mac values.\n * 2. **Numbers measured elsewhere in the kit**, reused and named as such.\n * 3. **Judgement**, called judgement, both here and in the report.\n *\n * One caution about the framework's fonts. `-[CKUIBehaviorMac conversationListSenderFont]` is 17 pt\n * semibold and `searchBarFont` is 17 pt, but SPEC measures both of those surfaces at 13 pt in\n * `conversation-pane-*.png`, so macOS 26's redesigned chrome plainly does not read those selectors\n * any more. Its geometry selectors do still agree with the captures (the five cross-checks above).\n * So this file takes **geometry** from the framework and **type sizes** from the measured macOS\n * scale in SPEC, and never quotes `detailsGroupHeaderCellTitleFont` (17 pt) as the name's size.\n *\n * ### Geometry, from ChatKit\n *\n * | Value | Selector on `CKUIBehaviorMac` |\n * |---|---|\n * | column 300 wide, resizable 280-400 | `defaultInspectorColumnWidth`, `minInspectorColumnWidth`, `maxInspectorColumnWidth` |\n * | content inset 16 | `searchDetailsLeadingAndTrailingMaxPadding`, and `searchDetailsResultsInsets` t12 l16 b16 r16 |\n * | contact photo O 37 | `detailsAvatarDiameter` = `detailsViewContactImageDiameter` |\n * | photo to name 12 | `detailsContactAvatarLabelSpacing` |\n * | name to subtitle 1 | `detailsGroupHeaderCellInterTextVerticalSpacing` |\n * | group photo stack 58 wide for two, 72 for three | `detailsAvatarPancakeViewWidth2Avatars`, `...3Avatars` |\n * | action button O 32 | `detailsAddButtonDiameter` |\n * | action glyph box 25, gap 12 | `detailsContactCellButtonWidth`/`Height`, `detailsContactCellButtonEdgeInsets` t8 l6 b8 r6 |\n * | section header 16 above, 12 below, 16 at the section's foot | `detailsSectionHeaderPaddingAbove`, `searchResultsTitleHeaderDetailsTopPadding`, `searchDetailsResultsInsets.bottom` |\n * | photo grid gap 10, tile radius 8 | `searchPhotosInterItemSpacingDetailsView`, `searchPhotosCellZKWAndDetailsCornerRadius` |\n * | link and document card radius 8 | `searchLinksCellCornerRadius`, `searchAttachmentsCellCornerRadius` |\n * | row height 40 | `detailsContactCellMinimumHeight` |\n * | option row height 44 | `+[CKDetailsChatOptionsCell estimatedHeight]` |\n * | Hide Alerts is a **16 x 16 checkbox**, not a switch | `-[CKDetailsChatOptionsCell controlSwitch]` is a `UISwitch`, and a `UISwitch` built under the Mac idiom reports `style` 1 (`UISwitchStyleCheckbox`) and `intrinsicContentSize` 16 x 16 |\n *\n * ### Colours, from `-[CKUIBehaviorMac theme]` (a `CKUIThemeMac`), resolved through a light and a dark `UITraitCollection`\n *\n * | Token | Light | Dark | Selector |\n * |---|---|---|---|\n * | label | rgba(0,0,0,0.847) | rgba(255,255,255,0.847) | `primaryLabelColor` |\n * | secondary | rgba(0,0,0,0.498) | rgba(255,255,255,0.549) | `secondaryLabelColor`, = `detailsContactCellSubTitleColor` |\n * | tertiary | rgba(0,0,0,0.259) | rgba(255,255,255,0.247) | `tertiaryLabelColor`, = `detailsContactCellChevronColor` |\n * | tint | #0088ff | #0091ff | `appTintColor`, = `detailsSeeAllButtonTextColor` = `iosMacDetailsButtonColor` |\n * | control fill | rgba(0,0,0,0.098) | rgba(255,255,255,0.098) | `detailsAddButtonBackgroundColor` |\n * | destructive | #ff383c | #ff4245 | `background_sendButtonColor` |\n *\n * ### Glyphs, rendered out of the framework\n *\n * `-[CKUIBehaviorMac detailsViewPhoneImage]`, `detailsViewFaceTimeVideoImage` and\n * `macToolbarDetailsImage` (`info.circle` at `macToolbarImagePointSize` 22) were drawn into 8x\n * bitmaps and traced. Ink boxes: phone 15.5 x 15.5 inside a 19.5 x 17.5 image, video 20.5 x 13.5\n * inside 24 x 15.5, info 22 x 22 inside 26 x 25. The video body is 14.375 x 13.5 with a corner whose\n * tangents sit 2.0 in, then a 1 pt gap, then a lens whose near edge stands at x 17.965 over y\n * 5.5-10.0 and whose arms run 1.15 across per 1 down to a right edge at x 23. The info ring is a\n * 1.875 stroke on a O 22 circle, its dot is O 2.8 centred (12.94, 7.3), and its stem is a 1.625\n * round-capped stroke. The phone reuses the `phone.fill` path already traced in `ios-details.tsx`,\n * drawn at this ink box.\n *\n * ### Everything the pane is made of that is NOT measured, and is judgement\n *\n * - Which sections it shows and in what order. `CKUIBehaviorMac detailsSectionCount` is 17 and\n *   ChatKit 26 carries `DetailsInfoTab`, `DetailsPhotosTab`, `DetailsLinksTab`, `DetailsLocationsTab`,\n *   `DetailsAttachmentsTab` and `DetailsWalletTab`, so the real pane has more than this, and may put\n *   them behind a tab strip rather than stacking them.\n * - Three photo columns, so an 82.667 tile at 300 wide.\n * - The pane's own top band. It reserves the 55 pt SPEC gives the conversation header so its content\n *   starts under the same toolbar line, and puts the Hide Details button at the band's trailing end.\n *   Native almost certainly leaves that button in the conversation's toolbar instead.\n * - That the column runs flush to the window's edges. SPEC measures the *sidebar* on macOS 26 as a\n *   floating panel inset 8 on three sides with a continuous corner, so the inspector may well float\n *   the same way. Its fill and rim are that panel's measured ones (#fafafa / #1b1b1b, rim #ffffff /\n *   #424242); its shape is not.\n * - The checkbox's drawing (only its 16 x 16 box is framework), the type weights, and the row\n *   internals of the link and document cards.\n *\n * ### The presentation is UNMEASURED\n *\n * Nothing in this repo records this pane in motion, and ChatKit vends no duration for it: there is no\n * `details`- or `inspector`-named timing anywhere on `CKUIBehaviorMac`. So `macDetailsMotion` below\n * is a choice, not a reading. Say so wherever it is quoted.\n *\n * Copy comes from ChatKit.strings: \"Hide Details\" (`HIDE_DETAILS_VIEW`), \"Photos\"\n * (`PHOTOS_MENU_ITEM_TITLE`), \"Links\" (`LINKS`), \"Documents\" (`SEARCH_ATTACHMENTS_TITLE`), \"See All\n * Photos\" / \"See All Links\" / \"See All Attachments\", \"Hide Alerts\"\n * (`DETAILS_VIEW_HIDE_ALERTS_TOGGLE_TITLE`), \"Block Contact\" (`BLOCK_CONTACT`), \"Delete\n * Conversation…\" (`DELETE_CONVERSATION_ELLIPSIS`), \"Leave this Conversation\" (`LEAVE_CONVERSATION`).\n */\n\nconst font = '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/** Framework geometry, plus the two judgement calls that shape the layout. */\nexport const macDetailsMetrics = {\n  /** `-[CKUIBehaviorMac defaultInspectorColumnWidth]`, with its own min and max. */\n  width: 300,\n  minWidth: 280,\n  maxWidth: 400,\n  /** SPEC \"macOS Chrome\": the toolbar's layout height, so the pane's content starts on the same line. */\n  headerHeight: 55,\n  /** `searchDetailsLeadingAndTrailingMaxPadding`, and the left and right of `searchDetailsResultsInsets`. */\n  inset: 16,\n  /** `detailsAvatarDiameter`. */\n  avatar: 37,\n  /** `detailsContactAvatarLabelSpacing`. */\n  avatarGap: 12,\n  /** `detailsGroupHeaderCellInterTextVerticalSpacing`. */\n  nameGap: 1,\n  /** `detailsAvatarPancakeViewWidth2Avatars` / `...3Avatars`, so the step is 21 for two and 17.5 for three. */\n  stack: { two: 58, three: 72 },\n  /** `detailsAddButtonDiameter`. `detailsContactCellButtonWidth`/`Height` give the 25 pt tap box. */\n  actionButton: 32,\n  actionHit: 25,\n  /** The left and right of `detailsContactCellButtonEdgeInsets`, added. */\n  actionGap: 12,\n  /** `detailsSectionHeaderPaddingAbove`, `searchResultsTitleHeaderDetailsTopPadding`, `searchDetailsResultsInsets.bottom`. */\n  sectionTop: 16,\n  sectionHeaderGap: 12,\n  sectionBottom: 16,\n  /** `searchPhotosInterItemSpacingDetailsView` and `searchPhotosCellZKWAndDetailsCornerRadius`. */\n  photoGap: 10,\n  photoRadius: 8,\n  /** Judgement: nothing in the framework says how many columns the grid runs at 300 wide. */\n  photoColumns: 3,\n  /** `searchLinksCellCornerRadius` = `searchAttachmentsCellCornerRadius`. */\n  cardRadius: 8,\n  /** `detailsContactCellMinimumHeight` for a card row, `+[CKDetailsChatOptionsCell estimatedHeight]` for an option. */\n  rowHeight: 40,\n  optionHeight: 44,\n  /** `-[UISwitch intrinsicContentSize]` under the Mac idiom, where its `style` resolves to checkbox. */\n  checkbox: 16,\n} as const;\n\n/**\n * UNMEASURED, and unmeasurable from anything committed here: no capture records the pane in motion,\n * and ChatKit has no duration selector for it. These sit in the family the kit already uses.\n *\n * - The slide runs 300 ms on `cubic-bezier(0.32, 0.72, 0, 1)`, the curve `ios-details.tsx` (320 ms),\n *   `ios-effects-picker.tsx` (260 ms) and the iOS shell's screen transitions (400 ms) all use.\n * - The overlay dim fades over 220 ms, a little ahead of the slide, the way the measured long-press\n *   dim (150 of its 600 ms) leads its menu.\n * - The way out is 250 ms, between the measured macOS menu dismiss (~120 ms) and the iOS details\n *   dismiss (260 ms).\n * - The sections settle after the panel in a short stagger, matching `iosDetailsMotion`'s cells.\n */\nexport const macDetailsMotion = {\n  enter: 300,\n  exit: 250,\n  dim: 220,\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n  exitEase: \"cubic-bezier(0.4, 0, 1, 1)\",\n  sectionStart: 70,\n  sectionStagger: 26,\n  sectionRise: 10,\n  sectionDuration: 190,\n} as const;\n\n/**\n * Light and dark live in CSS variables so a `.dark` ancestor flips the whole pane, the way every\n * other file in the kit does it. Sources are in the header table.\n */\nconst vars =\n  \"[--mdt-panel:#fafafa] [--mdt-ground:#f8f8f8] [--mdt-rim:#ffffff] [--mdt-label:rgba(0,0,0,0.847)] \" +\n  \"[--mdt-secondary:rgba(0,0,0,0.498)] [--mdt-tertiary:rgba(0,0,0,0.259)] [--mdt-tint:#0088ff] \" +\n  \"[--mdt-fill:rgba(0,0,0,0.098)] [--mdt-fill-hover:rgba(0,0,0,0.145)] [--mdt-red:#ff383c] \" +\n  \"[--mdt-separator:#e1e1e1] [--mdt-tile:#e9e9eb] [--mdt-scrim:rgba(0,0,0,0.10)] [--mdt-knob:#ffffff] \" +\n  \"dark:[--mdt-panel:#1b1b1b] dark:[--mdt-ground:#1c1c1c] dark:[--mdt-rim:#424242] dark:[--mdt-label:rgba(255,255,255,0.847)] \" +\n  \"dark:[--mdt-secondary:rgba(255,255,255,0.549)] dark:[--mdt-tertiary:rgba(255,255,255,0.247)] dark:[--mdt-tint:#0091ff] \" +\n  \"dark:[--mdt-fill:rgba(255,255,255,0.098)] dark:[--mdt-fill-hover:rgba(255,255,255,0.16)] dark:[--mdt-red:#ff4245] \" +\n  \"dark:[--mdt-separator:#3a3a3a] dark:[--mdt-tile:#3b3b3d] dark:[--mdt-scrim:rgba(0,0,0,0.28)]\";\n\n/** The focus ring the rest of the macOS chrome uses (`macos-header.tsx`, `macos-composer.tsx`). */\nconst focusRing = \"outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#3478f6]\";\n\nexport type MacDetailsAction = {\n  id: string;\n  label: string;\n  icon: \"phone\" | \"video\" | \"info\";\n  disabled?: boolean;\n  onPress?: () => void;\n};\n\nexport type MacDetailsPhoto = {\n  id: string;\n  /** Photo URL. Without one the tile paints `fill`, so the pane renders with no assets. */\n  src?: string;\n  fill?: string;\n  alt?: string;\n  onOpen?: () => void;\n};\n\nexport type MacDetailsLink = {\n  id: string;\n  title: string;\n  /** The hostname under the title. */\n  host: string;\n  onOpen?: () => void;\n};\n\nexport type MacDetailsAttachment = {\n  id: string;\n  name: string;\n  /** A size, a date, whatever belongs on the second line. */\n  meta?: string;\n  onOpen?: () => void;\n};\n\nexport type MacDetailsProps = Omit<ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  name: string;\n  initials?: string;\n  /** Contact photo URL, or a whole avatar node. */\n  photo?: string;\n  avatar?: ReactNode;\n  /** Two or three of these draw the group photo stack instead of one circle. */\n  participants?: Array<{ id: string; name: string; initials?: string; photo?: string }>;\n  /** The handle under the name, or the participant count for a group. */\n  subtitle?: string;\n\n  actions?: MacDetailsAction[];\n  photos?: MacDetailsPhoto[];\n  links?: MacDetailsLink[];\n  attachments?: MacDetailsAttachment[];\n  onSeeAllPhotos?: () => void;\n  onSeeAllLinks?: () => void;\n  onSeeAllAttachments?: () => void;\n\n  hideAlerts?: boolean;\n  onHideAlertsChange?: (next: boolean) => void;\n  /** A group conversation offers Leave in place of Block. */\n  onLeave?: () => void;\n  onBlock?: () => void;\n  onDelete?: () => void;\n\n  /** Closes the pane. Escape does the same. */\n  onClose?: () => void;\n\n  /**\n   * The conversation the pane opens beside. `push` shrinks it by the pane's width as the pane slides\n   * in, which is what an inspector column does; `overlay` leaves it alone and dims it under the pane.\n   */\n  conversation?: ReactNode;\n  mode?: \"push\" | \"overlay\";\n  width?: number;\n\n  /**\n   * Seek the presentation instead of playing it, which is what the harness does: while `open`, 0 is\n   * closed and 1 is settled; while it is closing, 0 is settled and 1 is gone. Leave it unset for the\n   * real thing.\n   */\n  progress?: number;\n  /** False plays the dismissal; `onExited` fires when it is over, and the consumer unmounts then. */\n  open?: boolean;\n  onExited?: () => void;\n};\n\nfunction prefersReducedMotion() {\n  return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\nfunction clamp01(value: number) { return Math.max(0, Math.min(1, value)); }\nfunction stop(animation: Animation) { try { animation.cancel(); } catch { /* already gone */ } }\n\ntype Pose = Record<string, string>;\ntype Layer = { el: HTMLElement; from: Pose; to: Pose; duration: number; delay: number; easing: string };\n\n/**\n * Every layer of the presentation, as the pose it holds while closed and the pose it settles on. The\n * settled pose is what each element already carries at rest, so cancelling the timeline once it lands\n * leaves the pane with no animation and no transform at all, and a settled checkpoint screenshots the\n * plain styles twice over.\n */\nfunction detailsLayers(panel: HTMLElement, pane: HTMLElement | null, scrim: HTMLElement | null, width: number): Layer[] {\n  const m = macDetailsMotion;\n  const layers: Layer[] = [];\n  const add = (el: HTMLElement | null, from: Pose, to: Pose, duration: number, delay = 0, easing: string = m.ease) => {\n    if (el) layers.push({ el, from, to, duration, delay, easing });\n  };\n  add(panel, { translate: `${width}px 0px` }, { translate: \"0px 0px\" }, m.enter);\n  // The push is the conversation's own trailing inset, so its bubbles rewrap as it narrows, which is\n  // what an inspector column does to the view beside it.\n  add(pane, { right: \"0px\" }, { right: `${width}px` }, m.enter);\n  add(scrim, { opacity: \"0\" }, { opacity: \"1\" }, m.dim, 0, \"ease-out\");\n  Array.from(panel.querySelectorAll<HTMLElement>('[data-slot=\"details-section\"]')).forEach((el, index) =>\n    add(el, { translate: `0px ${m.sectionRise}px`, opacity: \"0\" }, { translate: \"0px 0px\", opacity: \"1\" },\n      m.sectionDuration, m.sectionStart + index * m.sectionStagger));\n  return layers;\n}\n\n/** What an element holds right now, so a dismissal can start from a half-played entrance. */\nfunction poseNow(el: HTMLElement, shape: Pose): Pose {\n  const style = getComputedStyle(el);\n  const pose: Pose = {};\n  for (const key of Object.keys(shape)) pose[key] = style.getPropertyValue(key.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)) || shape[key];\n  return pose;\n}\n\n/** Web Animations, not a rAF loop and not a transition, so `document.getAnimations()` can seek a frame. */\nfunction runLayers(layers: Layer[], phase: \"enter\" | \"exit\"): Animation[] {\n  const m = macDetailsMotion;\n  return layers.map(({ el, from, to, duration, delay, easing }) => phase === \"enter\"\n    ? el.animate([from, to], { duration, delay, easing, fill: \"both\" })\n    // One flat span on the way out, starting from wherever the layer stands now.\n    : el.animate([poseNow(el, from), from], { duration: m.exit, easing: m.exitEase, fill: \"both\" }));\n}\n\n/**\n * SF Symbol stand-ins at the ink boxes the framework's own images measure, so the three keep their\n * relative weights. `phone` and `video` are `-[CKUIBehaviorMac detailsViewPhoneImage]` and\n * `detailsViewFaceTimeVideoImage`, which are the details view's own glyphs; `info` is\n * `macToolbarDetailsImage`, which is `info.circle` at `macToolbarImagePointSize` 22 and belongs to the\n * toolbar, so the button row draws it at 17.5 to sit in the other two's family. That 17.5 is\n * judgement: the framework has no details-view info image to measure.\n */\nfunction ActionGlyph({ icon, infoSize = 17.5 }: { icon: MacDetailsAction[\"icon\"]; infoSize?: number }) {\n  if (icon === \"phone\") {\n    // `phone.fill`: ink 15.5 square inside the framework's 19.5 x 17.5 image. The path is the one\n    // traced for `ios-details.tsx` off `references/ios/captures/details-light.png`, in its own\n    // 13.5-square viewBox, drawn here at the box this image measures.\n    return (\n      <svg aria-hidden=\"true\" width=\"15.5\" height=\"15.5\" viewBox=\"1.72 1.25 13.5 13.5\" fill=\"currentColor\">\n        <path d=\"M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.6 17.6 0 0 0 4.168 6.608 17.6 17.6 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.68.68 0 0 0-.58-.122l-2.19.547a1.75 1.75 0 0 1-1.657-.459L5.482 8.062a1.75 1.75 0 0 1-.46-1.657l.548-2.19a.68.68 0 0 0-.122-.58z\" />\n      </svg>\n    );\n  }\n  if (icon === \"video\") {\n    // `video.fill`: body x 2.5-16.875 y 1.0-14.5 with its corner tangents 2.0 in, then the lens, whose\n    // near edge stands at x 17.965 over y 5.5-10.0 and whose arms run 1.15 across per 1 down out to a\n    // right edge at x 23. Traced off the 8x render of `detailsViewFaceTimeVideoImage`.\n    return (\n      <svg aria-hidden=\"true\" width=\"20.5\" height=\"13.5\" viewBox=\"2.5 1 20.5 13.5\" fill=\"currentColor\">\n        <rect x=\"2.5\" y=\"1\" width=\"14.375\" height=\"13.5\" rx=\"2\" />\n        <path d=\"M17.97 5.55c0-.5.18-.8.58-1.05l2.75-2.15c.75-.6 1.7-.2 1.7.8v9.2c0 1-.95 1.4-1.7.8l-2.75-2.15c-.4-.25-.58-.55-.58-1.05z\" />\n      </svg>\n    );\n  }\n  // `info.circle` at `macToolbarImagePointSize` 22: a 1.875 stroke on a O 22 ring centred (13, 12.5),\n  // a O 2.8 dot centred (12.94, 7.3), and a 1.625 round-capped \"i\" whose stem runs y 11.31-17.69.\n  return (\n    <svg aria-hidden=\"true\" width={infoSize} height={infoSize} viewBox=\"1.5 1 23 23\" fill=\"none\">\n      <circle cx=\"13\" cy=\"12.5\" r=\"10.0625\" stroke=\"currentColor\" strokeWidth=\"1.875\" />\n      <circle cx=\"12.94\" cy=\"7.3\" r=\"1.4\" fill=\"currentColor\" />\n      <path d=\"M11.5 11.31h1.81v6.38M11.19 17.69h4.25\" stroke=\"currentColor\" strokeWidth=\"1.625\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * macOS 26 checkbox. Its 16 x 16 box is the framework's (`-[UISwitch intrinsicContentSize]` under the\n * Mac idiom, where `style` resolves to `UISwitchStyleCheckbox`); the corner and the tick are drawn,\n * not measured, because no capture in this repo holds a checkbox.\n */\nexport type MacCheckboxProps = Omit<ComponentProps<\"button\">, \"onChange\"> & {\n  checked?: boolean;\n  onChange?: (next: boolean) => void;\n  label?: string;\n};\n\nexport function MacCheckbox({ checked = false, onChange, label, className, style, ...rest }: MacCheckboxProps) {\n  const size = macDetailsMetrics.checkbox;\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"mac-checkbox\"\n      role=\"checkbox\"\n      aria-checked={checked}\n      aria-label={label}\n      onClick={() => onChange?.(!checked)}\n      className={cn(\"relative shrink-0 rounded-[4px] transition-colors motion-reduce:transition-none\", focusRing, className)}\n      style={{\n        width: size, height: size,\n        background: checked ? \"var(--mdt-tint)\" : \"var(--mdt-fill)\",\n        boxShadow: checked ? \"none\" : \"inset 0 0 0 1px var(--mdt-tertiary)\",\n        ...style,\n      }}\n      {...rest}\n    >\n      <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" width={size} height={size} className=\"block\" fill=\"none\"\n        stroke=\"var(--mdt-knob)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\n        style={{ opacity: checked ? 1 : 0 }}>\n        <path d=\"M4.2 8.4 6.9 11.1 11.9 5.2\" />\n      </svg>\n    </button>\n  );\n}\n\n/** The group photo stack. Widths are the framework's; the step falls out of them. */\nfunction ContactPhoto({ name, initials, photo, avatar, participants }: Pick<MacDetailsProps, \"name\" | \"initials\" | \"photo\" | \"avatar\" | \"participants\">) {\n  const { avatar: size, stack } = macDetailsMetrics;\n  const people = participants ?? [];\n  if (avatar) return <span className=\"flex\">{avatar}</span>;\n  if (people.length < 2) return <Avatar size={size} initials={initials} src={photo} name={name} />;\n  const shown = people.slice(0, 3);\n  const width = shown.length === 2 ? stack.two : stack.three;\n  const step = (width - size) / (shown.length - 1);\n  return (\n    <span className=\"relative block\" style={{ width, height: size }} role=\"img\" aria-label={`${shown.map(person => person.name).join(\", \")}`}>\n      {shown.map((person, index) => (\n        <span key={person.id} className=\"absolute top-0\" style={{ left: index * step, zIndex: shown.length - index }}>\n          <Avatar size={size} initials={person.initials} src={person.photo} name={person.name} aria-hidden=\"true\" />\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction SectionHeading({ id, title, action, onAction }: { id: string; title: string; action?: string; onAction?: () => void }) {\n  const m = macDetailsMetrics;\n  return (\n    <div className=\"flex items-baseline justify-between\" style={{ marginBottom: m.sectionHeaderGap }}>\n      <h2 id={id} className=\"m-0 font-semibold text-[var(--mdt-label)]\" style={{ fontSize: 13, lineHeight: \"16px\", letterSpacing: -0.4 }}>{title}</h2>\n      {action ? (\n        <button type=\"button\" data-slot=\"see-all\" onClick={onAction}\n          className={cn(\"rounded-[4px] bg-transparent p-0 text-[var(--mdt-tint)] hover:underline\", focusRing)}\n          style={{ fontSize: 12, lineHeight: \"15px\", letterSpacing: -0.3 }}>\n          {action}\n        </button>\n      ) : null}\n    </div>\n  );\n}\n\n/** A document glyph for the attachment rows. Drawn, not measured. */\nfunction DocumentGlyph() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 18 22\" width=\"18\" height=\"22\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinejoin=\"round\">\n      <path d=\"M2.7 1.7h7.4l5.2 5.2v13.4a1 1 0 0 1-1 1H2.7a1 1 0 0 1-1-1V2.7a1 1 0 0 1 1-1Z\" />\n      <path d=\"M10.1 1.9v5.1h5.1\" />\n    </svg>\n  );\n}\n\n/** A globe for the link rows. Drawn, not measured. */\nfunction LinkGlyph() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 22 22\" width=\"22\" height=\"22\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\">\n      <circle cx=\"11\" cy=\"11\" r=\"9.3\" />\n      <ellipse cx=\"11\" cy=\"11\" rx=\"4\" ry=\"9.3\" />\n      <path d=\"M2 8.2h18M2 13.8h18\" />\n    </svg>\n  );\n}\n\nexport function MacDetails({\n  name, initials, photo, avatar, participants, subtitle,\n  actions = [], photos = [], links = [], attachments = [],\n  onSeeAllPhotos, onSeeAllLinks, onSeeAllAttachments,\n  hideAlerts = false, onHideAlertsChange, onLeave, onBlock, onDelete, onClose,\n  conversation, mode = \"push\", width = macDetailsMetrics.width,\n  progress, open = true, onExited, className, style, ...props\n}: MacDetailsProps) {\n  const m = macDetailsMetrics;\n  const titleId = useId();\n  const photosId = `${titleId}-photos`;\n  const linksId = `${titleId}-links`;\n  const filesId = `${titleId}-files`;\n  const optionsId = `${titleId}-options`;\n\n  const panel = useRef<HTMLDivElement>(null);\n  const pane = useRef<HTMLDivElement>(null);\n  const scrim = useRef<HTMLDivElement>(null);\n  const timeline = useRef<Animation[] | null>(null);\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n\n  // The dismissal is derived during render, not in an effect: an effect leaves one committed frame\n  // with the pane already gone and the exit never runs. `closing` also separates a pane that is\n  // leaving (fire `onExited`) from one mounted closed, which just sits off screen.\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) { setSeenOpen(open); setClosing(!open); }\n\n  const build = (phase: \"enter\" | \"exit\"): Animation[] | null => {\n    const node = panel.current;\n    if (!node) return null;\n    return runLayers(detailsLayers(node, mode === \"push\" ? pane.current : null, scrim.current, width), phase);\n  };\n  const land = (list: Animation[]) => {\n    if (timeline.current !== list) return;\n    list.forEach(stop);\n    timeline.current = null;\n  };\n\n  // Only unmount cancels the timeline. The two phases hand over to each other without one, so a\n  // dismissal can read the pose the entrance is still holding.\n  useEffect(() => () => { timeline.current?.forEach(stop); timeline.current = null; }, []);\n\n  useLayoutEffect(() => {\n    if (prefersReducedMotion()) return;\n    const previous = timeline.current;\n    const next = build(open ? \"enter\" : \"exit\");\n    previous?.forEach(stop);\n    timeline.current = next;\n    // One rebuild per phase, and `build` only reads refs.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, width, mode]);\n\n  useLayoutEffect(() => {\n    // Reduced motion: the pane is simply there, and leaves at once. Its resting styles are the\n    // settled pose, so there is nothing to undo.\n    if (prefersReducedMotion()) { if (!open && closing) exited.current?.(); return; }\n    const list = timeline.current ?? build(open ? \"enter\" : \"exit\");\n    if (!list) return;\n    timeline.current = list;\n    const total = open ? macDetailsMotion.enter : macDetailsMotion.exit;\n    const seek = (time: number) => list.forEach(animation => { animation.pause(); try { animation.currentTime = time; } catch { /* no timeline yet */ } });\n    if (progress !== undefined) {\n      const time = clamp01(progress) * total;\n      seek(time);\n      // A settled checkpoint gets screenshotted, so drop the timeline there and leave the plain styles.\n      if (open && time >= total) land(list);\n      return;\n    }\n    // Mounted closed rather than closing: hold the dismissed pose instead of playing a dismissal.\n    if (!open && !closing) { seek(total); return; }\n    let dropped = false;\n    list.forEach(animation => animation.play());\n    Promise.allSettled(list.map(animation => animation.finished)).then(() => {\n      if (dropped) return;\n      if (open) land(list);\n      else if (closing && timeline.current === list) exited.current?.();\n    });\n    return () => { dropped = true; };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, progress, closing, width, mode]);\n\n  // The pane covers part of the window, so Escape backs out of it the way the close button does.\n  useEffect(() => {\n    if (!open || !onClose) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onClose(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onClose]);\n\n  const inert = !open && !closing;\n  const contentWidth = width - m.inset * 2;\n  const tile = (contentWidth - m.photoGap * (m.photoColumns - 1)) / m.photoColumns;\n  const shownPhotos = photos.slice(0, m.photoColumns * 2);\n  const sectionStyle: CSSProperties = { paddingTop: m.sectionTop, paddingBottom: m.sectionBottom };\n\n  return (\n    <div\n      data-slot=\"mac-details\"\n      className={cn(\"relative isolate size-full overflow-hidden bg-[var(--mdt-ground)] text-[var(--mdt-label)]\", vars, className)}\n      style={{ fontFamily: font, WebkitFontSmoothing: \"antialiased\", ...style }}\n      {...props}\n    >\n      {/* Resting styles ARE the settled pose, so cancelling the timeline once it lands leaves the pane\n          with no animation at all and a settled checkpoint screenshots the plain styles. A pane that is\n          mounted closed rests on the closed pose instead, which is also what reduced motion renders. */}\n      {conversation ? (\n        <div ref={pane} data-slot=\"details-conversation\" className=\"absolute inset-y-0 left-0\" style={{ right: inert || mode !== \"push\" ? 0 : width }}>\n          {conversation}\n        </div>\n      ) : null}\n\n      {/* Overlay mode dims what the pane covers; push mode moves it instead and needs no scrim. */}\n      {mode === \"overlay\" && conversation ? (\n        <div ref={scrim} aria-hidden=\"true\" data-slot=\"details-scrim\" className=\"absolute inset-y-0 left-0 bg-[var(--mdt-scrim)]\" style={{ right: width, opacity: inert ? 0 : 1 }} />\n      ) : null}\n\n      <div\n        ref={panel}\n        data-slot=\"details-panel\"\n        role=\"complementary\"\n        aria-label=\"Conversation details\"\n        aria-hidden={inert || undefined}\n        className=\"absolute inset-y-0 right-0 flex flex-col bg-[var(--mdt-panel)]\"\n        style={{\n          width,\n          minWidth: m.minWidth,\n          // A 1 pt bright rim down the leading edge, the sidebar panel's measured rim reused (SPEC\n          // \"macOS Chrome\": light #ffffff, dark #424242), over the soft shadow the sidebar casts on\n          // the pane beside it. Overlay needs a heavier one, riding above the conversation instead of\n          // beside it. Both shadows are judgement; the rim is measured, on the other panel.\n          boxShadow: mode === \"overlay\"\n            ? \"inset 1px 0 0 var(--mdt-rim), -14px 0 34px rgba(0,0,0,0.14)\"\n            : \"inset 1px 0 0 var(--mdt-rim), -10px 0 22px rgba(0,0,0,0.05)\",\n          pointerEvents: inert ? \"none\" : undefined,\n          translate: inert ? `${width}px 0px` : undefined,\n        }}\n      >\n        {/* The pane reserves the conversation header's measured 55 pt so its content starts on the\n            same line. Where the close button belongs is judgement: native most likely leaves the\n            info.circle toggle in the window's own toolbar. */}\n        <div data-slot=\"details-toolbar\" className=\"flex shrink-0 items-center justify-end\" style={{ height: m.headerHeight, paddingRight: m.inset }}>\n          {onClose ? (\n            <button type=\"button\" data-slot=\"details-close\" aria-label=\"Hide Details\" onClick={onClose}\n              className={cn(\"flex items-center justify-center rounded-full bg-transparent p-0 text-[var(--mdt-tint)] hover:bg-[var(--mdt-fill)]\", focusRing)}\n              style={{ width: m.actionButton, height: m.actionButton }}>\n              {/* The toolbar's own info.circle, at its `macToolbarImagePointSize` 22. */}\n              <ActionGlyph icon=\"info\" infoSize={22} />\n            </button>\n          ) : null}\n        </div>\n\n        <div data-slot=\"details-scroll\" className=\"mac-details-scroll min-h-0 flex-1 overflow-y-auto\" style={{ paddingInline: m.inset, paddingBottom: m.inset }}>\n          <style>{\".mac-details-scroll{scrollbar-width:none}.mac-details-scroll::-webkit-scrollbar{display:none}\"}</style>\n\n          <div data-slot=\"details-section\" className=\"flex flex-col items-center\" style={{ paddingBottom: m.sectionBottom }}>\n            <ContactPhoto name={name} initials={initials} photo={photo} avatar={avatar} participants={participants} />\n            <h1 id={titleId} className=\"m-0 text-center font-bold text-[var(--mdt-label)]\"\n              style={{ marginTop: m.avatarGap, fontSize: 13, lineHeight: \"16px\", letterSpacing: 0 }}>\n              {name}\n            </h1>\n            {subtitle ? (\n              <p className=\"m-0 text-center text-[var(--mdt-secondary)]\" style={{ marginTop: m.nameGap, fontSize: 11, lineHeight: \"14px\", letterSpacing: -0.2 }}>{subtitle}</p>\n            ) : null}\n\n            {/* Equal columns rather than a flex row, so the O 32 discs stay evenly pitched however wide\n                their labels run. The 12 between columns is the framework's; the column width is not. */}\n            {actions.length ? (\n              <div role=\"group\" aria-labelledby={titleId} className=\"grid w-full\"\n                style={{ marginTop: m.sectionTop, gridTemplateColumns: `repeat(${actions.length}, minmax(0, 1fr))`, columnGap: m.actionGap }}>\n                {actions.map(action => (\n                  <div key={action.id} className=\"flex min-w-0 flex-col items-center\">\n                    <button type=\"button\" data-slot=\"details-action\" data-action={action.id}\n                      aria-label={action.label} disabled={action.disabled} onClick={action.onPress}\n                      className={cn(\"flex items-center justify-center rounded-full bg-[var(--mdt-fill)] p-0 text-[var(--mdt-tint)] transition-colors motion-reduce:transition-none\",\n                        \"enabled:hover:bg-[var(--mdt-fill-hover)] disabled:text-[var(--mdt-tertiary)]\", focusRing)}\n                      style={{ width: m.actionButton, height: m.actionButton }}>\n                      <ActionGlyph icon={action.icon} />\n                    </button>\n                    <span aria-hidden=\"true\" className=\"mt-[5px] truncate text-[var(--mdt-secondary)]\"\n                      style={{ fontSize: 11, lineHeight: \"14px\", letterSpacing: -0.2 }}>{action.label}</span>\n                  </div>\n                ))}\n              </div>\n            ) : null}\n          </div>\n\n          {shownPhotos.length ? (\n            <section data-slot=\"details-section\" aria-labelledby={photosId} style={sectionStyle} className=\"border-t border-[var(--mdt-separator)]\">\n              <SectionHeading id={photosId} title=\"Photos\" action={onSeeAllPhotos ? \"See All Photos\" : undefined} onAction={onSeeAllPhotos} />\n              <ul className=\"m-0 grid list-none p-0\" style={{ gridTemplateColumns: `repeat(${m.photoColumns}, ${tile}px)`, gap: m.photoGap }}>\n                {shownPhotos.map((item, index) => (\n                  <li key={item.id} className=\"m-0 p-0\">\n                    <button type=\"button\" data-slot=\"details-photo\" onClick={item.onOpen}\n                      aria-label={item.alt ?? `Shared photo ${index + 1}`}\n                      className={cn(\"relative block overflow-hidden bg-[var(--mdt-tile)] p-0\", focusRing)}\n                      style={{ width: tile, height: tile, borderRadius: m.photoRadius, background: item.src ? \"var(--mdt-tile)\" : (item.fill ?? \"var(--mdt-tile)\") }}>\n                      {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral */}\n                      {item.src ? <img src={item.src} alt=\"\" aria-hidden=\"true\" draggable={false} className=\"absolute inset-0 size-full object-cover\" /> : null}\n                    </button>\n                  </li>\n                ))}\n              </ul>\n            </section>\n          ) : null}\n\n          {links.length ? (\n            <section data-slot=\"details-section\" aria-labelledby={linksId} style={sectionStyle} className=\"border-t border-[var(--mdt-separator)]\">\n              <SectionHeading id={linksId} title=\"Links\" action={onSeeAllLinks ? \"See All Links\" : undefined} onAction={onSeeAllLinks} />\n              <ul className=\"m-0 flex list-none flex-col p-0\" style={{ gap: 2 }}>\n                {links.map(item => (\n                  <li key={item.id} className=\"m-0 p-0\">\n                    <button type=\"button\" data-slot=\"details-link\" onClick={item.onOpen}\n                      className={cn(\"flex w-full items-center bg-transparent p-0 text-left hover:bg-[var(--mdt-fill)]\", focusRing)}\n                      style={{ height: m.rowHeight, borderRadius: m.cardRadius, gap: m.avatarGap, paddingInline: 6 }}>\n                      <span aria-hidden=\"true\" className=\"flex shrink-0 text-[var(--mdt-tertiary)]\"><LinkGlyph /></span>\n                      <span className=\"flex min-w-0 flex-col\">\n                        <span className=\"truncate text-[var(--mdt-label)]\" style={{ fontSize: 13, lineHeight: \"16px\", letterSpacing: -0.4 }}>{item.title}</span>\n                        <span className=\"truncate text-[var(--mdt-secondary)]\" style={{ fontSize: 11, lineHeight: \"14px\", letterSpacing: -0.2 }}>{item.host}</span>\n                      </span>\n                    </button>\n                  </li>\n                ))}\n              </ul>\n            </section>\n          ) : null}\n\n          {attachments.length ? (\n            <section data-slot=\"details-section\" aria-labelledby={filesId} style={sectionStyle} className=\"border-t border-[var(--mdt-separator)]\">\n              <SectionHeading id={filesId} title=\"Documents\" action={onSeeAllAttachments ? \"See All Attachments\" : undefined} onAction={onSeeAllAttachments} />\n              <ul className=\"m-0 flex list-none flex-col p-0\" style={{ gap: 2 }}>\n                {attachments.map(item => (\n                  <li key={item.id} className=\"m-0 p-0\">\n                    <button type=\"button\" data-slot=\"details-attachment\" onClick={item.onOpen}\n                      className={cn(\"flex w-full items-center bg-transparent p-0 text-left hover:bg-[var(--mdt-fill)]\", focusRing)}\n                      style={{ height: m.rowHeight, borderRadius: m.cardRadius, gap: m.avatarGap, paddingInline: 6 }}>\n                      <span aria-hidden=\"true\" className=\"flex shrink-0 text-[var(--mdt-tertiary)]\"><DocumentGlyph /></span>\n                      <span className=\"flex min-w-0 flex-col\">\n                        <span className=\"truncate text-[var(--mdt-label)]\" style={{ fontSize: 13, lineHeight: \"16px\", letterSpacing: -0.4 }}>{item.name}</span>\n                        {item.meta ? <span className=\"truncate text-[var(--mdt-secondary)]\" style={{ fontSize: 11, lineHeight: \"14px\", letterSpacing: -0.2 }}>{item.meta}</span> : null}\n                      </span>\n                    </button>\n                  </li>\n                ))}\n              </ul>\n            </section>\n          ) : null}\n\n          <section data-slot=\"details-section\" aria-labelledby={optionsId} style={{ paddingTop: m.sectionTop }} className=\"border-t border-[var(--mdt-separator)]\">\n            <h2 id={optionsId} className=\"sr-only\">Conversation options</h2>\n            {onHideAlertsChange ? (\n              <div className=\"flex items-center justify-between\" style={{ height: m.optionHeight, paddingInline: 6 }}>\n                <span className=\"text-[var(--mdt-label)]\" style={{ fontSize: 13, lineHeight: \"16px\", letterSpacing: -0.4 }}>Hide Alerts</span>\n                <MacCheckbox checked={hideAlerts} onChange={onHideAlertsChange} label=\"Hide Alerts\" />\n              </div>\n            ) : null}\n            {onLeave ? (\n              <button type=\"button\" data-slot=\"details-leave\" onClick={onLeave}\n                className={cn(\"flex w-full items-center bg-transparent p-0 text-left text-[var(--mdt-red)] hover:bg-[var(--mdt-fill)]\", focusRing)}\n                style={{ height: m.optionHeight, borderRadius: m.cardRadius, paddingInline: 6, fontSize: 13, letterSpacing: -0.4 }}>\n                Leave this Conversation\n              </button>\n            ) : null}\n            {onBlock ? (\n              <button type=\"button\" data-slot=\"details-block\" onClick={onBlock}\n                className={cn(\"flex w-full items-center bg-transparent p-0 text-left text-[var(--mdt-red)] hover:bg-[var(--mdt-fill)]\", focusRing)}\n                style={{ height: m.optionHeight, borderRadius: m.cardRadius, paddingInline: 6, fontSize: 13, letterSpacing: -0.4 }}>\n                Block Contact\n              </button>\n            ) : null}\n            {onDelete ? (\n              <button type=\"button\" data-slot=\"details-delete\" onClick={onDelete}\n                className={cn(\"flex w-full items-center bg-transparent p-0 text-left text-[var(--mdt-red)] hover:bg-[var(--mdt-fill)]\", focusRing)}\n                style={{ height: m.optionHeight, borderRadius: m.cardRadius, paddingInline: 6, fontSize: 13, letterSpacing: -0.4 }}>\n                Delete Conversation…\n              </button>\n            ) : null}\n          </section>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/macos-details.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-effects",
      "title": "Message effects",
      "description": "Send with effect on a bubble: the slam, loud and gentle animations and the Invisible Ink reveal, replayable on demand.",
      "files": [
        {
          "path": "registry/imessage/message-effects.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Send-with-effect: the four bubble effects and the invisible-ink reveal.\n *\n * The three scale timelines ARE measured: `references/ios/motion/effects.md` reads the bubble's height\n * over its settled height off 60 fps recordings of the iOS 26 simulator, and every offset below is one\n * of those times divided by the duration. What is still not measured, and is called out where it\n * appears: the rotation on Slam, the sideways jitter on Loud, the handful of steps between two\n * measured frames, and the screen effects. The screen that chooses an effect is measured too, in\n * `ios-effects-picker.tsx`, and so is the look of the Invisible Ink preview it shows.\n */\nexport type BubbleEffectKind = \"slam\" | \"loud\" | \"gentle\" | \"invisible-ink\";\n\nexport const bubbleEffects: Array<{ kind: BubbleEffectKind; title: string; description: string }> = [\n  { kind: \"slam\", title: \"Slam\", description: \"Drops the bubble onto the conversation and shakes it.\" },\n  { kind: \"loud\", title: \"Loud\", description: \"Blows the bubble up, then lets it settle.\" },\n  { kind: \"gentle\", title: \"Gentle\", description: \"Arrives small and grows to size.\" },\n  { kind: \"invisible-ink\", title: \"Invisible Ink\", description: \"Hides the message until it is revealed.\" },\n];\n\n/**\n * Total duration of each bubble effect, in ms, measured from 60 fps recordings of the iOS 26\n * simulator sending with each effect (`references/ios/motion/effects.md`). Invisible Ink has no\n * motion of its own: the message simply arrives covered.\n *\n * Slam and Gentle are timed from the frame the effects screen leaves, so their first 50 and 200 ms\n * are the send flight and the bubble is not on screen yet. Loud is timed from the first frame the\n * bubble is visible, 117 ms after the screen leaves, so it starts at full opacity and its 1230 ms sit\n * after that flight rather than containing it.\n */\nexport const bubbleEffectDuration: Record<BubbleEffectKind, number> = { slam: 640, loud: 1230, gentle: 3000, \"invisible-ink\": 0 };\n\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n}\n\n/**\n * Keyframes traced from those recordings. Each `offset` is the measured time in ms divided by the\n * duration above, to four places, which lands every one within 0.05 ms of its frame; the Slam impact\n * is the one exception and carries its exact fraction, for the reason given there. Each scale is the\n * bubble's measured height over its settled height. `easing: \"linear\"` keeps the curve between two\n * keyframes a straight line, so seeking to a measured time renders that measured scale, and every\n * measured frame reads back within 0.002 of the table. A step with no frame behind it is interpolated\n * between its neighbours and is called out where it appears.\n */\nfunction keyframes(kind: BubbleEffectKind): Keyframe[] {\n  switch (kind) {\n    case \"slam\":\n      // Arrives far oversized and shrinking fast, lands squashed at 0.92, rebounds to 1.07, settles.\n      // Frames at 50 (7.7, and clipped by the screen, so that is a lower bound), 233, 267, 300, 333,\n      // 367, 400, 467, 533 and 633 ms. Two steps are not measured: the scale 8 it starts at, which is\n      // off the screen before the bubble is visible at all, and the 2.6 at 288 ms, because the\n      // recording jumps 4.67 -> 0.92 between two frames and that is the slam itself.\n      return [\n        { offset: 0, transform: \"scale(8) rotate(-4deg)\", opacity: 0 },\n        { offset: 0.0521, opacity: 0 },\n        { offset: 0.0781, transform: \"scale(7.7) rotate(-3.4deg)\", opacity: 1 },\n        { offset: 0.3641, transform: \"scale(5.1) rotate(-1.4deg)\" },\n        { offset: 0.4172, transform: \"scale(4.67) rotate(-1deg)\" },\n        { offset: 0.45, transform: \"scale(2.6) rotate(-0.4deg)\" },\n        // 300/640 exactly. The impact is falling 0.14 of scale per ms, a hundred times faster than any\n        // other stretch of any of these curves, so rounding this one offset to four places would land\n        // the 0.92 a thirtieth of a millisecond late and read 0.925 at 300 ms.\n        { offset: 0.46875, transform: \"scale(0.92) rotate(0deg)\" },\n        { offset: 0.5203, transform: \"scale(0.95)\" },\n        { offset: 0.5734, transform: \"scale(1)\" },\n        { offset: 0.625, transform: \"scale(1.02)\" },\n        { offset: 0.7297, transform: \"scale(1.07)\" },\n        { offset: 0.8328, transform: \"scale(1.05)\" },\n        { offset: 0.9891, transform: \"scale(1)\" },\n        { offset: 1, transform: \"scale(1)\" },\n      ];\n    case \"loud\":\n      // Starts a third of size, blows past twice its size, shakes at the top, then falls back. Frames\n      // at 0, 33, 83, 183, 283, 350, 450, 550, 683, 783, 883, 1033 and 1233 ms on a clock that starts\n      // where the bubble becomes visible, so it opens at 0.32 with no fade. The sideways jitter is the\n      // shake the recording shows on top of the growth, and the 1.05 at 1144 ms is interpolated: the\n      // recording has no frame between 1033 and the settle.\n      return [\n        { offset: 0, transform: \"scale(0.32)\" },\n        { offset: 0.0268, transform: \"scale(0.58)\" },\n        { offset: 0.0675, transform: \"scale(1.02)\" },\n        { offset: 0.1488, transform: \"scale(1.75) translateX(-4px)\" },\n        { offset: 0.2301, transform: \"scale(2.12) translateX(4px)\" },\n        { offset: 0.2846, transform: \"scale(2.23) translateX(-3px)\" },\n        { offset: 0.3659, transform: \"scale(2.35) translateX(3px)\" },\n        { offset: 0.4472, transform: \"scale(2.27) translateX(-2px)\" },\n        { offset: 0.5553, transform: \"scale(2.32) translateX(2px)\" },\n        { offset: 0.6366, transform: \"scale(2.2) translateX(0)\" },\n        { offset: 0.7179, transform: \"scale(1.87)\" },\n        { offset: 0.8398, transform: \"scale(1.25)\" },\n        { offset: 0.93, transform: \"scale(1.05)\" },\n        { offset: 1, transform: \"scale(1)\" },\n      ];\n    case \"gentle\":\n      // Arrives at a third of size, overshoots slightly, then takes seconds to relax into place.\n      // Frames at 200 (the first one the bubble is on screen), 233, 300, 400, 533, the hold through\n      // 1250, then 1983, 2583 and the settle at 3083, which is 83 ms past this duration and lands on\n      // the last keyframe instead. The 1.18 at 1650 ms is interpolated: nothing is measured between\n      // the end of the hold and 1983.\n      return [\n        { offset: 0, transform: \"scale(0.38)\", opacity: 0 },\n        { offset: 0.0611, opacity: 0 },\n        { offset: 0.0667, transform: \"scale(0.38)\", opacity: 1 },\n        { offset: 0.0777, transform: \"scale(0.65)\" },\n        { offset: 0.1, transform: \"scale(0.85)\" },\n        { offset: 0.1333, transform: \"scale(1.13)\" },\n        { offset: 0.1777, transform: \"scale(1.25)\" },\n        { offset: 0.4167, transform: \"scale(1.25)\" },\n        { offset: 0.55, transform: \"scale(1.18)\" },\n        { offset: 0.661, transform: \"scale(1.1)\" },\n        { offset: 0.861, transform: \"scale(1.05)\" },\n        { offset: 1, transform: \"scale(1)\" },\n      ];\n    default:\n      return [{ opacity: 1 }, { opacity: 1 }];\n  }\n}\n\nexport type BubbleEffectHandle = { seek(ms: number): void; play(): void; cancel(): void; readonly duration: number };\n\n/**\n * Runs a bubble effect on an element. Pass `progress` (0..1) to scrub instead of play, which is what\n * the harness timeline does. Under reduced motion the element simply appears.\n */\nexport function playBubbleEffect(element: HTMLElement, kind: BubbleEffectKind, options: { progress?: number; origin?: string } = {}): BubbleEffectHandle {\n  const duration = bubbleEffectDuration[kind];\n  if (kind === \"invisible-ink\" || duration === 0 || prefersReducedMotion()) {\n    element.style.transform = \"\";\n    return { seek() {}, play() {}, cancel() {}, duration: 0 };\n  }\n  // The recordings keep the bubble pinned at its trailing bottom corner while everything else about it\n  // grows (in the Loud capture the bottom edge stays within 785-793 for the whole 1230 ms), so that\n  // corner is the origin: bottom right for an outgoing bubble, bottom left for an incoming one. The\n  // direction sits on an ancestor when the effect runs on a bubble frame and on a descendant when a\n  // `BubbleEffect` wraps a whole bubble, so look both ways; looking up alone left the wrapper scaling\n  // out of its bottom left corner and dragging an outgoing bubble off the screen.\n  const direction = element.closest(\"[data-direction]\")?.getAttribute(\"data-direction\")\n    ?? element.querySelector(\"[data-direction]\")?.getAttribute(\"data-direction\");\n  element.style.transformOrigin = options.origin ?? (direction === \"outgoing\" ? \"100% 100%\" : \"0% 100%\");\n  const animation = element.animate(keyframes(kind), { duration, easing: \"linear\", fill: \"both\" });\n  if (options.progress === undefined) animation.play();\n  else { animation.pause(); animation.currentTime = Math.max(0, Math.min(1, options.progress)) * duration; }\n  return {\n    seek(ms) { animation.pause(); animation.currentTime = Math.max(0, Math.min(duration, ms)); },\n    play() { animation.play(); },\n    cancel() { animation.cancel(); element.style.transform = \"\"; },\n    duration,\n  };\n}\n\n/** Declarative wrapper: runs `kind` on its child whenever `kind` or `replay` changes. */\nexport function BubbleEffect({ kind, progress, replay, children, className, style, ...props }: ComponentProps<\"div\"> & { kind?: BubbleEffectKind; progress?: number; replay?: number }) {\n  const host = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    const element = host.current;\n    if (!element || !kind) return;\n    const handle = playBubbleEffect(element, kind, { progress });\n    return () => handle.cancel();\n  }, [kind, progress, replay]);\n  return <div ref={host} data-slot=\"bubble-effect\" data-effect={kind} className={cn(\"will-change-transform\", className)} style={style} {...props}>{children}</div>;\n}\n\n/**\n * Invisible Ink: the message is covered by drifting particles until it is revealed. Native reveals on\n * touch or a pointer pass and hides again after a moment; `revealed` lets an app control that instead.\n */\n/**\n * iOS 26 offers eight, in this order. The Screen tab's page dots confirm the count: eight dots, and\n * swiping past Celebration goes nowhere (`references/ios/motion/effects.md`). Shooting Star, which\n * earlier releases had, is gone.\n */\nexport type ScreenEffectKind = \"echo\" | \"spotlight\" | \"balloons\" | \"confetti\" | \"love\" | \"lasers\" | \"fireworks\" | \"celebration\";\n\nexport const screenEffects: Array<{ kind: ScreenEffectKind; title: string }> = [\n  { kind: \"echo\", title: \"Echo\" },\n  { kind: \"spotlight\", title: \"Spotlight\" },\n  { kind: \"balloons\", title: \"Balloons\" },\n  { kind: \"confetti\", title: \"Confetti\" },\n  { kind: \"love\", title: \"Love\" },\n  { kind: \"lasers\", title: \"Lasers\" },\n  { kind: \"fireworks\", title: \"Fireworks\" },\n  { kind: \"celebration\", title: \"Celebration\" },\n];\n\n/**\n * One deterministic 1-bit speckle tile, 64 px square with 15% of its pixels opaque. It is an image\n * rather than an SVG filter so every engine rasterises the same specks and a screenshot stays\n * comparable, and it is a single layer because two composited mask layers do not agree across\n * engines: `mask-composite: intersect` reads as \"intersect with nothing\" for the first layer in\n * WebKit and masks the text away entirely.\n */\nconst inkTile = \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAQAAAAAYLlVAAADkklEQVR42q1ZSXLDMAwj9P8/o4ckNheQkpP60Jk6WrkAIA3a9dBg8aHZ9a7+mucxjOUw071ZcsHPg2tpS4vQ/UXZBmaGa0w9uHuz0kJ1K78Fr8MhLdTd0tvBX47xAPHsDFawdK9spXgnilsyjGN8v9w/dINuAzLEA6sRwyZId0SxFqKTVtjUm+pjwHhmiANX/99HoYwtNxbUQQj5RmfClB/W/vaet+RPSKfFsL01h5oc5eatYhi6JMoRYMNN9aFQAtM7ji8XcFi6Mx1Hm5yvyHWdi+IubFMOjU3YOqWOo5lhJczD1rva+Dx0TcGN6gIKcKUEXNtwg5pb5q+A2taQDxKM9HdjQlOPJAhh+Hb6Cwd293oSpizxMc7+AFEmTx5FwD88q4QbChGroOKxPaZVkh6gZK+Z/SxQFo/gOISojwFu1M83gLVdYTmIxJD/lPZRyoFFqNU3KQjrzZlimC3h4MACo+WWCEArJ4Vkdh+o3LAkHTJ4ecczHOhjoNrnIVauI3zrko9BJ57yR7DwElpXp6Xy+BOFULkQtws6k/KQgviAqmQaxjt2Qbm/p6e1qKisQ8QlbmGlDOEovOiyBIH3ICSZRYevIMo73sc2EKd4wE6SWRNeKPFNkTGQuMgNM1yzwVle9vn9RcBpLkCbgGxBlxLnzlAj4qKrjGYGZKgY8VA7dTzLlyqeFkWqjOEiY8L/E7XMSkadet+TzUxEUEXZ6yqriVlugfVMniGBGzpROmkYto0a6dWDOrntEUE0HCr/W+kjRKyg7DbV3oOwwHMNyAfFeUtGXRcDm9xmqnT8W5RmzaYwsa/Q8DdcDFyQPNNgGwaM46Zm5FwZearhVudAeBuj21Sl1UAxj1JtauuyKUylmxQO8KeyNB9PF7vXiCVugAG7ufNpsc64vcKB76xwlh9MB+TdI2JpWe9Qn1vMUNIui94gyWJLRnW3McQ1H9QGDqLvDsmeaPqA49jI1aL2nfbg9/F9HhVDJqyt8KCAm51GyFshOC2MXaEeYkvIVr4fYC46pQYSbtm7gI8lqBKzAxuqfuhZ7ws/s2FQxdFHbDsbFrRx7RjxcGt+cACDN3OxmSXXDMJTU/tqjq/jvvZtagwH7b4qoYOoXzQhT/vB05g11LJo2YBtwFJ+7utYJvQHnkQ+2lIVbgTbj2DuIL5R6RGd8pvXr/3zplWLJEfYfHDDIUJw6CFtJNnZt799I/sBZf0Bkk4FKDOrOUwAAAAASUVORK5CYII=\";\n\nconst inkMask: CSSProperties = {\n  maskImage: `url(${inkTile})`,\n  maskSize: \"64px 64px\",\n  maskRepeat: \"repeat\",\n  WebkitMaskImage: `url(${inkTile})`,\n  WebkitMaskSize: \"64px 64px\",\n  WebkitMaskRepeat: \"repeat\",\n  animation: \"im-ink-drift 5200ms linear infinite\",\n};\n\nconst inkKeyframes = `\n@keyframes im-ink-drift {\n  from { mask-position: 0 0; -webkit-mask-position: 0 0; }\n  to { mask-position: 64px -64px; -webkit-mask-position: 64px -64px; }\n}\n/* The drift runs on whatever the mask lands on, which is the text slot rather than the wrapper that\n   carries [data-ink-masked], so the reduced-motion rule has to name all three or it stops nothing. */\n@media (prefers-reduced-motion: reduce) {\n  [data-slot=\"invisible-ink\"] :is([data-ink-masked], [data-slot=\"text\"], [data-slot=\"ink-target\"]) { animation: none !important; }\n}\n`;\n\n/**\n * Invisible Ink: the message's text dissolves into drifting specks until it is revealed, and the\n * bubble itself stays where it is. Native reveals on touch or a pointer pass and hides again after a\n * moment; `revealed` lets an app control that instead.\n *\n * The mask lands on the bubble's `[data-slot=\"text\"]` (or anything marked `data-slot=\"ink-target\"`),\n * so the fill, the tail and any reactions keep drawing normally. Content with neither gets masked\n * whole, which is the right fallback for a plain string.\n */\nexport function InvisibleInk({ revealed, onRevealChange, children, className, style, ...props }: Omit<ComponentProps<\"div\">, \"onChange\"> & { revealed?: boolean; onRevealChange?: (revealed: boolean) => void }) {\n  const [internal, setInternal] = useState(false);\n  const open = revealed ?? internal;\n  const id = useId();\n  const scope = `ink-${id.replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const set = (next: boolean) => { if (revealed === undefined) setInternal(next); onRevealChange?.(next); };\n\n  const filterId = `${scope}-spread`;\n  // Camel case to CSS, keeping the `-webkit-` prefix intact: Safari still needs the prefixed\n  // mask properties, and its `mask-composite` takes the legacy keywords.\n  const cssName = (key: string) =>\n    key.startsWith(\"Webkit\")\n      ? `-webkit-${key.slice(6).replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`).replace(/^-/, \"\")}`\n      : key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);\n  const declarations = Object.entries(inkMask).map(([key, value]) => `${cssName(key)}:${value}`).join(\";\");\n\n  return (\n    <div data-slot=\"invisible-ink\" data-revealed={open} className={cn(\"relative\", scope, className)} style={style} {...props}>\n      {!open && (\n        <svg aria-hidden=\"true\" width=\"0\" height=\"0\" className=\"absolute\" focusable=\"false\">\n          <filter id={filterId} x=\"-25%\" y=\"-40%\" width=\"150%\" height=\"180%\" colorInterpolationFilters=\"sRGB\">\n            {/* Spread each glyph into a cloud, then push the cloud's alpha back up so the specks the\n                mask leaves behind are as bright as the text was. Native emits one particle per glyph\n                pixel and lets them drift, which is what this approximates. */}\n            <feGaussianBlur stdDeviation=\"1.3\" />\n            <feComponentTransfer><feFuncA type=\"linear\" slope=\"3.6\" intercept=\"-0.42\" /></feComponentTransfer>\n          </filter>\n        </svg>\n      )}\n      {!open && (\n        <style>{`${inkKeyframes}\n.${scope} :is([data-slot=\"text\"], [data-slot=\"ink-target\"]) { ${declarations};filter:url(#${filterId}) }\n.${scope}:not(:has([data-slot=\"text\"], [data-slot=\"ink-target\"])) > div:first-of-type { ${declarations};filter:url(#${filterId}) }`}</style>\n      )}\n      <div data-ink-masked={open ? undefined : \"\"}>{children}</div>\n      {!open && (\n        <button type=\"button\" aria-label=\"Reveal message sent with Invisible Ink\" aria-describedby={`${id}-hint`}\n          onClick={() => set(true)} onPointerEnter={() => set(true)} onFocus={() => set(true)}\n          className=\"absolute inset-0 cursor-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\">\n          <span id={`${id}-hint`} className=\"sr-only\">Hidden with Invisible Ink. Activate to reveal.</span>\n        </button>\n      )}\n    </div>\n  );\n}\n\n/** Convenience: run a bubble effect on the message row with `data-message-id={id}` inside `frame`. */\nexport function useBubbleEffectOnMessage(frame: RefObject<HTMLElement | null>, effect: { id: string; kind: BubbleEffectKind; progress?: number } | null | undefined) {\n  useEffect(() => {\n    if (!effect) return;\n    const element = frame.current?.querySelector<HTMLElement>(`[data-message-id=\"${effect.id}\"] [data-slot=\"bubble-frame\"]`);\n    if (!element) return;\n    const handle = playBubbleEffect(element, effect.kind, { progress: effect.progress });\n    return () => handle.cancel();\n  }, [frame, effect]);\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-effects.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "screen-effects",
      "title": "Screen effects",
      "description": "The full-screen send effects, drawn deterministically on a canvas so they can be scrubbed and screenshotted.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/message-effects.json"
      ],
      "files": [
        {
          "path": "registry/imessage/screen-effects.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useRef, type CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ScreenEffectKind } from \"@/components/imessage/message-effects\";\n\n/**\n * Full-screen send effects, drawn on a canvas that covers the device frame.\n *\n * NOT MEASURED. No native recording of a screen effect exists in `references/`, so the particle\n * counts, colours and timings follow the well-known look rather than a capture. Re-derive them from a\n * recording before claiming fidelity.\n *\n * Every effect is a pure function of `t` (0..1) and the anchor rect: no wall clock, no `Math.random`,\n * no state carried between frames. The same `t` therefore paints the same pixels on every run and in\n * every engine, which is what makes the harness scrubbable and its screenshots stable.\n */\nexport const screenEffectDuration: Record<ScreenEffectKind, number> = {\n  echo: 2400, spotlight: 2600, balloons: 4200, confetti: 4200, love: 2600,\n  lasers: 3000, fireworks: 3600, celebration: 3600,\n};\n\n/**\n * The one frame each effect holds under `prefers-reduced-motion`: its fullest, calmest pose. The\n * canvas is painted once and never repainted, so nothing on screen moves.\n */\nconst stillFrame: Record<ScreenEffectKind, number> = {\n  echo: 0.5, spotlight: 0.5, balloons: 0.62, confetti: 0.5, love: 0.55,\n  lasers: 0.5, fireworks: 0.45, celebration: 0.5,\n};\n\nexport type Anchor = { x: number; y: number; width: number; height: number };\n\ntype Frame = { context: CanvasRenderingContext2D; width: number; height: number; t: number; anchor: Anchor };\n\nconst TAU = Math.PI * 2;\n\nfunction clamp01(value: number): number {\n  return value < 0 ? 0 : value > 1 ? 1 : value;\n}\n\n/** 0 → 1 → 0, rising over the first `rise` of the effect and falling over the last `fall`. */\nfunction envelope(t: number, rise: number, fall: number): number {\n  return Math.min(1, clamp01(t) / rise) * Math.min(1, (1 - clamp01(t)) / fall);\n}\n\nfunction easeOut(value: number): number {\n  const v = 1 - clamp01(value);\n  return 1 - v * v * v;\n}\n\n/**\n * Deterministic value in [0,1) from an index and a channel. Integer operations only, so every engine\n * returns the same bits. The previous `fract(sin(seed) * 43758.5453)` did not: `Math.sin` is only\n * implementation-approximated, and measured across 600 seeds Chromium and WebKit disagreed on 15 of\n * them (worst delta 7.3e-12), which is enough to flip a `> 0.5` branch. `channel` keeps each stream\n * independent; the old code reused one seed with different offsets, so streams overlapped (fireworks'\n * angle jitter equalled its own spark reach on the first burst, which visibly clumped it).\n */\nfunction hash(index: number, channel: number): number {\n  let h = Math.imul(index ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(channel + 0x27d4eb2f, 0xc2b2ae35);\n  h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);\n  h = Math.imul(h ^ (h >>> 12), 0x297a2d39);\n  return ((h ^ (h >>> 15)) >>> 0) / 4294967296;\n}\n\n/** `roundRect` is recent in WebKit; `arcTo` draws the same path everywhere. */\nfunction roundedRect(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {\n  const radius = Math.min(r, w / 2, h / 2);\n  context.beginPath();\n  context.moveTo(x + radius, y);\n  context.arcTo(x + w, y, x + w, y + h, radius);\n  context.arcTo(x + w, y + h, x, y + h, radius);\n  context.arcTo(x, y + h, x, y, radius);\n  context.arcTo(x, y, x + w, y, radius);\n  context.closePath();\n}\n\n/** Effects that happen against a night sky: the screen dims under them, as it does natively. */\nconst dims: Partial<Record<ScreenEffectKind, number>> = { fireworks: 0.82, lasers: 0.78 };\n\nfunction drawBackdrop(kind: ScreenEffectKind, { context, width, height, t }: Frame) {\n  const peak = dims[kind];\n  if (!peak) return;\n  // Holds until the last particle is gone, then lifts, rather than fading out from 83% while\n  // fireworks are still bursting.\n  const fade = envelope(t, 1 / 6, 0.08);\n  context.fillStyle = `rgba(4,6,18,${(peak * fade).toFixed(3)})`;\n  context.fillRect(0, 0, width, height);\n}\n\nconst confettiColors = [\"#ff3b30\", \"#ff9500\", \"#ffcc00\", \"#34c759\", \"#0088ff\", \"#af52de\", \"#ff2d55\"];\nconst balloonColors = [\"#ff3b30\", \"#ff9500\", \"#ffcc00\", \"#34c759\", \"#0088ff\", \"#af52de\"];\nconst laserColors: ReadonlyArray<readonly [number, number, number]> = [\n  [255, 45, 85], [255, 149, 0], [255, 214, 10], [52, 199, 89], [0, 199, 255], [0, 136, 255], [175, 82, 222],\n];\n\n/** `hsl()` rounds to bytes differently between engines; laser beams mix their own rgba instead. */\nfunction rgba(color: readonly [number, number, number], alpha: number): string {\n  return `rgba(${color[0]},${color[1]},${color[2]},${alpha.toFixed(3)})`;\n}\n\nfunction drawConfetti({ context, width, height, t }: Frame) {\n  const count = 160;\n  for (let i = 0; i < count; i++) {\n    const local = t - hash(i, 1) * 0.35;\n    if (local <= 0) continue;\n    const x = hash(i, 2) * width + Math.sin((local * 3 + i) * 1.6) * 22;\n    const y = -30 + local * (height + 160) * (0.75 + hash(i, 3) * 0.55);\n    if (y > height + 30) continue;\n    const size = 5 + hash(i, 4) * 5;\n    context.save();\n    context.translate(x, y);\n    context.rotate((local * 6 + hash(i, 5) * 6) * (hash(i, 6) < 0.5 ? -1 : 1));\n    context.globalAlpha = clamp01((1 - local) * 3);\n    context.fillStyle = confettiColors[i % confettiColors.length];\n    context.fillRect(-size / 2, -size / 4, size, size / 2);\n    context.restore();\n  }\n}\n\nfunction drawBalloons({ context, width, height, t }: Frame) {\n  const count = 22;\n  for (let i = 0; i < count; i++) {\n    const local = t - hash(i, 11) * 0.3;\n    if (local <= 0) continue;\n    const x = 20 + hash(i, 12) * (width - 40) + Math.sin((local * 2 + i) * 1.2) * 14;\n    const y = height + 80 - local * (height + 220) * (0.8 + hash(i, 13) * 0.5);\n    if (y < -120) continue;\n    const w = 26 + hash(i, 14) * 16;\n    const h = w * 1.22;\n    context.save();\n    context.globalAlpha = Math.min(1, local * 6) * Math.min(1, (1 - local) * 4);\n    context.fillStyle = balloonColors[i % balloonColors.length];\n    context.beginPath();\n    context.ellipse(x, y, w / 2, h / 2, 0, 0, TAU);\n    context.fill();\n    context.globalAlpha *= 0.22;\n    context.fillStyle = \"#ffffff\";\n    context.beginPath();\n    context.ellipse(x - w * 0.17, y - h * 0.2, w * 0.16, h * 0.2, -0.4, 0, TAU);\n    context.fill();\n    context.restore();\n    context.save();\n    context.globalAlpha = Math.min(1, local * 6) * Math.min(1, (1 - local) * 4);\n    context.beginPath();\n    context.moveTo(x, y + h / 2);\n    context.quadraticCurveTo(x + 5, y + h / 2 + 22, x, y + h / 2 + 44);\n    context.strokeStyle = \"rgba(0,0,0,0.25)\";\n    context.lineWidth = 1;\n    context.stroke();\n    context.restore();\n  }\n}\n\nfunction heartPath(context: CanvasRenderingContext2D, x: number, y: number, size: number) {\n  const s = size / 32;\n  context.beginPath();\n  context.moveTo(x, y + 10 * s);\n  context.bezierCurveTo(x - 18 * s, y - 6 * s, x - 8 * s, y - 22 * s, x, y - 10 * s);\n  context.bezierCurveTo(x + 8 * s, y - 22 * s, x + 18 * s, y - 6 * s, x, y + 10 * s);\n  context.closePath();\n}\n\nfunction drawLove({ context, width, height, t, anchor }: Frame) {\n  const originX = anchor.x + anchor.width / 2;\n  const originY = anchor.y + anchor.height / 2;\n  const size = 26 + easeOut(t / 0.42) * 210;\n  // The heart grows out of the bubble and lifts, but it settles at a point that keeps it framed: the\n  // old rise carried it off the top of the screen well before the effect ended.\n  const lift = easeOut((t - 0.28) / 0.46);\n  const centerX = originX + (width / 2 - originX) * lift;\n  const centerY = originY + (Math.max(size * 0.55, height * 0.34) - originY) * lift;\n  const out = clamp01((t - 0.78) / 0.22);\n  // Continuous: the old beat switched off at t = 0.5 mid-swing and popped.\n  const beat = 1 + Math.sin(t * 26) * 0.035 * (1 - clamp01(t / 0.7));\n  const scale = beat * (1 + out * 0.18);\n  context.save();\n  context.globalAlpha = 1 - out;\n  context.translate(centerX, centerY);\n  context.scale(scale, scale);\n  heartPath(context, 0, 0, size);\n  context.fillStyle = \"#ff2d55\";\n  context.fill();\n  context.restore();\n}\n\nfunction drawFireworks({ context, width, height, t }: Frame) {\n  const bursts = 8;\n  for (let b = 0; b < bursts; b++) {\n    // The last burst now closes at t = 0.999, so the sky is busy until the dim lifts and empty after.\n    const local = (t - b * 0.0885) / 0.38;\n    if (local <= 0 || local >= 1) continue;\n    // Stratified by the golden ratio, then jittered: six raw hashes clumped in the upper left.\n    const cx = width * (0.15 + (((b * 0.618034 + 0.18) % 1) * 0.7 + (hash(b, 21) - 0.5) * 0.05));\n    const cy = height * (0.12 + (((b * 0.381966 + 0.4) % 1) * 0.44 + (hash(b, 22) - 0.5) * 0.05));\n    const color = confettiColors[b % confettiColors.length];\n    if (local < 0.2) {\n      // The shell, before the sparks separate. White read as a gray smudge over the dim, so it burns\n      // in the burst's own colour.\n      const flash = 1 - local / 0.2;\n      const glow = context.createRadialGradient(cx, cy, 0, cx, cy, 26 * (0.4 + local * 3));\n      glow.addColorStop(0, `rgba(255,255,255,${(0.7 * flash).toFixed(3)})`);\n      glow.addColorStop(0.45, `${color}${Math.round(0.55 * flash * 255).toString(16).padStart(2, \"0\")}`);\n      glow.addColorStop(1, `${color}00`);\n      context.globalAlpha = 1;\n      context.fillStyle = glow;\n      context.fillRect(cx - 90, cy - 90, 180, 180);\n    }\n    const sparks = 40;\n    for (let i = 0; i < sparks; i++) {\n      const seed = b * 128 + i;\n      const angle = (i / sparks) * TAU + hash(seed, 23) * 0.14;\n      const reach = (70 + hash(seed, 24) * 92) * easeOut(local);\n      const x = cx + Math.cos(angle) * reach;\n      const y = cy + Math.sin(angle) * reach + local * local * 48;\n      context.globalAlpha = Math.pow(1 - local, 0.7);\n      context.fillStyle = color;\n      context.beginPath();\n      context.arc(x, y, 2.7, 0, TAU);\n      context.fill();\n    }\n  }\n  context.globalAlpha = 1;\n}\n\nfunction drawCelebration({ context, width, height, t }: Frame) {\n  const streams = 3;\n  for (let s = 0; s < streams; s++) {\n    const originX = width * (0.22 + s * 0.28);\n    for (let i = 0; i < 64; i++) {\n      const seed = s * 128 + i;\n      const local = t - (s * 0.07 + hash(seed, 31) * 0.34);\n      if (local <= 0) continue;\n      // Split into horizontal and vertical speed: the old single speed with a shallow gravity term\n      // peaked around 200 px, so every stream stayed inside the bottom quarter of the screen.\n      const vx = (hash(seed, 32) - 0.5) * 420;\n      const vy = 1750 + hash(seed, 33) * 1250;\n      const x = originX + vx * local;\n      const y = height + 12 - vy * local + 2600 * local * local;\n      if (y > height + 40 || y < -40 || x < -40 || x > width + 40) continue;\n      context.globalAlpha = clamp01((1 - local) * 2.4);\n      context.fillStyle = i % 3 === 0 ? \"#ffd60a\" : confettiColors[i % confettiColors.length];\n      context.beginPath();\n      context.arc(x, y, 2.1 + hash(seed, 34) * 1.4, 0, TAU);\n      context.fill();\n    }\n  }\n  context.globalAlpha = 1;\n}\n\nfunction drawLasers({ context, width, height, t, anchor }: Frame) {\n  // Anchored: the beams leave the message and sweep out to the edges of the screen.\n  const cx = anchor.x + anchor.width / 2;\n  const cy = anchor.y + anchor.height / 2;\n  const reach = Math.hypot(width, height);\n  const beams = 12;\n  const fade = envelope(t, 0.12, 0.14);\n  if (fade <= 0) return;\n  context.save();\n  context.globalCompositeOperation = \"lighter\";\n  // Beams leave the edge of the bubble rather than its centre, so the message stays readable.\n  const inset = Math.max(anchor.width, anchor.height) * 0.42;\n  for (let i = 0; i < beams; i++) {\n    const side = i % 2 === 0 ? 1 : -1;\n    const lane = Math.floor(i / 2) / (beams / 2 - 1) - 0.5;\n    const angle = lane * 0.92 + Math.sin((t * 1.7 + i / beams) * TAU) * 0.34 * side;\n    const dirX = Math.cos(angle) * side;\n    const dirY = Math.sin(angle) * side;\n    const startX = cx + dirX * inset;\n    const startY = cy + dirY * inset;\n    const endX = cx + dirX * reach;\n    const endY = cy + dirY * reach;\n    const color = laserColors[i % laserColors.length];\n    const gradient = context.createLinearGradient(startX, startY, endX, endY);\n    gradient.addColorStop(0, rgba(color, 0.9 * fade));\n    gradient.addColorStop(0.55, rgba(color, 0.5 * fade));\n    gradient.addColorStop(1, rgba(color, 0));\n    context.strokeStyle = gradient;\n    context.lineCap = \"round\";\n    context.lineWidth = 9;\n    context.globalAlpha = 0.3;\n    context.beginPath();\n    context.moveTo(startX, startY);\n    context.lineTo(endX, endY);\n    context.stroke();\n    context.lineWidth = 2.2;\n    context.globalAlpha = 1;\n    context.stroke();\n  }\n  const halo = context.createRadialGradient(cx, cy, inset * 0.5, cx, cy, inset + 70);\n  halo.addColorStop(0, `rgba(255,255,255,${(0.16 * fade).toFixed(3)})`);\n  halo.addColorStop(1, \"rgba(255,255,255,0)\");\n  context.globalAlpha = 1;\n  context.fillStyle = halo;\n  context.fillRect(cx - inset - 80, cy - inset - 80, (inset + 80) * 2, (inset + 80) * 2);\n  context.restore();\n}\n\nfunction drawSpotlight({ context, width, height, t, anchor }: Frame) {\n  const fade = envelope(t, 0.25, 0.25);\n  if (fade <= 0) return;\n  const cx = anchor.x + anchor.width / 2;\n  const cy = anchor.y + anchor.height / 2;\n  // The beam opens wide and closes onto the bubble.\n  const radius = (Math.max(anchor.width, anchor.height) * 0.75 + 90) * (1.45 - 0.45 * easeOut(t / 0.45));\n  context.save();\n  context.fillStyle = `rgba(0,0,0,${(0.72 * fade).toFixed(3)})`;\n  context.fillRect(0, 0, width, height);\n  // Punched at full alpha. Sharing the dim's alpha here left the middle of the beam 20% black, so the\n  // message under the spotlight came out darker than the rest of the conversation.\n  context.globalCompositeOperation = \"destination-out\";\n  const beam = context.createRadialGradient(cx, cy, radius * 0.34, cx, cy, radius);\n  beam.addColorStop(0, \"rgba(0,0,0,1)\");\n  beam.addColorStop(1, \"rgba(0,0,0,0)\");\n  context.fillStyle = beam;\n  context.beginPath();\n  context.arc(cx, cy, radius, 0, TAU);\n  context.fill();\n  context.restore();\n}\n\nfunction drawEcho({ context, width, height, t, anchor }: Frame) {\n  const copies = 18;\n  const spread = Math.hypot(width, height) * 0.46;\n  const w = anchor.width;\n  const h = anchor.height;\n  const radius = Math.min(19, h / 2);\n  for (let i = 0; i < copies; i++) {\n    const local = (t - i * 0.021) / 0.62;\n    if (local <= 0 || local >= 1) continue;\n    // Golden angle: random angles clumped the copies on one side of the bubble.\n    const angle = i * 2.39996 + hash(i, 51) * 0.6;\n    const drift = easeOut(local) * spread * (0.5 + hash(i, 52) * 0.7);\n    // Each copy keeps its own size and grows a little, so they still read as bubbles; scaling to 2.4x\n    // turned the late ones into bars wider than the screen.\n    const scale = (0.6 + hash(i, 54) * 0.5) * (0.85 + local * 0.75);\n    context.save();\n    context.globalAlpha = Math.min(1, local * 6) * (1 - local) * 0.6;\n    context.translate(anchor.x + w / 2 + Math.cos(angle) * drift, anchor.y + h / 2 + Math.sin(angle) * drift);\n    context.rotate((hash(i, 53) - 0.5) * 0.3);\n    context.scale(scale, scale);\n    context.fillStyle = \"#0088ff\";\n    roundedRect(context, -w / 2, -h / 2, w, h, radius);\n    context.fill();\n    context.restore();\n  }\n}\n\nconst painters: Record<ScreenEffectKind, (frame: Frame) => void> = {\n  confetti: drawConfetti, balloons: drawBalloons, love: drawLove, fireworks: drawFireworks,\n  celebration: drawCelebration, lasers: drawLasers,\n  spotlight: drawSpotlight, echo: drawEcho,\n};\n\nexport type ScreenEffectProps = {\n  kind: ScreenEffectKind;\n  /** Scrub position 0..1. Omit to play once. */\n  progress?: number;\n  /** Bump to replay. */\n  replay?: number;\n  /**\n   * The bubble the effect radiates from, in frame coordinates. Echo, spotlight, lasers and love use\n   * it. Without one the bubble named by `anchorMessageId` is measured, then the last outgoing bubble\n   * in the frame, then a point above the composer.\n   */\n  anchor?: Anchor;\n  /** The message the effect was sent with, when the host would rather name it than measure it. */\n  anchorMessageId?: string;\n  onDone?: () => void;\n  className?: string;\n  style?: CSSProperties;\n};\n\n/** Absolutely positioned canvas; put it inside the device frame with `inset-0`. */\nexport function ScreenEffect({ kind, progress, replay, anchor, anchorMessageId, onDone, className, style }: ScreenEffectProps) {\n  const canvas = useRef<HTMLCanvasElement>(null);\n  const done = useRef(onDone);\n  const anchorRef = useRef(anchor);\n  // The clock lives outside the effect so a re-render (a new `anchor` object, a parent state change)\n  // re-syncs the canvas without restarting the animation from zero.\n  const startedAt = useRef(0);\n  const finished = useRef(false);\n\n  // Declared first, so both are current before the painting effect below re-runs.\n  useEffect(() => { done.current = onDone; anchorRef.current = anchor; });\n  useEffect(() => { startedAt.current = 0; finished.current = false; }, [kind, replay]);\n\n  const anchorKey = anchor ? `${anchor.x},${anchor.y},${anchor.width},${anchor.height}` : \"\";\n  useEffect(() => {\n    const element = canvas.current;\n    const parent = element?.parentElement;\n    if (!element || !parent) return;\n    const context = element.getContext(\"2d\");\n    if (!context) return;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n    let raf = 0;\n    let width = 0, height = 0;\n    let fallback: Anchor = { x: 0, y: 0, width: 120, height: 40 };\n\n    /** Where the effect radiates from when the host passes no anchor. */\n    const measureAnchor = (frameRect: DOMRect): Anchor => {\n      const named = anchorMessageId\n        ? parent.querySelector<HTMLElement>(`[data-message-id=\"${CSS.escape(anchorMessageId)}\"] [data-slot=\"bubble\"]`)\n        : null;\n      const outgoing = parent.querySelectorAll<HTMLElement>('[data-slot=\"message-bubble\"][data-direction=\"outgoing\"] [data-slot=\"bubble\"]');\n      const rect = (named ?? outgoing[outgoing.length - 1])?.getBoundingClientRect();\n      if (rect && rect.width > 0 && rect.height > 0) {\n        return { x: rect.left - frameRect.left, y: rect.top - frameRect.top, width: rect.width, height: rect.height };\n      }\n      return { x: width * 0.5 - 60, y: height * 0.62, width: 120, height: 40 };\n    };\n\n    const size = () => {\n      // The frame, not the canvas: a canvas is a replaced element, so `inset-0` alone leaves it at its\n      // intrinsic size. The explicit CSS size below is what stretches it over the frame.\n      const rect = parent.getBoundingClientRect();\n      const dpr = Math.min(3, window.devicePixelRatio || 1);\n      width = Math.max(1, Math.round(rect.width));\n      height = Math.max(1, Math.round(rect.height));\n      const backingWidth = Math.round(width * dpr);\n      const backingHeight = Math.round(height * dpr);\n      // Assigning `width` resets the bitmap even when the value is unchanged, which would blank the\n      // canvas for a frame every time this effect re-runs.\n      if (element.width !== backingWidth || element.height !== backingHeight) {\n        element.width = backingWidth;\n        element.height = backingHeight;\n      }\n      element.style.width = `${width}px`;\n      element.style.height = `${height}px`;\n      context.setTransform(dpr, 0, 0, dpr, 0, 0);\n      fallback = measureAnchor(rect);\n    };\n\n    const paint = (value: number) => {\n      context.clearRect(0, 0, width, height);\n      const frame: Frame = { context, width, height, t: clamp01(value), anchor: anchorRef.current ?? fallback };\n      context.save();\n      drawBackdrop(kind, frame);\n      painters[kind](frame);\n      context.restore();\n    };\n\n    size();\n    // Scrubbed by the harness: one frame, no clock, no callback.\n    if (progress !== undefined) { paint(progress); return; }\n\n    const duration = screenEffectDuration[kind];\n    if (reduced) {\n      // Static and calm: one representative frame, held. A single timer still retires the effect so\n      // the host clears the overlay, and it is cleared on unmount.\n      paint(stillFrame[kind]);\n      if (finished.current) return;\n      if (!startedAt.current) startedAt.current = performance.now();\n      const remaining = Math.max(0, duration - (performance.now() - startedAt.current));\n      const timer = window.setTimeout(() => { finished.current = true; done.current?.(); }, remaining);\n      return () => window.clearTimeout(timer);\n    }\n    if (finished.current) { paint(1); return; }\n\n    const step = (now: number) => {\n      if (!startedAt.current) startedAt.current = now;\n      const t = (now - startedAt.current) / duration;\n      paint(t);\n      if (t < 1) { raf = requestAnimationFrame(step); return; }\n      finished.current = true;\n      done.current?.();\n    };\n    // Repaint the frame the clock is already on, so a re-run never shows an empty canvas.\n    if (startedAt.current) paint((performance.now() - startedAt.current) / duration);\n    raf = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(raf);\n  }, [kind, progress, replay, anchorKey, anchorMessageId]);\n\n  return <canvas ref={canvas} aria-hidden=\"true\" data-slot=\"screen-effect\" data-effect={kind} className={cn(\"pointer-events-none absolute inset-0\", className)} style={style} />;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/screen-effects.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-effects-picker",
      "title": "iOS effects picker",
      "description": "The iOS \"Send with effect\" screen: the Bubble/Screen tabs, the effect rail, the preview bubble and its caption.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/message-effects.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/ios-effects-picker.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { fontStack } from \"@/components/imessage/tokens\";\nimport { InvisibleInk, bubbleEffects, screenEffects, type BubbleEffectKind, type ScreenEffectKind } from \"@/components/imessage/message-effects\";\n\n/**\n * \"Send with effect\", the screen iOS shows when you press and hold the send button.\n *\n * Measured from `references/ios/captures/effects-picker-light.png` (nothing chosen),\n * `effects-slam-light.png` (a bubble effect chosen) and `effects-screen-light.png` (the Screen tab),\n * all iOS 26 on a 402x874 pt screen captured at 3x.\n *\n * The layout is not a list. The conversation freezes and blurs behind; the message being sent is\n * previewed as a real bubble; the four bubble effects live in a white vertical rail on the trailing\n * edge, each row a right-aligned label plus a dot. Choosing one turns that row's dot into the send\n * button, moves the preview up alongside it, replaces that row's label with \"SEND WITH <NAME>\" above\n * the bubble, and dims the labels of the rows that were not chosen. The Screen tab drops the rail\n * entirely and stacks the send button above the close button.\n */\nexport const effectsPickerMetrics = {\n  /** Everything below is in points on the 402x874 screen the numbers were measured from. */\n  frame: { width: 402, height: 874 },\n  title: { capTop: 96, baseline: 112, fontSize: 22, centerX: 201 },\n  segment: { left: 81, top: 132, width: 240, height: 32, radius: 16, inset: 2, fontSize: 13, labelCapTop: 143, selectedWeight: 600, weight: 400 },\n  rail: { left: 321.75, top: 572, width: 53.75, height: 214.67, radius: 23, rowPitch: 57, firstRowCenter: 593.67 },\n  dot: { size: 9, centerX: 348.5 },\n  /** The chosen row's dot becomes a send button the same size as the close button. */\n  send: { width: 38, height: 28, centerX: 348.5, screenCenterY: 765.83 },\n  close: { width: 38, height: 28, centerX: 348.5, centerY: 825.83 },\n  /** Row labels and the caption share one type style and one right ink edge. */\n  label: { rightInk: 298.33, bearing: 1.2, fontSize: 11.33, weight: 400, capHeight: 8.33, tracking: 0, dimOpacity: 0.44 },\n  /**\n   * The caption sits above the preview. The Screen tab really does set it a third of a point smaller\n   * and 3.33 pt further left: three different Screen captions measure a right ink edge of 294.67 and\n   * an 11 pt body, against 298.33 and 11.33 for every Bubble one.\n   */\n  caption: { rightInk: 298.33, gapAboveBubble: 8.67, screenRightInk: 294.67, screenFontSize: 11, screenGapAboveBubble: 7 },\n  /**\n   * The preview bubble. It rests low on the screen until an effect is chosen, then rises to the\n   * chosen row and slides 14.33 pt toward the rail. Both right edges are measured, not derived.\n   */\n  preview: {\n    restingTop: 792,\n    restingRight: 301.33,\n    chosenRight: 315.67,\n    chosenTopFromRowCenter: -17.33,\n    screenTop: 731,\n    screenRight: 301.33,\n    maxWidth: 280.5,\n    /**\n     * The preview never drops below this. Fitted from the Invisible Ink capture, where the fourth\n     * row would otherwise put the bubble's bottom at 834.34 and it sits at 827.33 instead.\n     */\n    maxBottom: 827.33,\n  },\n  /**\n   * The preview bubble is a flat #0088ff, not the conversation's screen-space gradient: the same\n   * bubble measures (0, 136, 255) at y 578 and again at y 871 across the four captures.\n   */\n  previewFill: \"#0088ff\",\n  /**\n   * The Screen tab pages through its effects and shows a dot per effect under the preview. There are\n   * eight dots in iOS 26, and the eighth is the last: swiping past Celebration goes nowhere.\n   */\n  pageDots: { size: 7.67, pitch: 17.62, firstCenterX: 139.17, centerY: 807 },\n  /** Time the whole screen takes to appear and to leave. */\n  timing: { enter: 260, exit: 200, move: 320 },\n} as const;\n\n/**\n * Where the cap top falls inside a `line-height: 1` box, as a fraction of the font size.\n * The captures record positions as cap tops, so every text offset here goes through this.\n * The ratio is calibrated by rendering, not taken from font tables: browsers snap a baseline to a\n * device pixel, so the best single ratio lands every label within one device pixel at 3x.\n */\nfunction capTopInset(fontSize: number) {\n  return fontSize * 0.1145;\n}\n\n/**\n * Apple's continuous corner, the same profile the composer and nav bar use. A circular corner is up\n * to 0.9 pt too tight along the flank of a rail this wide. Browsers without `corner-shape` fall back\n * to a plain round corner.\n */\nconst continuous = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/**\n * Light values are measured off the three captures; the dark set follows the same iOS system colours\n * the rest of the kit uses. They are custom properties so a `.dark` ancestor flips the whole screen\n * without the caller passing anything.\n */\nconst vars =\n  \"[--ios-fx-backdrop:rgba(233,236,242,0.86)] [--ios-fx-title:rgba(60,60,67,0.66)] [--ios-fx-track:rgba(120,120,128,0.16)] [--ios-fx-pill:#ffffff] [--ios-fx-tab:#000000] [--ios-fx-rail:#ffffff] [--ios-fx-dot:#999999] [--ios-fx-label:rgba(60,60,67,0.75)] [--ios-fx-close:#808080] [--ios-fx-send:#0088ff] [--ios-fx-glyph:#ffffff] \" +\n  \"dark:[--ios-fx-backdrop:rgba(10,10,12,0.9)] dark:[--ios-fx-title:rgba(255,255,255,0.8)] dark:[--ios-fx-track:rgba(120,120,128,0.24)] dark:[--ios-fx-pill:#636366] dark:[--ios-fx-tab:#ffffff] dark:[--ios-fx-rail:rgba(255,255,255,0.14)] dark:[--ios-fx-dot:rgba(255,255,255,0.24)] dark:[--ios-fx-label:rgba(255,255,255,0.8)]\";\n\nconst c = {\n  backdrop: \"var(--ios-fx-backdrop)\",\n  title: \"var(--ios-fx-title)\",\n  segmentTrack: \"var(--ios-fx-track)\",\n  segmentPill: \"var(--ios-fx-pill)\",\n  segmentLabel: \"var(--ios-fx-tab)\",\n  rail: \"var(--ios-fx-rail)\",\n  dot: \"var(--ios-fx-dot)\",\n  label: \"var(--ios-fx-label)\",\n  close: \"var(--ios-fx-close)\",\n  send: \"var(--ios-fx-send)\",\n  glyph: \"var(--ios-fx-glyph)\",\n};\n\nexport type EffectsPickerSelection = { bubble: BubbleEffectKind } | { screen: ScreenEffectKind } | null;\n\nexport type IosEffectsPickerProps = Omit<ComponentProps<\"div\">, \"onSelect\" | \"children\"> & {\n  tab?: \"bubble\" | \"screen\";\n  onTabChange?: (tab: \"bubble\" | \"screen\") => void;\n  selection?: EffectsPickerSelection;\n  onSelect?: (selection: EffectsPickerSelection) => void;\n  /** Fires when the chosen effect's send control is activated. */\n  onSend?: (selection: EffectsPickerSelection) => void;\n  onClose?: () => void;\n  /** False plays the exit timeline and then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  /** Seek the entrance to this fraction (0..1) instead of playing it, which is what the harness does. */\n  progress?: number;\n  /** The message being sent, rendered as the preview bubble. */\n  preview: ReactNode;\n};\n\nfunction UpArrow() {\n  // 13 x 15.67 pt of ink, 2 pt stroke, measured off the send button in effects-slam-light.png.\n  return (\n    <svg aria-hidden=\"true\" width=\"13\" height=\"15.67\" viewBox=\"0 0 13 15.67\" fill=\"none\">\n      <path d=\"M6.5 14.67V1M1.2 5.7 6.5 1l5.3 4.7\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction Cross() {\n  // 13.67 pt square of ink, 2 pt stroke.\n  return (\n    <svg aria-hidden=\"true\" width=\"13.67\" height=\"13.67\" viewBox=\"0 0 13.67 13.67\" fill=\"none\">\n      <path d=\"M1 1l11.67 11.67M12.67 1 1 12.67\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** A label or caption placed by its cap top and its right ink edge, the way the captures measure it. */\nfunction InkLabel({ rightInk, capTop, fontSize, dim, style, children, ...props }: ComponentProps<\"span\"> & { rightInk: number; capTop: number; fontSize: number; dim?: boolean }) {\n  const m = effectsPickerMetrics;\n  return (\n    <span\n      {...props}\n      style={{\n        position: \"absolute\",\n        right: m.frame.width - rightInk - m.label.bearing,\n        top: capTop - capTopInset(fontSize),\n        fontSize,\n        lineHeight: 1,\n        fontWeight: m.label.weight,\n        letterSpacing: m.label.tracking,\n        whiteSpace: \"nowrap\",\n        textAlign: \"right\",\n        opacity: dim ? m.label.dimOpacity : 1,\n        transition: \"opacity 220ms ease\",\n        ...style,\n      }}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function IosEffectsPicker({\n  tab: tabProp,\n  onTabChange,\n  selection = null,\n  onSelect,\n  onSend,\n  onClose,\n  open = true,\n  onExited,\n  progress,\n  preview,\n  className,\n  style,\n  ...props\n}: IosEffectsPickerProps) {\n  const [internalTab, setInternalTab] = useState<\"bubble\" | \"screen\">(\"bubble\");\n  const tab = tabProp ?? internalTab;\n  const setTab = (next: \"bubble\" | \"screen\") => {\n    if (tabProp === undefined) setInternalTab(next);\n    onTabChange?.(next);\n  };\n  const m = effectsPickerMetrics;\n  const root = useRef<HTMLDivElement>(null);\n  const rail = useRef<HTMLDivElement>(null);\n  const previewBox = useRef<HTMLDivElement>(null);\n  const exited = useRef(false);\n\n  useEffect(() => {\n    exited.current = false;\n  }, []);\n\n  /**\n   * The screen is modal, so it takes focus when it opens (a scrubbed entrance does not: the harness\n   * seeks frames and must not move the caret) and gives Escape back wherever focus happens to be.\n   * The root carries `outline-none`: it is a focus holder, not a control, and must paint nothing.\n   */\n  useEffect(() => {\n    if (!open || progress !== undefined) return;\n    root.current?.focus({ preventScroll: true });\n  }, [open, progress]);\n  useEffect(() => {\n    if (!open || !onClose) return;\n    const onKey = (event: globalThis.KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onClose(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onClose]);\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node) return;\n    if (open) {\n      const entrance = node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: m.timing.enter, easing: \"cubic-bezier(0.32, 0.72, 0, 1)\", fill: \"both\" });\n      if (progress === undefined) return;\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      entrance.pause();\n      entrance.currentTime = Math.max(0, Math.min(1, progress)) * m.timing.enter;\n      return () => entrance.cancel();\n    }\n    const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: m.timing.exit, easing: \"ease-out\", fill: \"forwards\" });\n    const finish = () => {\n      if (exited.current) return;\n      exited.current = true;\n      onExited?.();\n    };\n    animation.addEventListener(\"finish\", finish);\n    return () => animation.removeEventListener(\"finish\", finish);\n  }, [open, progress, m.timing.enter, m.timing.exit, onExited]);\n\n  const chosenBubble = selection && \"bubble\" in selection ? selection.bubble : null;\n  const chosenScreen = selection && \"screen\" in selection ? selection.screen : null;\n  const chosenIndex = chosenBubble ? bubbleEffects.findIndex(effect => effect.kind === chosenBubble) : -1;\n  const chosenTitle = chosenBubble\n    ? bubbleEffects.find(effect => effect.kind === chosenBubble)?.title\n    : chosenScreen\n      ? screenEffects.find(effect => effect.kind === chosenScreen)?.title\n      : null;\n  const caption = chosenTitle ? `SEND WITH ${chosenTitle.toUpperCase()}` : null;\n\n  const onScreenTab = tab === \"screen\";\n  const [previewHeight, setPreviewHeight] = useState(0);\n  const rowTop = chosenIndex >= 0 ? m.rail.firstRowCenter + chosenIndex * m.rail.rowPitch + m.preview.chosenTopFromRowCenter : 0;\n  const previewTop = chosenIndex >= 0\n    ? (previewHeight > 0 ? Math.min(rowTop, m.preview.maxBottom - previewHeight) : rowTop)\n    : onScreenTab\n      ? m.preview.screenTop\n      : m.preview.restingTop;\n  const previewRight = chosenIndex >= 0 ? m.preview.chosenRight : onScreenTab ? m.preview.screenRight : m.preview.restingRight;\n  const captionGap = onScreenTab ? m.caption.screenGapAboveBubble : m.caption.gapAboveBubble;\n  const captionFontSize = onScreenTab ? m.caption.screenFontSize : m.label.fontSize;\n  const captionRightInk = onScreenTab ? m.caption.screenRightInk : m.caption.rightInk;\n\n  // The bubble fill is a screen-space gradient, so the preview has to say where it sits on the\n  // screen. The picker fills the frame, which makes that its own offset plus its measured height.\n  useEffect(() => {\n    const node = previewBox.current;\n    if (!node) return;\n    const sync = () => {\n      setPreviewHeight(node.offsetHeight);\n      node.style.setProperty(\"--bubble-bottom\", `${previewTop + node.offsetHeight}px`);\n    };\n    sync();\n    const observer = new ResizeObserver(sync);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [previewTop, preview]);\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"ios-effects-picker\"\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label=\"Send with effect\"\n      tabIndex={-1}\n      className={cn(\"absolute inset-0 select-none outline-none\", vars, className)}\n      style={{\n        fontFamily: fontStack,\n        background: c.backdrop,\n        backdropFilter: \"blur(45px) saturate(1.6)\",\n        WebkitBackdropFilter: \"blur(45px) saturate(1.6)\",\n        ...style,\n      }}\n      {...props}\n    >\n      <div\n        data-slot=\"effects-title\"\n        style={{\n          position: \"absolute\",\n          left: 0,\n          right: 0,\n          top: m.title.capTop - capTopInset(m.title.fontSize),\n          textAlign: \"center\",\n          fontSize: m.title.fontSize,\n          lineHeight: 1,\n          color: c.title,\n        }}\n      >\n        Send with effect\n      </div>\n\n      <div\n        data-slot=\"effects-tabs\"\n        role=\"tablist\"\n        aria-label=\"Effect kind\"\n        onKeyDown={event => {\n          if (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\" && event.key !== \"Home\" && event.key !== \"End\") return;\n          event.preventDefault();\n          const next = event.key === \"Home\" ? \"bubble\" : event.key === \"End\" ? \"screen\" : event.key === \"ArrowRight\" ? \"screen\" : \"bubble\";\n          if (next !== tab) setTab(next);\n          event.currentTarget.querySelector<HTMLButtonElement>(`[data-tab=\"${next}\"]`)?.focus();\n        }}\n        style={{\n          position: \"absolute\",\n          left: m.segment.left,\n          top: m.segment.top,\n          width: m.segment.width,\n          height: m.segment.height,\n          borderRadius: m.segment.radius,\n          background: c.segmentTrack,\n        }}\n      >\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"effects-tab-pill\"\n          style={{\n            position: \"absolute\",\n            top: m.segment.inset,\n            left: onScreenTab ? m.segment.width / 2 + m.segment.inset : m.segment.inset,\n            width: m.segment.width / 2 - m.segment.inset * 2,\n            height: m.segment.height - m.segment.inset * 2,\n            borderRadius: (m.segment.height - m.segment.inset * 2) / 2,\n            background: c.segmentPill,\n            boxShadow: \"0 3px 8px rgba(0, 0, 0, 0.12), 0 3px 1px rgba(0, 0, 0, 0.04)\",\n            transition: \"left 220ms cubic-bezier(0.32, 0.72, 0, 1)\",\n          }}\n        />\n        {([\"bubble\", \"screen\"] as const).map((value, index) => (\n          <button\n            key={value}\n            type=\"button\"\n            role=\"tab\"\n            aria-selected={tab === value}\n            data-tab={value}\n            tabIndex={tab === value ? 0 : -1}\n            onClick={() => setTab(value)}\n            style={{\n              position: \"absolute\",\n              top: 0,\n              left: index * (m.segment.width / 2),\n              width: m.segment.width / 2,\n              height: m.segment.height,\n              background: \"transparent\",\n              border: 0,\n              padding: 0,\n              fontFamily: \"inherit\",\n              fontSize: m.segment.fontSize,\n              fontWeight: tab === value ? m.segment.selectedWeight : m.segment.weight,\n              color: c.segmentLabel,\n              cursor: \"default\",\n            }}\n          >\n            <span\n              style={{\n                position: \"absolute\",\n                left: 0,\n                right: 0,\n                top: m.segment.labelCapTop - m.segment.top - capTopInset(m.segment.fontSize),\n                lineHeight: 1,\n              }}\n            >\n              {value === \"bubble\" ? \"Bubble\" : \"Screen\"}\n            </span>\n          </button>\n        ))}\n      </div>\n\n      {caption && (\n        <InkLabel\n          data-slot=\"effects-caption\"\n          rightInk={captionRightInk}\n          capTop={previewTop - captionGap - m.label.capHeight}\n          fontSize={captionFontSize}\n          style={{ color: c.label, transition: \"top 320ms cubic-bezier(0.32, 0.72, 0, 1)\" }}\n        >\n          {caption}\n        </InkLabel>\n      )}\n\n      {/* Labels sit outside the rail so the rail's own bounds never clip them. */}\n      {!onScreenTab &&\n        bubbleEffects.map((effect, index) => {\n          const chosen = chosenBubble === effect.kind;\n          if (chosen) return null;\n          const centerY = m.rail.firstRowCenter + index * m.rail.rowPitch;\n          return (\n            <InkLabel\n              key={effect.kind}\n              aria-hidden=\"true\"\n              data-slot=\"effects-row-label\"\n              rightInk={m.label.rightInk}\n              capTop={centerY - m.label.capHeight / 2}\n              fontSize={m.label.fontSize}\n              dim={chosenIndex >= 0}\n              style={{ color: c.label }}\n            >\n              {effect.title.toUpperCase()}\n            </InkLabel>\n          );\n        })}\n\n      <div\n        ref={previewBox}\n        data-slot=\"effects-preview\"\n        style={{\n          position: \"absolute\",\n          right: m.frame.width - previewRight,\n          top: previewTop,\n          width: m.preview.maxWidth,\n          display: \"flex\",\n          justifyContent: \"flex-end\",\n          transition: `top ${m.timing.move}ms cubic-bezier(0.32, 0.72, 0, 1), right ${m.timing.move}ms cubic-bezier(0.32, 0.72, 0, 1)`,\n          [\"--im-blue-top\" as string]: m.previewFill,\n          [\"--im-blue-bottom\" as string]: m.previewFill,\n        }}\n      >\n        {chosenBubble === \"invisible-ink\" ? <InvisibleInk revealed={false}>{preview}</InvisibleInk> : preview}\n      </div>\n\n      {!onScreenTab && (\n        <div\n          ref={rail}\n          data-slot=\"effects-rail\"\n          role=\"radiogroup\"\n          aria-label=\"Bubble effects\"\n          onKeyDown={event => {\n            const keys = [\"ArrowDown\", \"ArrowRight\", \"ArrowUp\", \"ArrowLeft\", \"Home\", \"End\"];\n            if (!keys.includes(event.key)) return;\n            event.preventDefault();\n            const radios = Array.from(rail.current?.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]') ?? []);\n            if (!radios.length) return;\n            const from = radios.indexOf(document.activeElement as HTMLButtonElement);\n            const current = from < 0 ? Math.max(0, chosenIndex) : from;\n            const next = event.key === \"Home\" ? 0\n              : event.key === \"End\" ? radios.length - 1\n                : event.key === \"ArrowDown\" || event.key === \"ArrowRight\" ? (current + 1) % radios.length\n                  : (current - 1 + radios.length) % radios.length;\n            // A radio group moves and chooses together, which is also what the rail's preview does.\n            radios[next]?.focus();\n            onSelect?.({ bubble: bubbleEffects[next].kind });\n          }}\n          style={{\n            position: \"absolute\",\n            left: m.rail.left,\n            top: m.rail.top,\n            width: m.rail.width,\n            height: m.rail.height,\n            borderRadius: m.rail.radius,\n            ...continuous,\n            background: c.rail,\n          }}\n        >\n          {bubbleEffects.map((effect, index) => {\n            const centerY = m.rail.firstRowCenter - m.rail.top + index * m.rail.rowPitch;\n            const chosen = chosenBubble === effect.kind;\n            return (\n              <button\n                key={effect.kind}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={chosen}\n                aria-label={chosen ? `Send with ${effect.title}` : effect.title}\n                tabIndex={chosen || (chosenIndex < 0 && index === 0) ? 0 : -1}\n                onClick={() => (chosen ? onSend?.({ bubble: effect.kind }) : onSelect?.({ bubble: effect.kind }))}\n                style={{\n                  position: \"absolute\",\n                  left: (m.rail.width - (chosen ? m.send.width : m.dot.size)) / 2,\n                  top: centerY - (chosen ? m.send.height : m.dot.size) / 2,\n                  width: chosen ? m.send.width : m.dot.size,\n                  height: chosen ? m.send.height : m.dot.size,\n                  borderRadius: chosen ? m.send.height / 2 : m.dot.size / 2,\n                  background: chosen ? c.send : c.dot,\n                  color: c.glyph,\n                  border: 0,\n                  padding: 0,\n                  cursor: \"default\",\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  transition: \"width 220ms cubic-bezier(0.32, 0.72, 0, 1), height 220ms cubic-bezier(0.32, 0.72, 0, 1), left 220ms cubic-bezier(0.32, 0.72, 0, 1), top 220ms cubic-bezier(0.32, 0.72, 0, 1), background-color 220ms ease\",\n                }}\n              >\n                {chosen && <UpArrow />}\n              </button>\n            );\n          })}\n        </div>\n      )}\n\n      {onScreenTab && (\n        <div data-slot=\"effects-pages\" aria-hidden=\"true\" style={{ position: \"absolute\", left: 0, top: m.pageDots.centerY - m.pageDots.size / 2, height: m.pageDots.size }}>\n          {screenEffects.map((effect, index) => (\n            <span\n              key={effect.kind}\n              style={{\n                position: \"absolute\",\n                left: m.pageDots.firstCenterX + index * m.pageDots.pitch - m.pageDots.size / 2,\n                width: m.pageDots.size,\n                height: m.pageDots.size,\n                borderRadius: m.pageDots.size / 2,\n                background: c.label,\n                opacity: chosenScreen === effect.kind ? 1 : m.label.dimOpacity,\n                transition: \"opacity 220ms ease\",\n              }}\n            />\n          ))}\n        </div>\n      )}\n\n      {onScreenTab && (\n        <button\n          type=\"button\"\n          data-slot=\"effects-send\"\n          aria-label={chosenTitle ? `Send with ${chosenTitle}` : \"Send\"}\n          onClick={() => onSend?.(selection)}\n          style={{\n            position: \"absolute\",\n            left: m.send.centerX - m.send.width / 2,\n            top: m.send.screenCenterY - m.send.height / 2,\n            width: m.send.width,\n            height: m.send.height,\n            borderRadius: m.send.height / 2,\n            background: c.send,\n            color: c.glyph,\n            border: 0,\n            padding: 0,\n            cursor: \"default\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <UpArrow />\n        </button>\n      )}\n\n      <button\n        type=\"button\"\n        data-slot=\"effects-close\"\n        aria-label=\"Cancel\"\n        onClick={onClose}\n        style={{\n          position: \"absolute\",\n          left: m.close.centerX - m.close.width / 2,\n          top: m.close.centerY - m.close.height / 2,\n          width: m.close.width,\n          height: m.close.height,\n          borderRadius: m.close.height / 2,\n          background: c.close,\n          color: c.glyph,\n          border: 0,\n          padding: 0,\n          cursor: \"default\",\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n        }}\n      >\n        <Cross />\n      </button>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-effects-picker.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-reply",
      "title": "Inline reply",
      "description": "The quoted stub above a reply, the reply-count affordance, and the focused thread view.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/message-bubble.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-reply.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/imessage/bubble-shape\";\nimport { bubbleMetrics, fontStack, type Direction, type Service } from \"@/components/imessage/tokens\";\nimport { MessageBubble } from \"@/components/imessage/message-bubble\";\n\n/**\n * Inline replies: the quoted stub above a reply, the \"N replies\" affordance under a message that has\n * a thread, and the focused thread view.\n *\n * PARTLY MEASURED. No native capture of a reply thread exists in `references/`, so the two numbers\n * that describe the stub itself stay provisional: it is drawn at `stubScale` (0.76) of the quoted\n * bubble and at `stubOpacity` (55%). What the stub is built from is measured, and is derived here\n * rather than guessed:\n *\n * - Type, padding, radius, minimum width, tail and maximum width are the platform's measured bubble\n *   metrics multiplied by `stubScale`, so the stub is a real bubble at 76% rather than a picture of\n *   one. It is laid out at that size (no `transform: scale`), which matters for three measured\n *   behaviours: the stub's own edge lands exactly on the reply bubble's measured edge inset, its\n *   screen-space gradient stays in screen coordinates, and `MessageBubble`'s shrink-to-fit still\n *   measures line boxes in unscaled px (under a transform, `getClientRects` returns scaled px and the\n *   bubble sets a frame `scale` too narrow; `message-actions.tsx` documents the same trap).\n * - The gap under the stub is the measured cluster gap (4.33 iOS / 3 macOS), body bottom to body top,\n *   or the measured between-cluster gap (10.33 / 11.5) when the stub carries a tail, that being the\n *   one measured gap a tail is known to hang into. Tails take no layout space, as measured.\n * - A long quote is clamped to `stubMaxLines` with an ellipsis instead of wrapping without limit. Two\n *   lines is what the measured conversation-list preview uses on both platforms and the stub is the\n *   same kind of quotation, but nothing has been measured on the stub itself.\n * - The stub's tail is off by default. The measured rule (\"only the last bubble of a cluster has a\n *   tail\") would give a lone quoted copy one, but no capture shows the stub, so it stays a prop. When\n *   it is on, the tail is drawn outside the body and nothing on the stub clips it.\n *\n * The reply count reuses the measured secondary label (\"Delivered\": 11pt / 10pt semibold, its\n * tracking, its gap under the bubble, and #8a8a8e / #808080) rather than a size and colour of its own.\n *\n * NOTHING ABOUT THE THREAD SURFACE IS MEASURED. No capture in `references/` shows a reply thread, and\n * none shows the transition into or out of one, so the count's hit target, the thread's chrome and\n * every timing in `replyThreadMotion` are plausible values rather than readings. They are kept in the\n * family of the two motions that were measured: the long-press overlay (dim 150 ms, entrance 600,\n * dismissal 220) and the \"Send with effect\" screen (entrance 260, exit 200). The only positions\n * borrowed from a capture are the iOS back control's measured Ø44 box centred (38, 84) and the\n * measured edge insets and cluster gaps the messages inside the thread already use.\n */\nexport type ReplyQuote = {\n  id: string;\n  text: string;\n  direction: Direction;\n  service?: Service;\n  sender?: string;\n};\n\n/**\n * Provisional stub numbers. `stubScale` and `stubOpacity` are the two values SPEC.md carries for\n * replies; `stubMaxLines` follows the measured 2-line conversation-list preview.\n */\nexport const replyMetrics: Record<Platform, { stubScale: number; stubOpacity: number; stubMaxLines: number }> = {\n  ios: { stubScale: 0.76, stubOpacity: 0.55, stubMaxLines: 2 },\n  macos: { stubScale: 0.78, stubOpacity: 0.55, stubMaxLines: 2 },\n};\n\n/** The stub's geometry: the platform's measured bubble metrics scaled by `stubScale`. */\nexport function replyStubMetrics(platform: Platform) {\n  const m = bubbleMetrics[platform];\n  const scale = replyMetrics[platform].stubScale;\n  return {\n    scale,\n    fontSize: m.fontSize * scale,\n    lineHeight: m.lineHeight * scale,\n    paddingX: m.paddingX * scale,\n    paddingY: m.paddingY * scale,\n    radius: m.radius * scale,\n    minWidth: m.minWidth * scale,\n    letterSpacing: m.letterSpacing * scale,\n    tailScale: m.tailScale * scale,\n    /** How far the tail hangs below the body. It is drawn outside the body and takes no space. */\n    hang: tailBox.hang * m.tailScale * scale,\n    /** Stub body bottom to the reply body's top, without and with a tail. */\n    gap: m.gapInGroup,\n    gapWithTail: m.gapBetweenGroups,\n    /**\n     * A bubble's own rule (fixed px on iOS, a share of the pane on macOS), scaled. The macOS share is\n     * of the pane, and a row is inset from it on both sides, so add the insets back the way\n     * `message-list` does before taking the share.\n     */\n    maxWidth: platform === \"ios\"\n      ? `min(${(m.maxWidth * scale).toFixed(2)}px, 100%)`\n      : `min(calc((100% + ${2 * m.edgeInset}px) * ${(m.maxWidthRatio * scale).toFixed(5)}), 100%)`,\n  };\n}\n\n/** Height of a stub body of `lines` lines. The tail hang and the gap below it are extra. */\nexport function replyStubHeight(platform: Platform, lines = 1): number {\n  const s = replyStubMetrics(platform);\n  return s.lineHeight * lines + s.paddingY * 2;\n}\n\n/**\n * A bubble hugs its longest wrapped line instead of stretching to its maximum width (measured), and\n * CSS shrink-to-fit cannot express that, so the laid-out line boxes are measured and the frame's width\n * set from them. `MessageBubble` does the same for real bubbles; the stub cannot borrow that hook\n * because it lays out at `stubScale`, and the copy is small enough to keep the loop simple: clamped\n * lines are still laid out, so they take part in the measurement, and the widest one can only be the\n * width already available, which leaves the frame where it is.\n */\nfunction useStubTextFit(paddingX: number, deps: unknown[]) {\n  const frame = useRef<HTMLDivElement>(null);\n  useLayoutEffect(() => {\n    const frameEl = frame.current;\n    const textEl = frameEl?.querySelector<HTMLElement>('[data-slot=\"text\"]');\n    if (!frameEl || !textEl) return;\n    const container = frameEl.parentElement;\n    let raf = 0;\n    const measure = () => {\n      frameEl.style.width = \"\";\n      const range = document.createRange();\n      range.selectNodeContents(textEl);\n      const lines: Array<{ top: number; left: number; right: number }> = [];\n      for (const rect of Array.from(range.getClientRects())) {\n        if (rect.width === 0 && rect.height === 0) continue;\n        const line = lines.find(l => Math.abs(l.top - rect.top) < 1);\n        if (line) { line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right); }\n        else lines.push({ top: rect.top, left: rect.left, right: rect.right });\n      }\n      if (lines.length < 2) return;\n      const longest = Math.max(...lines.map(l => l.right - l.left));\n      const hug = Math.ceil((longest + 2 * paddingX) * 100) / 100 + 0.05;\n      if (hug < frameEl.getBoundingClientRect().width - 0.1) frameEl.style.width = `${hug}px`;\n    };\n    measure();\n    const observer = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); });\n    if (container) observer.observe(container);\n    document.fonts?.ready.then(() => measure()).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [paddingX, ...deps]);\n  return frame;\n}\n\n/** The stub above a reply: the quoted message, smaller and dimmer, on the reply's own edge. */\nexport function ReplyStub({ quote, tail = false, maxLines, platform: platformProp, className, style, ...props }: Omit<ComponentProps<\"div\">, \"children\"> & {\n  quote: ReplyQuote; tail?: boolean; maxLines?: number; platform?: Platform;\n}) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const s = replyStubMetrics(platform);\n  const lines = maxLines ?? replyMetrics[platform].stubMaxLines;\n  const outgoing = quote.direction === \"outgoing\";\n  const side = outgoing ? \"right\" : \"left\";\n  const service = quote.service ?? \"imessage\";\n  const key = outgoing ? (service === \"sms\" ? \"green\" : \"blue\") : \"gray\";\n  const frame = useStubTextFit(s.paddingX, [quote.text, platform, lines, tail]);\n\n  // The same screen-space fill a bubble uses: one gradient the height of the screen, anchored to this\n  // body's bottom edge. `use-screen-space.ts` keeps `--bubble-bottom` current while the list scrolls.\n  const bottomVar = \"var(--bubble-bottom, calc(var(--im-screen-h) * 0.55))\";\n  const fill: CSSProperties = {\n    backgroundImage: \"linear-gradient(var(--im-fill-top), var(--im-fill-bottom))\",\n    backgroundSize: \"100% var(--im-screen-h)\",\n    backgroundRepeat: \"no-repeat\",\n    backgroundColor: \"var(--im-fill-bottom)\",\n  };\n\n  return (\n    // The row is a flex column aligned to the reply's own side; `align-items: inherit` hands that\n    // alignment to the copy. Shrink-to-fit alone would not: a quote long enough to reach the stub's\n    // maximum width makes this box fill the row, and the copy inside it would sit on the far edge.\n    <div data-slot=\"reply-stub\" data-direction={quote.direction} data-platform={platform}\n      className={cn(\"pointer-events-none flex w-full flex-col select-none\", className)}\n      style={{ fontFamily: fontStack, alignItems: \"inherit\", opacity: replyMetrics[platform].stubOpacity, marginBottom: tail ? s.gapWithTail : s.gap, ...style }} {...props}>\n      <span className=\"sr-only\">{`Replying to ${quote.sender ?? (outgoing ? \"your message\" : \"their message\")}: ${quote.text}`}</span>\n      {/* The copy itself is decorative: the sentence above already reads the quote out. */}\n      <div ref={frame} aria-hidden=\"true\" data-slot=\"message-bubble\" data-stub=\"true\" data-direction={quote.direction} data-service={service} data-platform={platform}\n        className=\"relative\"\n        style={{ maxWidth: s.maxWidth, \"--im-fill-top\": `var(--im-${key}-top)`, \"--im-fill-bottom\": `var(--im-${key}-bottom)` } as CSSProperties}>\n        <div data-slot=\"bubble\" className=\"relative\" style={{\n          fontSize: s.fontSize, lineHeight: `${s.lineHeight}px`, letterSpacing: s.letterSpacing,\n          padding: `${s.paddingY}px ${s.paddingX}px`, minWidth: s.minWidth, textAlign: \"start\",\n          color: outgoing ? \"var(--im-outgoing-text)\" : \"var(--im-incoming-text)\",\n        }}>\n          {/* The fill sits behind the text, so clipping the tail corner never clips glyphs. */}\n          <div data-slot=\"fill\" className=\"pointer-events-none absolute inset-0\" style={{\n            borderRadius: s.radius, clipPath: tail ? bodyClipPath(side, s.tailScale, tailSeamOverlap[platform]) : `inset(0 round ${s.radius}px)`,\n            ...fill, backgroundPosition: `0 calc(100% + (var(--im-screen-h) - ${bottomVar}))`,\n          }} />\n          {tail && <div data-slot=\"tail\" className=\"pointer-events-none absolute\" style={{\n            [side]: 0, bottom: -s.hang, width: tailBox.width * s.tailScale, height: tailBox.height * s.tailScale + s.hang,\n            clipPath: `path(\"${tailPath(side, s.tailScale)}\")`,\n            ...fill, backgroundPosition: `0 calc(100% + (var(--im-screen-h) - ${bottomVar} - ${s.hang}px))`,\n          }} />}\n          {/* `fit-content` centres a quote too short to fill the minimum width and leaves a wrapped one\n              flush; the clamp lives here so the body never puts the tail inside an `overflow: hidden`. */}\n          <span data-slot=\"text\" className=\"relative\" style={{\n            display: \"-webkit-box\", WebkitBoxOrient: \"vertical\", WebkitLineClamp: lines, overflow: \"hidden\",\n            width: \"fit-content\", marginInline: \"auto\", whiteSpace: \"pre-wrap\", overflowWrap: \"anywhere\",\n          }}>{quote.text}</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/** A reply: the quoted stub, then the reply bubble itself, both on the reply's own edge. */\nexport function ReplyMessage({ quote, direction = \"outgoing\", service, tail = false, status, stubTail = false, stubMaxLines, children, platform: platformProp, className, style, ...props }: Omit<ComponentProps<\"div\">, \"children\"> & {\n  quote: ReplyQuote; direction?: Direction; service?: Service; tail?: boolean; status?: ReactNode;\n  stubTail?: boolean; stubMaxLines?: number; children: ReactNode; platform?: Platform;\n}) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  // macOS caps a bubble at a share of the *pane*, and `MessageBubble` resolves that percentage against\n  // its own box. Left to shrink-wrap inside this row the box is only as wide as the text, so the cap\n  // collapses onto it and the bubble is squeezed to a few characters. Fill the row and hand the bubble\n  // the same maximum `message-list` does, insets added back.\n  const maxWidth = platform === \"ios\" ? undefined : `calc((100% + ${2 * m.edgeInset}px) * ${m.maxWidthRatio})`;\n  return (\n    <div data-slot=\"reply-message\" data-direction={direction} className={cn(\"flex w-full min-w-0 flex-col\", direction === \"outgoing\" ? \"items-end\" : \"items-start\", className)} style={style} {...props}>\n      <ReplyStub quote={quote} tail={stubTail} maxLines={stubMaxLines} platform={platform} />\n      <MessageBubble direction={direction} service={service} tail={tail} status={status} platform={platform}\n        maxWidth={maxWidth} style={{ width: \"100%\" }}>{children}</MessageBubble>\n    </div>\n  );\n}\n\n/**\n * How far the reply count's hit target reaches past its ink. UNMEASURED. The label is the measured\n * secondary label, 13 pt of line box on iOS and 11 on macOS, which is far under a comfortable target,\n * so the button hangs an invisible box off the ink instead of taking padding: padding would move the\n * label off the measured `statusGap`. It reaches further down than up because up is that gap and then\n * the bubble, which keeps its own long-press and right-click gestures.\n */\nexport const replyCountHit: Record<Platform, { x: number; top: number; bottom: number }> = {\n  ios: { x: 10, top: 4, bottom: 13 },\n  macos: { x: 8, top: 3, bottom: 8 },\n};\n\n/**\n * \"1 reply\" under a message that started a thread, set in the measured secondary label.\n *\n * It is a real control: a button (so Enter and Space open the thread), an accessible name that says\n * how many replies it opens and that it opens a dialog, hover and focus feedback, and a hit target\n * larger than the ink. `expanded` reflects whether the thread it opens is on screen.\n *\n * The feedback is a web affordance, not a reading: no capture shows this label being pointed at, and\n * SPEC.md records that macOS Messages paints no hover state on its rows or bubbles at all. It costs no\n * fidelity because it only exists while the pointer or the focus ring is on the control: at rest the\n * button paints exactly the measured label, verified pixel for pixel against the `reply` baseline.\n */\nexport function ReplyCount({ count, onOpen, expanded, platform: platformProp, className, style, ...props }: Omit<ComponentProps<\"button\">, \"children\" | \"onClick\"> & { count: number; onOpen?: () => void; expanded?: boolean; platform?: Platform }) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const hit = replyCountHit[platform];\n  return (\n    // `aria-haspopup` only when something is wired to it: an unwired count opens nothing and should\n    // not say that it does.\n    <button type=\"button\" data-slot=\"reply-count\" data-count={count} data-platform={platform} onClick={onOpen}\n      aria-haspopup={onOpen ? \"dialog\" : undefined} aria-expanded={expanded} aria-label={count === 1 ? \"Show 1 reply\" : `Show ${count} replies`}\n      className={cn(\"relative underline-offset-2 transition-opacity duration-150 hover:underline active:opacity-60 focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff] motion-reduce:transition-none\", className)}\n      style={{\n        fontFamily: fontStack, fontSize: m.statusFontSize, lineHeight: `${m.statusLineHeight}px`, fontWeight: 600,\n        letterSpacing: m.statusLetterSpacing, marginTop: m.statusGap, color: \"var(--im-secondary)\", ...style,\n      }} {...props}>\n      {/* Inside the button, so a press on it is a press on the control; absolute, so it adds no ink. */}\n      <span aria-hidden=\"true\" data-slot=\"hit-area\" className=\"absolute\" style={{ left: -hit.x, right: -hit.x, top: -hit.top, bottom: -hit.bottom }} />\n      {count === 1 ? \"1 reply\" : `${count} replies`}\n    </button>\n  );\n}\n\n/**\n * The thread's motion. UNMEASURED, see the note at the top of this file: plausible durations in the\n * family of the measured ones, not readings off a frame.\n */\nexport const replyThreadMotion = {\n  /** The whole surface arriving, and leaving. The measured effects screen uses 260 and 200. */\n  enter: 260,\n  exit: 200,\n  /** The dim and the backdrop's blur ramp together, over the measured long-press dim. */\n  dim: 150,\n  /** iOS: the panel grows into place from this scale, this far below it. macOS slides its pane in. */\n  panelScale: 0.94,\n  panelRise: 10,\n  /** The thread's root message grows a little further than the panel around it. */\n  rootScale: 0.92,\n  /** The iOS sheet curve the rest of the kit uses, and a plain accelerate for the way out. */\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n  exitEase: \"cubic-bezier(0.4, 0, 1, 1)\",\n} as const;\n\n/**\n * The thread's chrome, per platform. UNMEASURED apart from the iOS close control, which sits in the\n * nav bar's measured Ø44 box centred (38, 84), and the insets and gaps inside the thread, which are\n * the platform's measured bubble metrics.\n *\n * iOS presents the thread full screen over the blurred conversation, so `paneWidth` is 0 and the\n * panel fills the frame. macOS presents it as a pane on the trailing edge with the conversation still\n * legible beside it, so it takes a width, paints its own background, and does not blur what is behind.\n */\nexport const replyThreadMetrics: Record<Platform, {\n  topInset: number; headerHeight: number; titleSize: number; closeSize: number; closeInset: number; blur: number; paneWidth: number;\n}> = {\n  ios: { topInset: 62, headerHeight: 44, titleSize: 17, closeSize: 44, closeInset: 16, blur: 9, paneWidth: 0 },\n  macos: { topInset: 0, headerHeight: 38, titleSize: 13, closeSize: 22, closeInset: 12, blur: 0, paneWidth: 420 },\n};\n\n/** The close control's glyph: the nav bar's back chevron on iOS, a cross on the macOS pane. */\nfunction ThreadCloseGlyph({ platform }: { platform: Platform }) {\n  if (platform === \"ios\") {\n    // The measured back chevron, in its measured 44 pt box (`ios-details.tsx` draws the same path).\n    return (\n      <svg aria-hidden=\"true\" width=\"44\" height=\"44\" viewBox=\"0 0 44 44\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M24.8 13.87 16.2 22.17 24.8 30.47\" />\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden=\"true\" width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\">\n      <path d=\"M1 1 10 10M10 1 1 10\" />\n    </svg>\n  );\n}\n\n/**\n * The focused thread: the rest of the conversation dims and blurs behind it and only the thread stays\n * lit. Render inside the device frame with `inset-0`.\n *\n * Pass the conversation as `backdrop` and it is blurred with a real `filter`, the way `ios-details`\n * does it. `backdrop-filter` samples whatever the host happens to have painted behind the overlay and\n * Chromium picks its own radius on a composited layer, so the same markup screenshots differently in\n * different hosts; with a `backdrop` the blur is ours and the frame is reproducible. Without one it\n * falls back to `backdrop-filter`, which is fine live and unreliable in a capture.\n *\n * The dim is the measured long-press value, shared with `message-actions` through `--im-dim`.\n *\n * Motion: the dim and the blur come up over `dim` ms while the panel grows into place (iOS) or slides\n * in from the trailing edge (macOS) over `enter`, the root message growing a little further than the\n * panel around it. `open={false}` reverses that over `exit` and calls `onExited` at the end, so the\n * parent can keep the thread mounted until the dismissal has actually been seen:\n *\n *     const openId = thread?.rootId ?? null;\n *     const [seen, setSeen] = useState(openId);\n *     const [closing, setClosing] = useState<string | null>(null);\n *     if (seen !== openId) { setSeen(openId); setClosing(openId ? null : seen); }   // during render\n *     {(openId ?? closing) && <ReplyThread open={openId !== null} onExited={() => setClosing(null)} …>}\n *\n * That `if` has to run during render, not in an effect: an effect would leave one committed frame\n * with the thread already unmounted and the dismissal would never run. `ios-messages-app.tsx` does\n * the same for the long-press overlay and the effects screen.\n *\n * The entrance is built with the Web Animations API, so `progress` (0..1) pauses and seeks it instead\n * of playing it, and `document.getAnimations()` can reach every part of it.\n */\nexport function ReplyThread({\n  title = \"Replies\", root: rootMessage, onClose, closeLabel, backdrop, blur, open = true, onExited, progress,\n  platform: platformProp, children, className, style, ...props\n}: Omit<ComponentProps<\"div\">, \"title\"> & {\n  title?: string;\n  /** The message the thread hangs off. Drawn above the replies, and it grows ahead of them. */\n  root?: ReactNode;\n  onClose?: () => void;\n  closeLabel?: string;\n  backdrop?: ReactNode;\n  blur?: number;\n  /** Flip to false to play the dismissal; `onExited` fires when it is over. */\n  open?: boolean;\n  onExited?: () => void;\n  /** Seek the entrance to this fraction (0..1) instead of playing it, which is what the harness does. */\n  progress?: number;\n  platform?: Platform;\n}) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const t = replyThreadMetrics[platform];\n  const ios = platform === \"ios\";\n  const blurPx = blur ?? t.blur;\n  const panelFrom = ios ? `translateY(${replyThreadMotion.panelRise}px) scale(${replyThreadMotion.panelScale})` : \"translateX(100%)\";\n  const surface = useRef<HTMLDivElement>(null);\n  const scrub = useRef<((time: number | null) => void) | null>(null);\n  const titleId = useId();\n\n  // Derived DURING RENDER, not in an effect. Two things depend on it: the dismissal has to start on\n  // the frame `open` goes false (an effect leaves one committed frame with nothing left to animate),\n  // and `onExited` must not fire for a thread that mounted closed and never opened.\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) {\n    setSeenOpen(open);\n    setClosing(!open);\n  }\n\n  // The thread is modal, so it takes focus on open (a scrubbed entrance does not: the harness seeks\n  // frames and must not move the caret) and Escape closes it from anywhere, not only while focus\n  // happens to sit inside. The surface paints no focus ring: it is a holder, not a control.\n  useEffect(() => {\n    if (!open || closing || progress !== undefined) return;\n    surface.current?.focus({ preventScroll: true });\n  }, [open, closing, progress]);\n  useEffect(() => {\n    if (!onClose || closing) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onClose(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [onClose, closing]);\n\n  // The control that opened the thread gets focus back when the thread gives it up, so a keyboard\n  // lands back on the reply count it came from rather than at the top of the document.\n  const restoreTo = useRef<HTMLElement | null>(null);\n  useLayoutEffect(() => {\n    const active = document.activeElement;\n    if (!(active instanceof Node && surface.current?.contains(active))) restoreTo.current = active as HTMLElement | null;\n  }, []);\n  // Called when the dismissal finishes rather than only from an unmount cleanup: React runs a passive\n  // cleanup after the node is already detached, by which time focus has fallen to the body and the\n  // \"was it still inside?\" test can no longer be answered. The unmount keeps it as a backstop for a\n  // parent that drops the thread without playing the dismissal.\n  const restoreFocus = useCallback(() => {\n    const previous = restoreTo.current;\n    if (!previous?.isConnected) return;\n    const el = surface.current;\n    const active = document.activeElement;\n    const gone = !active || active === document.body;\n    if (!gone && !(el && active instanceof Node && el.contains(active))) return;\n    restoreTo.current = null;\n    previous.focus({ preventScroll: true });\n  }, []);\n  useEffect(() => restoreFocus, [restoreFocus]);\n\n  /** Tab stays inside while the thread is up, the way a modal sheet does. */\n  function onSurfaceKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    if (event.key !== \"Tab\") return;\n    const el = surface.current;\n    if (!el) return;\n    const stops = Array.from(el.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex=\"-1\"])')).filter(node => node.getClientRects().length > 0);\n    if (!stops.length) return;\n    const first = stops[0], last = stops[stops.length - 1];\n    const active = document.activeElement;\n    const inside = active instanceof Node && el.contains(active);\n    if (event.shiftKey ? active === first || !inside : active === last || !inside) {\n      event.preventDefault();\n      (event.shiftKey ? last : first).focus();\n    }\n  }\n\n  // The entrance, built with the Web Animations API so it can be played or seeked. Every layer's\n  // resting style already is the end of its keyframes, so settling the timeline is just cancelling\n  // it, which also releases the composited layer a held animation would otherwise pin (a held layer\n  // cuts `backdrop-filter` off from what it samples; `message-actions.tsx` documents the same trap).\n  // Settling empties the list, so a seek back below the end rebuilds it rather than losing it.\n  useLayoutEffect(() => {\n    const el = surface.current;\n    if (!el) return;\n    const T = replyThreadMotion;\n    const one = (slot: string) => el.querySelector<HTMLElement>(`[data-slot=\"${slot}\"]`);\n    let list: Animation[] = [];\n    const build = () => {\n      const made: Animation[] = [];\n      const add = (target: Element | null, keyframes: Keyframe[], options: KeyframeAnimationOptions) => { if (target) made.push(target.animate(keyframes, { fill: \"both\", ...options })); };\n      add(one(\"dim\"), [{ opacity: 0 }, { opacity: 1 }], { duration: T.dim, easing: \"ease-out\" });\n      add(one(\"backdrop-blur\"), [{ opacity: 0 }, { opacity: 1 }], { duration: T.dim, easing: \"ease-out\" });\n      add(one(\"backdrop-content\"), [{ filter: \"blur(0px)\" }, { filter: `blur(${blurPx}px)` }], { duration: T.dim, easing: \"ease-out\" });\n      add(one(\"thread-panel\"), ios\n        ? [{ opacity: 0, transform: panelFrom }, { opacity: 1, transform: \"none\" }]\n        : [{ transform: panelFrom }, { transform: \"none\" }], { duration: T.enter, easing: T.ease });\n      add(one(\"thread-root\"), [{ transform: `scale(${T.rootScale})` }, { transform: \"none\" }], { duration: T.enter, easing: T.ease });\n      return made;\n    };\n    const drop = (a: Animation) => { try { a.cancel(); } catch { /* already gone */ } };\n    const settle = () => { for (const a of list) drop(a); list = []; };\n    scrub.current = time => {\n      if (time !== null && time >= T.enter) { settle(); return; }\n      if (!list.length) list = build();\n      if (time === null) {\n        // Each leg is released as it ends, not when the first one does: the dim is over at 150 ms and\n        // the panel at 260, and settling them together would cut the panel's last 110 ms. Its `finished`\n        // is captured before the cancel, which replaces the promise with a fresh pending one.\n        const playing = list;\n        const ends = playing.map(a => { a.play(); return a.finished; });\n        ends.forEach((end, i) => end.then(() => drop(playing[i])).catch(() => {}));\n        Promise.allSettled(ends).then(() => { if (list === playing) list = []; });\n        return;\n      }\n      for (const a of list) { a.pause(); a.currentTime = time; }\n    };\n    return () => { settle(); scrub.current = null; };\n  }, [blurPx, ios, panelFrom]);\n\n  useEffect(() => {\n    if (!open || closing) return;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    if (progress !== undefined) scrub.current?.(Math.min(1, Math.max(0, progress)) * replyThreadMotion.enter);\n    else if (reduced) scrub.current?.(replyThreadMotion.enter);\n    else scrub.current?.(null);\n  }, [open, closing, progress]);\n\n  // The dismissal: the entrance is settled first, so this runs from the settled, open frame.\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n  useEffect(() => {\n    if (!closing) return;\n    const el = surface.current;\n    const reduced = typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n    scrub.current?.(replyThreadMotion.enter);\n    let done = false;\n    const finish = () => { if (done) return; done = true; restoreFocus(); exited.current?.(); };\n    if (!el || reduced) { finish(); return; }\n    const one = (slot: string) => el.querySelector<HTMLElement>(`[data-slot=\"${slot}\"]`);\n    const timing: KeyframeAnimationOptions = { duration: replyThreadMotion.exit, easing: replyThreadMotion.exitEase, fill: \"both\" };\n    const running: Animation[] = [];\n    const add = (target: Element | null, keyframes: Keyframe[]) => { if (target) running.push(target.animate(keyframes, timing)); };\n    add(one(\"dim\"), [{ opacity: 1 }, { opacity: 0 }]);\n    add(one(\"backdrop-blur\"), [{ opacity: 1 }, { opacity: 0 }]);\n    add(one(\"backdrop-content\"), [{ filter: `blur(${blurPx}px)` }, { filter: \"blur(0px)\" }]);\n    add(one(\"thread-panel\"), ios\n      ? [{ opacity: 1, transform: \"none\" }, { opacity: 0, transform: panelFrom }]\n      : [{ transform: \"none\" }, { transform: panelFrom }]);\n    add(one(\"thread-root\"), [{ transform: \"none\" }, { transform: `scale(${replyThreadMotion.rootScale})` }]);\n    if (!running.length) { finish(); return; }\n    Promise.allSettled(running.map(a => a.finished)).then(finish);\n    return () => running.forEach(a => { try { a.cancel(); } catch { /* already gone */ } });\n  }, [closing, blurPx, ios, panelFrom, restoreFocus]);\n\n  const dismiss = closing ? undefined : onClose;\n  const panelStyle: CSSProperties = ios\n    ? { inset: 0, transformOrigin: \"50% 100%\" }\n    : {\n      top: 0, bottom: 0, right: 0, width: `min(100%, ${t.paneWidth}px)`, background: \"var(--im-bg)\",\n      borderInlineStart: \"1px solid var(--im-separator)\", boxShadow: \"-8px 0 26px rgba(0,0,0,0.12)\",\n    };\n\n  return (\n    <div ref={surface} data-slot=\"reply-thread\" data-platform={platform} data-state={closing ? \"closing\" : \"open\"}\n      role=\"dialog\" aria-modal=\"true\" aria-labelledby={titleId} tabIndex={-1} onKeyDown={onSurfaceKeyDown}\n      className={cn(\"absolute inset-0 z-30 outline-none\", className)}\n      style={{ fontFamily: fontStack, ...style }} {...props}>\n      {backdrop !== undefined ? (\n        <div aria-hidden=\"true\" data-slot=\"backdrop\" className=\"absolute inset-0 -z-10 overflow-hidden\">\n          {/* Blurring a box larger than the screen keeps the filter's own edge falloff off-screen. */}\n          <div data-slot=\"backdrop-content\" className=\"absolute\" style={{ inset: -60, background: \"var(--im-bg)\", filter: `blur(${blurPx}px)` }}>\n            <div className=\"absolute\" style={{ inset: 60 }}>{backdrop}</div>\n          </div>\n        </div>\n      ) : blurPx > 0 ? (\n        // The fallback blur is its own layer rather than a filter on the surface: the entrance fades\n        // it in, and fading the surface itself would take the thread with it.\n        <div aria-hidden=\"true\" data-slot=\"backdrop-blur\" className=\"absolute inset-0 -z-10\"\n          style={{ backdropFilter: `blur(${blurPx}px)`, WebkitBackdropFilter: `blur(${blurPx}px)` }} />\n      ) : null}\n      {/* The dim, then a scrim over it: Escape does the same job for the keyboard, so the scrim stays\n          out of the a11y tree. The scrim goes inert while the thread is leaving, so a second click\n          cannot ask for a dismissal that is already running. */}\n      <div aria-hidden=\"true\" data-slot=\"dim\" className=\"absolute inset-0\" style={{ background: \"var(--im-dim, rgba(22,18,44,0.21))\" }} />\n      <div aria-hidden=\"true\" data-slot=\"scrim\" onClick={dismiss} className=\"absolute inset-0 cursor-default\" />\n      {/* iOS fills the frame, so the panel lets clicks through to the scrim and only its own content\n          takes them back. The macOS pane is opaque and takes everything inside its own width. */}\n      <div data-slot=\"thread-panel\" className={cn(\"absolute flex flex-col\", ios && \"pointer-events-none\")} style={panelStyle}>\n        <div data-slot=\"thread-header\" className=\"pointer-events-auto relative flex shrink-0 items-center\"\n          style={{ height: t.headerHeight, marginTop: t.topInset, paddingInline: m.edgeInset, justifyContent: ios ? \"center\" : \"space-between\" }}>\n          <span id={titleId} data-slot=\"thread-title\" style={{ fontSize: t.titleSize, lineHeight: 1, fontWeight: 600, color: \"var(--im-incoming-text)\" }}>{title}</span>\n          {onClose && (\n            <button type=\"button\" data-slot=\"thread-close\" onClick={dismiss} aria-label={closeLabel ?? (ios ? \"Back to the conversation\" : \"Close replies\")}\n              className=\"flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n              style={ios\n                ? { position: \"absolute\", left: t.closeInset, top: (t.headerHeight - t.closeSize) / 2, width: t.closeSize, height: t.closeSize, color: \"var(--im-incoming-text)\" }\n                : { width: t.closeSize, height: t.closeSize, color: \"var(--im-secondary)\" }}>\n              <ThreadCloseGlyph platform={platform} />\n            </button>\n          )}\n        </div>\n        <div data-slot=\"thread-messages\" className=\"pointer-events-auto relative mt-auto flex flex-col\" style={{ gap: m.gapBetweenGroups, padding: m.edgeInset }}>\n          {rootMessage !== undefined && (\n            <div data-slot=\"thread-root\" className=\"flex w-full flex-col\" style={{ transformOrigin: \"50% 100%\" }}>{rootMessage}</div>\n          )}\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/** Shape an app can store alongside a message to describe its thread. */\nexport type ThreadInfo = { rootId: string; replyCount: number };\n\nexport type ReplyStyle = CSSProperties;\n",
          "type": "registry:ui",
          "target": "components/imessage/message-reply.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-image",
      "title": "Photo message",
      "description": "Photos and videos in the bubble's shape, tail included, with the native multi-photo grid.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-image.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type MouseEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, fontStack, type Direction } from \"@/components/imessage/tokens\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/imessage/bubble-shape\";\n\n/**\n * Photos and videos sent in a conversation. A photo takes the bubble's shape, tail included, so the\n * same traced outline in `bubble-shape.ts` clips the image instead of filling it.\n *\n * NOT MEASURED against a native photo message: the corner radius and tail come from the measured text\n * bubble, and the multi-photo grid follows the documented layout. The tile gap is provisional.\n *\n * The single photo's box, on the other hand, is now read out of ChatKit rather than guessed: see\n * `photoBox` below. So are the accessible names: ChatKit's own accessibility bundle\n * (`/System/iOSSupport/System/Library/AccessibilityBundles/ChatKitFramework.axbundle`,\n * `Accessibility.loctable`) calls one attachment `photo.attachment` = \"Photo\", counts them with\n * `attachment.count` = \"%d attachments\" and positions each one with\n * `messages.attachment.stack.view.format` = \"attachment %1$d of %2$d\".\n *\n * A photo sent together with text is NOT one balloon with a caption under the photo. ChatKit splits a\n * message into parts (`-[CKMessagePartChatItem messagePartRange]`, `CKTextMessagePartChatItem` vs\n * `CKAttachmentMessagePartChatItem`), and each part is its own balloon chat item, so native draws the\n * photo balloon and the text balloon as two bubbles of one cluster. \"Caption\" exists nowhere in the\n * image balloon; in ChatKit it belongs to Business Chat rich cards\n * (`-[CKBalloonView didTapTruncatedCaptionForRichCard:]`). The message list therefore renders the two,\n * passing `tail` to whichever comes last; this component stays a photo group.\n */\nexport type MessageImage = { src: string; alt: string; width?: number; height?: number };\n\nexport type MessageImagesProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  images: MessageImage[];\n  direction?: Direction;\n  tail?: boolean;\n  /** Longest edge of the group, in px. Defaults to the platform's maximum bubble width. */\n  maxWidth?: number;\n  /** Tallest the group may grow, in px. Defaults to the native cap (see `photoBox`). */\n  maxHeight?: number;\n  /** The transfer has not arrived yet: hold every tile on the placeholder. */\n  loading?: boolean;\n  /**\n   * Open the full-screen viewer at `index`. The second argument is the tile's own box in viewport\n   * coordinates, so the viewer can zoom out of the tile the way native does instead of fading in.\n   */\n  onOpenImage?: (index: number, rect: DOMRect) => void;\n  /** @deprecated Use `onOpenImage`, which also hands over the tile's box. */\n  onOpen?: (index: number) => void;\n  /** Tapback balloons; positioned on the group's top corner away from the screen edge. */\n  reactions?: ReactNode;\n  platform?: Platform;\n};\n\n/** Native shows at most four tiles and counts the rest. */\nconst MAX_TILES = 4;\n\n/** Gap between tiles. Provisional: no native capture of a multi-photo message exists. */\nconst TILE_GAP = 2;\n\n/**\n * How big one photo's balloon is, read out of ChatKit instead of a screenshot.\n * `-[CKUIBehaviorPhone thumbnailFillSizeForWidth:imageSize:]`, swept over widths 100…600 against\n * extreme image sizes, fills the balloon width and then clamps the shape: anything wider than 16:9\n * comes back at `width x 0.5625` and anything taller than 3:4 at `width x 1.3333`, in both cases\n * cropped, since the answer is a *fill* size (its sibling `unconstrainedAspectFillSizeForWidth:`\n * returns the unclamped fit). It never returns a height over 500; past that the width shrinks to hold\n * the ratio (w 400, a 1:4 photo → 375 x 500).\n *\n * `-[CKUIBehaviorMac thumbnailFillSizeForWidth:imageSize:]` overrides it and applies no shape clamp at\n * all: it returns the true aspect fit, capped at the same height of 500 (a 1:6 photo → 83.5 x 500).\n * Native rounds each result to the device pixel grid (it answers 158.0 where 280.5 x 0.5625 is\n * 157.78); the fractions are kept here for the same reason `tileSize` keeps its own.\n */\nexport const photoBox: Record<Platform, { minRatio: number; maxRatio: number; maxHeight: number }> = {\n  ios: { minRatio: 0.5625, maxRatio: 4 / 3, maxHeight: 500 },\n  macos: { minRatio: 0, maxRatio: Number.POSITIVE_INFINITY, maxHeight: 500 },\n};\n\n/**\n * Where a tapback balloon sits against the group's top corner. Measured on text bubbles and carried\n * over: a photo group takes a tapback like any other balloon, and native anchors it to the balloon's\n * frame, which is what this box is. Numbers from `tapback.tsx`'s `balloonSlot` (the corrected pair),\n * not from `message-bubble.tsx`'s older copy. A photo has no measured slot of its own; ChatKit does\n * carry `-[CKUIBehavior messageAcknowledgmentPhotoGridXOffsetScalar]` = 0 / `…YOffsetScalar` = 0.2 on\n * iPhone and 0.35 / 0.35 on Mac, but those are fractions of a frame this component does not build (a\n * photo *grid* view), so they are recorded rather than used.\n */\nconst reactionSlot: Record<Platform, { marginTop: number; top: number; side: number }> = {\n  ios: { marginTop: 28, top: -27.39, side: -13.85 },\n  macos: { marginTop: 27.4, top: -22.05, side: -11.79 },\n};\n\n/**\n * What native offers on an attachment it has not fetched: ChatKit's `TAP_TO_DOWNLOAD` = \"Tap to\n * Download\" and `CLICK_TO_DOWNLOAD` = \"Click to Download\" (`ChatKit.framework/Resources/ChatKit.loctable`).\n * The tile keeps the same copy when the fetch fails, and activating it asks for the photo again.\n */\nconst downloadLabel: Record<Platform, string> = { ios: \"Tap to Download\", macos: \"Click to Download\" };\n\n/**\n * Aspect ratios already learned this session, so a photo that has been seen once never opens at the\n * 4:3 placeholder again. A remount (the viewer closing, a list re-render) would otherwise lay the\n * bubble out at the wrong height and snap when the image decodes. Empty during hydration, because\n * nothing can have loaded by then, so the first client render still matches the server's.\n */\nconst aspectMemo = new Map<string, number>();\n\ntype Phase = \"ready\" | \"failed\";\n\n/**\n * Average colour of the corner of `image` that the tail meets, so the tail reads as a continuation of\n * the photo rather than a gray stub. The tile is `object-cover`, so the visible corner is not the\n * source's own corner: map the tail's footprint back through the cover crop before reading it.\n * Returns null when the image has not loaded, is not laid out, or taints the canvas (a cross-origin\n * photo), and the caller keeps its fallback fill.\n */\nfunction sampleCorner(image: HTMLImageElement, side: \"left\" | \"right\", boxWidth: number, boxHeight: number): string | null {\n  const naturalWidth = image.naturalWidth;\n  const naturalHeight = image.naturalHeight;\n  if (!image.complete || !naturalWidth || !naturalHeight) return null;\n  const rect = image.getBoundingClientRect();\n  if (!rect.width || !rect.height) return null;\n  const cover = Math.max(rect.width / naturalWidth, rect.height / naturalHeight);\n  const visibleWidth = Math.min(naturalWidth, rect.width / cover);\n  const visibleHeight = Math.min(naturalHeight, rect.height / cover);\n  const patchWidth = Math.max(1, Math.min(visibleWidth, boxWidth / cover));\n  const patchHeight = Math.max(1, Math.min(visibleHeight, boxHeight / cover));\n  const left = (naturalWidth - visibleWidth) / 2;\n  const sx = side === \"right\" ? left + visibleWidth - patchWidth : left;\n  const sy = (naturalHeight - visibleHeight) / 2 + visibleHeight - patchHeight;\n  try {\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = 8;\n    canvas.height = 8;\n    const context = canvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!context) return null;\n    context.drawImage(image, sx, sy, patchWidth, patchHeight, 0, 0, 8, 8);\n    const data = context.getImageData(0, 0, 8, 8).data;\n    let r = 0, g = 0, b = 0;\n    for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; }\n    const pixels = data.length / 4;\n    return `rgb(${Math.round(r / pixels)}, ${Math.round(g / pixels)}, ${Math.round(b / pixels)})`;\n  } catch {\n    return null; // a cross-origin photo taints the canvas\n  }\n}\n\nexport function MessageImages({\n  images, direction = \"outgoing\", tail = false, maxWidth, maxHeight, loading = false,\n  onOpenImage, onOpen, reactions, platform: platformProp, className, style, ...props\n}: MessageImagesProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const side = direction === \"outgoing\" ? \"right\" : \"left\";\n  const width = maxWidth ?? m.maxWidth;\n  const tiles = images.slice(0, MAX_TILES);\n  const overflow = images.length - tiles.length;\n  const single = tiles.length === 1;\n  const groupKey = tiles.map(image => image.src).join(\"|\");\n\n  // Tiles are square, so one number sets both axes: half the group, less the gap. Keep the fraction\n  // instead of rounding it, or they stop being square: 280.5 halves to 139.25, and two of those plus\n  // the gap is the group's 280.5 again.\n  const tileSize = (width - TILE_GAP) / 2;\n\n  // One photo keeps its aspect ratio: the caller's dimensions when it gave any, otherwise the image's\n  // own once it has loaded. 4:3 is only the placeholder until then, and only for a photo whose size\n  // this session has never seen; declaring `width`/`height` is what keeps the bubble from resizing at\n  // all on a cold load.\n  const [measured, setMeasured] = useState<{ src: string; aspect: number } | null>(null);\n  const first: MessageImage | undefined = tiles[0];\n  const firstSrc = first?.src ?? \"\";\n  const declaredAspect = single && first?.width && first.height ? first.width / first.height : null;\n  const learnedAspect = single ? (measured?.src === firstSrc ? measured.aspect : aspectMemo.get(firstSrc)) : undefined;\n  const aspect = declaredAspect ?? learnedAspect ?? 4 / 3;\n\n  // The clamp is the native one, so a very tall photo stops at 4:3 of its width and a very wide one at\n  // 16:9, both cropped by `object-cover`; the group never grows past `maxHeight`, and when the clamped\n  // shape would, the width comes in with it rather than the photo stretching.\n  const box = photoBox[platform];\n  const ceiling = maxHeight ?? box.maxHeight;\n  const ratio = Math.min(Math.max(1 / aspect, box.minRatio), box.maxRatio);\n  const groupWidth = single ? Math.min(width, ceiling / ratio) : width;\n  const height = single ? groupWidth * ratio : tiles.length === 2 ? tileSize : tileSize * 2 + TILE_GAP;\n\n  const grid = useMemo<CSSProperties>(() => {\n    if (single) return { display: \"block\" };\n    return { display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gridTemplateRows: tiles.length === 2 ? \"1fr\" : \"1fr 1fr\", gap: TILE_GAP };\n  }, [single, tiles.length]);\n\n  const hang = tailBox.hang * m.tailScale;\n  // The tail continues the photo, so it is filled from the tile that touches it: the bottom tile on\n  // the tail's side. Three photos put a full-height tile first, so on an incoming message that first\n  // tile is the one the tail meets; the 2x2 grid meets tile 3 on the left and tile 4 on the right.\n  const tailIndex = side === \"right\" ? tiles.length - 1 : tiles.length === MAX_TILES ? 2 : 0;\n  const [tailFill, setTailFill] = useState<{ key: string; color: string } | null>(null);\n  // A tile is on the placeholder until its own <img> says otherwise, so the bubble is never a hole.\n  const [phase, setPhase] = useState<Record<string, Phase>>({});\n  const [attempt, setAttempt] = useState<Record<string, number>>({});\n  const gridRef = useRef<HTMLDivElement>(null);\n\n  // Runs on mount as well as on load: a server-rendered <img> is normally already complete by the\n  // time React attaches its handlers, and then `onLoad` never fires at all, the tail stays gray and\n  // every tile looks unloaded. It is a layout effect so a photo the browser already has is laid out\n  // at its real shape in the same frame, instead of painting 4:3 once and resizing.\n  const measure = useCallback(() => {\n    const element = gridRef.current;\n    if (!element) return;\n    const rendered = element.querySelectorAll(\"img\");\n    // By tile index, not by position in the list: a tile that failed renders no <img> at all, and\n    // counting elements would then sample the wrong photo for the tail.\n    const edge = element.querySelector<HTMLImageElement>(`img[data-tile=\"${tailIndex}\"]`);\n    // Only ever replace a real colour with a real colour: a half-decoded image reads as null, and\n    // dropping back to gray for a frame would flash the tail.\n    const sampled = tail && edge ? sampleCorner(edge, side, tailBox.width * m.tailScale, tailBox.height * m.tailScale) : null;\n    if (sampled) setTailFill(previous => (previous?.key === groupKey && previous.color === sampled ? previous : { key: groupKey, color: sampled }));\n    setPhase(previous => {\n      let next = previous;\n      rendered.forEach(image => {\n        if (!image.complete) return;\n        const src = image.getAttribute(\"data-src\") ?? image.src;\n        const value: Phase = image.naturalWidth > 0 ? \"ready\" : \"failed\";\n        if (previous[src] === value) return;\n        if (next === previous) next = { ...previous };\n        next[src] = value;\n      });\n      return next;\n    });\n    const photo = element.querySelector<HTMLImageElement>('img[data-tile=\"0\"]');\n    if (single && photo?.naturalWidth && photo.naturalHeight) {\n      const value = photo.naturalWidth / photo.naturalHeight;\n      aspectMemo.set(firstSrc, value);\n      setMeasured(previous => (previous?.src === firstSrc && previous.aspect === value ? previous : { src: firstSrc, aspect: value }));\n    }\n  }, [single, tail, tailIndex, side, m.tailScale, groupKey, firstSrc, setMeasured, setPhase, setTailFill]);\n  useLayoutEffect(() => { measure(); }, [measure]);\n\n  const retry = useCallback((src: string) => {\n    setPhase(previous => { const next = { ...previous }; delete next[src]; return next; });\n    setAttempt(previous => ({ ...previous, [src]: (previous[src] ?? 0) + 1 }));\n  }, []);\n\n  const open = onOpenImage ?? (onOpen ? (index: number) => onOpen(index) : undefined);\n  const busy = loading || tiles.some(image => phase[image.src] === undefined);\n  const slot = reactionSlot[platform];\n  // No photos is not an empty balloon: without this the group would paint a bare gray square the size\n  // of a four-tile grid.\n  if (!tiles.length) return null;\n\n  return (\n    <div data-slot=\"message-images\" data-direction={direction} data-count={images.length}\n      // ChatKit's `attachment.count` reads \"%d attachments\"; the group is named with the photo noun\n      // its own `PHOTO_ATTACHMENT_STATUS_PHOTOS_TITLE_FORMAT` (\"%tu Photos\") uses. One photo needs no\n      // group at all: the tile's own name already says everything.\n      role={images.length > 1 ? \"group\" : undefined}\n      aria-label={images.length > 1 ? `${images.length} Photos` : undefined}\n      aria-busy={busy || undefined}\n      className={cn(\"relative\", className)}\n      style={{ width: groupWidth, fontFamily: fontStack, marginTop: reactions ? slot.marginTop : undefined, ...style }} {...props}>\n      <div ref={gridRef} data-slot=\"image-grid\" style={{ ...grid, width: groupWidth, height, borderRadius: m.radius, overflow: \"hidden\", clipPath: tail ? bodyClipPath(side, m.tailScale, tailSeamOverlap[platform]) : undefined, background: \"var(--im-gray-top)\" }}>\n        {tiles.map((image, index) => {\n          const spanFirst = tiles.length === 3 && index === 0;\n          const state = phase[image.src];\n          const failed = state === \"failed\";\n          const last = index === MAX_TILES - 1 && overflow > 0;\n          const name = image.alt?.trim() || \"Photo\";\n          // \"Photo, 2 of 5\" follows ChatKit's own `messages.attachment.stack.view.format`\n          // (\"attachment %1$d of %2$d\"); the counted tile has to say what its \"+N\" opens.\n          const position = images.length > 1 ? `${name}, ${index + 1} of ${images.length}` : name;\n          // ChatKit's `attachment.count` carries a real plural rule (\"%d attachment\" / \"%d attachments\"),\n          // so the counted tile gets one too rather than reading \"1 more photos\".\n          const more = `Show ${overflow} more photo${overflow === 1 ? \"\" : \"s\"}.`;\n          const label = failed ? `${position}. ${downloadLabel[platform]}.` : last ? `${position}. ${more}` : position;\n          // A tile that cannot be fetched offers the fetch again, the way native's undownloaded\n          // attachment does. Otherwise it opens the viewer, handing over its own box so the viewer can\n          // grow out of this tile.\n          const activate: ((event: MouseEvent<HTMLButtonElement>) => void) | undefined = failed\n            ? () => retry(image.src)\n            : open\n              ? event => open(index, event.currentTarget.getBoundingClientRect())\n              : undefined;\n          // A button is a control for the pointer and the keyboard both, for free. With nothing to\n          // open it would be a dead tab stop that reads out \"dimmed\", so the tile becomes a named\n          // image instead: the photo keeps its accessible name either way.\n          const shell = {\n            \"data-slot\": \"photo-tile\", \"data-index\": index,\n            \"data-state\": failed ? \"failed\" : state === \"ready\" ? \"ready\" : \"loading\",\n            \"aria-label\": label,\n            className: \"relative block h-full w-full overflow-hidden p-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\",\n            style: spanFirst ? { gridRow: \"span 2\" } : undefined,\n          } as const;\n          const inner = (\n            <>\n              {failed ? (\n                // The placeholder the grid already paints, plus native's own copy for a photo it does\n                // not have. Type size is not measured.\n                <span data-slot=\"photo-failed\" aria-hidden=\"true\"\n                  className=\"absolute inset-0 flex items-center justify-center px-[8px] text-center font-medium\"\n                  style={{ fontSize: platform === \"ios\" ? 15 : 13, lineHeight: 1.2, color: \"var(--im-incoming-text)\" }}>\n                  {downloadLabel[platform]}\n                </span>\n              ) : (\n                /* eslint-disable-next-line @next/next/no-img-element */\n                <img key={attempt[image.src] ?? 0} src={image.src} data-src={image.src} data-tile={index} alt=\"\" loading=\"lazy\" decoding=\"async\"\n                  onLoad={measure} onError={measure}\n                  className=\"h-full w-full object-cover\" style={{ opacity: loading ? 0 : 1 }} />\n              )}\n              {last && (\n                <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center font-semibold text-white\"\n                  style={{ background: \"rgba(0,0,0,0.42)\", fontSize: platform === \"ios\" ? 22 : 17 }}>+{overflow}</span>\n              )}\n            </>\n          );\n          return activate\n            ? <button key={image.src + index} type=\"button\" onClick={activate} {...shell}>{inner}</button>\n            : <div key={image.src + index} role=\"img\" {...shell}>{inner}</div>;\n        })}\n      </div>\n      {tail && (\n        <div aria-hidden=\"true\" data-slot=\"tail\" className=\"pointer-events-none absolute\"\n          style={{ [side]: 0, bottom: -hang, width: tailBox.width * m.tailScale, height: tailBox.height * m.tailScale + hang, clipPath: `path(\"${tailPath(side, m.tailScale)}\")`, background: tailFill?.key === groupKey ? tailFill.color : \"var(--im-gray-bottom)\" }} />\n      )}\n      {/* Outside the grid on purpose: the grid clips to the balloon's outline, and a balloon that laps\n          the photo's rounded corner would lose its own edge to that clip. */}\n      {reactions && (\n        <div data-slot=\"reactions\" className=\"absolute z-10\" style={{ top: slot.top, [direction === \"outgoing\" ? \"left\" : \"right\"]: slot.side }}>{reactions}</div>\n      )}\n    </div>\n  );\n}\n\n/** Override the sampled tail colour, e.g. when the image is cross-origin and cannot be read back. */\nexport function photoTailFill(color: string): CSSProperties {\n  return { [\"--im-gray-bottom\" as string]: color } as CSSProperties;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-image.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "image-viewer",
      "title": "Photo viewer",
      "description": "The full-screen viewer a photo opens into: zoom out of the tile, swipe between the message's photos, pinch and pan, react, and drag down to close.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tapback-bar.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/image-viewer.tsx",
          "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, fontStack } from \"@/components/imessage/tokens\";\nimport { Tapback, TapbackGlyph, balloonGeometry, balloonSlot, pickerBalloonGeometry, tapbackVars } from \"@/components/imessage/tapback\";\nimport { SmileyIcon, TapbackBar, tapbackBarMetrics, type TapbackSelection } from \"@/components/imessage/tapback-bar\";\n\n/**\n * The full-screen photo viewer: what a photo in a bubble opens into.\n *\n * NO CAPTURE OF THIS SCREEN EXISTS, and none can be made from here, so every number below says where\n * it actually came from. Three kinds, and each value repeats its own:\n *\n * FRAMEWORK. Read out of the binaries shipped on this Mac (macOS 26.5.2, Messages 26.0) by dlopen-ing\n * the macCatalyst frameworks from a macCatalyst process and reading the live Objective-C objects, plus\n * `dyld_info` over the same images. The class and selector is named on every value.\n *   - Messages does not draw a photo browser of its own: it opens QuickLook.\n *     `ChatKit.CKQLPreviewController` is a `QLPreviewController` subclass, and its members say what the\n *     screen holds: `numberOfPreviewItemsInPreviewController:` and `currentPreviewItemIndex` (paging\n *     over the message's own items), `replyButton`, `tapbackButton`, `saveTapped:`, `replyTapped:`,\n *     `tapbackTapped:` and `updateBarButtonItems` (the bars). That is the shape this component copies.\n *   - The reaction picker over a full-screen item is `ChatKit.CKFullScreenBalloonViewController`,\n *     anchored on the toolbar's tapback button\n *     (`CKQLPreviewController -tapbackButtonFrameForFullScreenBalloonViewController:`) and drawn with a\n *     downward tail (`-fullScreenBalloonViewControllerPickerViewUsesBottomTail:`). Hence the picker pill\n *     floats above the footer with its thought bubble pointing back down at that button.\n *   - Colour and type come from `PhotosUIPrivate.PUBlackOneUpInterfaceTheme`, the theme the photo\n *     browser runs under. Durations, zoom factors and the page gap come from\n *     `PhotosUIPrivate.PUOneUpSettings` and `PUTilingViewSettings`.\n *   - The accessible names are ChatKit's own, from\n *     `AccessibilityBundles/ChatKitFramework.axbundle/Contents/Resources/Accessibility.loctable`:\n *     `photo.attachment` = \"Photo\", `messages.attachment.stack.view.format` = \"attachment %1$d of %2$d\",\n *     `save.photo.button` = \"Save photo\", `delete.button.label` = \"Delete\", `balloon.message.reply` = \"Reply\".\n *\n * MEASURED, from `references/SPEC.md`: the iOS safe area. SPEC's iOS frame is 402x874 with the status\n * bar over y 0-54 (Dynamic Island 14-50.67) and records \"Home indicator: none in simulator captures\".\n * So the default inset is 54 top and 0 bottom, and `safeArea` is the prop for a device that does show a\n * gesture bar. The chrome is laid out inside those insets and never under them.\n *\n * JUDGEMENT, with no framework symbol and no capture behind it (each one says so again where it lives):\n * the thresholds that commit a swipe or a dismissal, the scale the photo shrinks to while it is being\n * flung away, the bar heights (UIKit's stock 44 and 49), the gradient behind the bars, where an applied\n * tapback balloon sits on a full-screen photo, and the whole macOS presentation.\n *\n * macOS ASSUMPTION. Messages on macOS is the same Catalyst binary, so the same QuickLook viewer opens,\n * but into a 960x640 window instead of over a phone screen. This component keeps one API and changes\n * only the framing: no safe-area inset, shorter bars, the macOS two-row tapback picker in a floating\n * panel instead of the iOS pill, and pointer and keyboard paths (double click, trackpad pinch, arrow\n * keys, plus and minus) carrying what touch carries on iOS. None of that is measured.\n *\n * SEEKABLE. The entrance and the exit are one Web Animations timeline per layer, so\n * `document.getAnimations()` reaches them and `progress` pauses and seeks instead of playing. With\n * `progress` set, every CSS transition in the tree is switched off too, so a checkpoint renders the\n * same frame every run.\n */\nexport const imageViewerMetrics = {\n  /**\n   * The viewer's ground. `PUBlackOneUpInterfaceTheme -photoBrowserChromeVisibleBackgroundColor` and\n   * `-photoBrowserChromeHiddenBackgroundColor` both resolve to opaque black in the light AND the dark\n   * trait collection, so the viewer does not follow the theme: it is black either way.\n   */\n  ground: \"#000000\",\n  /** `PUBlackOneUpInterfaceTheme -photoBrowserTitleViewTextColor` (and `-photoBrowserTitleViewTappableTextColor`): white in both themes. */\n  chromeInk: \"#ffffff\",\n  /** `-photoBrowserPhotoPrimaryTitleFont` is \".SFNS-Regular 15.00 pt\". */\n  title: { fontSize: 15, weight: 400 },\n  /** `-photoBrowserPhotoSubtitleFont` is \".SFNS-Regular 11.00 pt\". The opacity is JUDGEMENT: the theme gives one white for both lines. */\n  subtitle: { fontSize: 11, weight: 400, opacity: 0.72 },\n  /** `PUBlackOneUpInterfaceTheme -topToolbarToolButtonGlyphSize` = 21. */\n  glyph: 21,\n  /** `-topToolbarToolButtonFont` is \".SFNS-Regular 15.00 pt\"; the Done button takes it. */\n  button: { fontSize: 15, weight: 400 },\n  /**\n   * Bar heights. UIKit's stock navigation bar and toolbar for a phone in portrait, and a guess for a\n   * window. NOT measured, and not a framework value I could read: platform default, not Messages' own.\n   */\n  bars: { ios: { header: 44, footer: 49 }, macos: { header: 38, footer: 44 } },\n  /** `PUOneUpSettings -barsAreaVerticalOutset` = 10: how far the bars' area runs past the bars themselves. */\n  barsOutset: 10,\n  /** `PUOneUpSettings -interpageSpacing` = 100. The gap is the viewer's ground, so it reads as black. */\n  interpageSpacing: 100,\n  /** `PUOneUpSettings -doubleTapZoomFactor` = 2.5. */\n  doubleTapZoom: 2.5,\n  /** `PUOneUpSettings -defaultZoomInFactor` = 6, the pinch ceiling. */\n  maxZoom: 6,\n  /** `PUOneUpSettings -allowDoubleTapZoom`, `-allowChromeHiding` and `-allowUserTransform` are all 1. */\n  allowDoubleTapZoom: true,\n  allowChromeHiding: true,\n  /**\n   * `PUOneUpSettings -parallaxFactor` = 12.5 with `-allowParallax` = 1: while a page moves, its photo\n   * lags by 1/12.5 of that page's offset, so the strip does not slide as one sheet.\n   */\n  parallaxFactor: 12.5,\n  /** `PUOneUpSettings -chromeAutoHideDelay` = 3 s. Messages does not auto-hide, so nothing here uses it; recorded because it is the framework's number. */\n  chromeAutoHideDelay: 3000,\n  /**\n   * Rubber band. UIScrollView's documented resistance constant, which every Apple scroll view uses and\n   * the one-up pager is one. NOT read out of PhotosUI: platform default, not a measurement.\n   */\n  rubberBand: 0.55,\n  /**\n   * Committing a swipe. JUDGEMENT, no framework value: the page flips when the drag passes a third of\n   * the frame or the flick is faster than 500 pt/s.\n   */\n  pageCommit: { distance: 1 / 3, velocity: 500 },\n  /**\n   * Committing a drag-to-dismiss, and how far the photo shrinks on the way down. JUDGEMENT:\n   * `PUOneUpSettings` carries no dismissal threshold. 120 pt of travel or a 700 pt/s flick, with the\n   * photo scaling to 0.6 across that travel while the ground fades out under it.\n   */\n  dismiss: { distance: 120, velocity: 700, minScale: 0.6 },\n  /** How far a pointer has to move before a drag stops being a tap. JUDGEMENT. */\n  slop: 6,\n  /** How long a second tap counts as a double tap, and how far it may land from the first. JUDGEMENT. */\n  doubleTap: { window: 300, slop: 24 },\n  timing: {\n    /** `PUTilingViewSettings -springAnimationDuration` = 0.3, with `-useSpringAnimations` = 1. The open zoom, and the exit that reverses it. */\n    zoom: 300,\n    /** `PUTilingViewSettings -transitionDuration` = 0.2: the ground coming up behind the zoom. */\n    backdrop: 200,\n    /** `PUOneUpSettings -chromeDefaultAnimationDuration` = 0.2. */\n    chrome: 200,\n    /** `PUTilingViewSettings -transitionChromeDelay` = 0: the chrome arrives with the zoom, not after it. */\n    chromeDelay: 0,\n    /** `PUOneUpSettings -finalFadeOutDuration` = 0.2. Used when there is no tile to zoom out of. */\n    fade: 200,\n    /** `PUOneUpSettings -bounceDuration` = 0.5 with `-bounceSpringDamping` = 1, i.e. critically damped, no overshoot. The snap back from an overscrolled pan. */\n    bounce: 500,\n    /** Paging under its own steam, e.g. an arrow key. `PUTilingViewSettings -springAnimationDuration`. */\n    page: 300,\n    /** `ChatKit.CKUIBehaviorPhone -tapbackDismissalDuration` = 0.5: how long the picker takes to leave. */\n    tapbackDismiss: 500,\n  },\n  /**\n   * The easing on the zoom. `PUTilingViewSettings` says spring, overshoot allowed\n   * (`-useOvershootingSpringAnimations` = 1), but carries no stiffness or damping to read, so this is\n   * the standard curve the rest of the kit uses. JUDGEMENT.\n   */\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n  /**\n   * The iOS safe area, from SPEC: the status bar occupies y 0-54 and no gesture bar appears in any\n   * capture. A device that shows one passes its own inset through `safeArea`.\n   */\n  safeArea: { ios: { top: 54, bottom: 0 }, macos: { top: 0, bottom: 0 } },\n  /**\n   * The wash under each bar, so white ink stays legible over a bright photo. JUDGEMENT: the theme gives\n   * an opaque black ground for the chrome, which is the colour behind the bars once the photo is\n   * letterboxed, but nothing says what covers the photo itself.\n   */\n  barScrim: \"rgba(0,0,0,0.55)\",\n} as const;\n\nexport type ImageViewerRect = { x: number; y: number; width: number; height: number };\nexport type ImageViewerPhoto = { src: string; alt?: string; width?: number; height?: number };\nexport type ImageViewerSize = { width: number; height: number };\n\nexport type ImageViewerProps = Omit<ComponentProps<\"div\">, \"children\" | \"onSelect\"> & {\n  /** The photos of the message that was tapped. Paging runs over exactly these. */\n  photos: ImageViewerPhoto[];\n  /** Which one is showing. Controlled when given, otherwise the viewer keeps its own. */\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  /**\n   * The tapped tile's box in the viewer's OWN coordinates, i.e. relative to the element this viewer\n   * fills. A caller holding a viewport `DOMRect` (which is what `MessageImages onOpenImage` hands over)\n   * subtracts the viewer host's own `getBoundingClientRect()` first. The open zoom starts here and the\n   * exit returns to it; null falls back to a fade, which is what a viewer opened from a keyboard or a\n   * deep link should do.\n   */\n  sourceRect?: ImageViewerRect | null;\n  /** The tile's corner radius, so the zoom starts with the bubble's corner and squares off on the way out. */\n  sourceRadius?: number;\n  /** False plays the exit and then calls `onExited`. Derive it during render, never in an effect. */\n  open?: boolean;\n  onExited?: () => void;\n  onClose?: () => void;\n  /** Seek the entrance to this fraction (0..1) instead of playing it, and freeze every transition. */\n  progress?: number;\n  /** Header lines: who sent it above, when below. */\n  title?: string;\n  subtitle?: string;\n  /** Show \"2 of 5\" beside the subtitle. Off by default: neither `PUOneUpSettings` nor QuickLook has a page indicator. */\n  pageIndicator?: boolean;\n  /** Header and footer visibility. Controlled when given; tapping the photo toggles it either way. */\n  chrome?: boolean;\n  onChromeChange?: (visible: boolean) => void;\n  /** Fit is 1. Controlled when given. */\n  zoom?: number;\n  onZoomChange?: (zoom: number) => void;\n  /** The reaction on the photo that is showing, and the picker over it. */\n  reaction?: TapbackSelection | null;\n  onReact?: (selection: TapbackSelection) => void;\n  tapbackOpen?: boolean;\n  onTapbackOpenChange?: (open: boolean) => void;\n  recent?: string[];\n  onShare?: () => void;\n  onDelete?: () => void;\n  onReply?: () => void;\n  /** Insets the chrome stays inside. Defaults to the platform's measured ones. */\n  safeArea?: { top?: number; bottom?: number };\n  /** The viewer's own size before it can measure itself, e.g. during a server render. */\n  frame?: ImageViewerSize;\n  platform?: Platform;\n};\n\n/** The largest box of `aspect` that fits inside `frame`, centred. Native fits a photo, it does not fill. */\nexport function fitPhotoRect(aspect: number, frame: ImageViewerSize): ImageViewerRect {\n  const width = Math.min(frame.width, frame.height * aspect);\n  const height = width / aspect;\n  return { x: (frame.width - width) / 2, y: (frame.height - height) / 2, width, height };\n}\n\n/**\n * UIScrollView's resistance: the first point of overscroll moves almost one for one and the curve\n * flattens from there, never passing `c * dimension`.\n */\nexport function rubberBand(distance: number, dimension: number, c: number = imageViewerMetrics.rubberBand): number {\n  if (!dimension) return 0;\n  const magnitude = Math.abs(distance);\n  return Math.sign(distance) * (1 - 1 / (magnitude / (c * dimension) + 1)) * c * dimension;\n}\n\nfunction clamp(value: number, low: number, high: number) {\n  return Math.min(high, Math.max(low, value));\n}\n\nfunction prefersReducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n}\n\n/**\n * The two poses of the open zoom, in the zoom layer's own coordinates with `transform-origin: 0 0`.\n * The end pose is the identity, because the photo is already fitted in the frame. The start pose scales\n * the whole frame down until the fitted photo covers `source` exactly, clips away everything outside\n * it, and divides the tile's corner radius by the same scale so it lands at `radius` on screen. Both\n * poses are `inset()` clips of the same shape, which is what makes the pair animatable.\n */\nexport function zoomPose(source: ImageViewerRect | null, fit: ImageViewerRect, frame: ImageViewerSize, radius: number) {\n  const settled = { transform: \"translate(0px, 0px) scale(1)\", clipPath: \"inset(0px 0px 0px 0px round 0px)\", opacity: 1 };\n  if (!source || !fit.width || !fit.height) {\n    return { start: { transform: \"translate(0px, 0px) scale(0.94)\", clipPath: settled.clipPath, opacity: 0 }, end: settled };\n  }\n  const scale = Math.max(source.width / fit.width, source.height / fit.height);\n  const tx = source.x + source.width / 2 - scale * (fit.x + fit.width / 2);\n  const ty = source.y + source.height / 2 - scale * (fit.y + fit.height / 2);\n  const left = (source.x - tx) / scale;\n  const top = (source.y - ty) / scale;\n  const width = source.width / scale;\n  const height = source.height / scale;\n  return {\n    start: {\n      transform: `translate(${tx}px, ${ty}px) scale(${scale})`,\n      clipPath: `inset(${top}px ${frame.width - left - width}px ${frame.height - top - height}px ${left}px round ${radius / scale}px)`,\n      opacity: 1,\n    },\n    end: settled,\n  };\n}\n\n/** SF Symbol \"square.and.arrow.up\" look-alike. */\nfunction ShareIcon({ size }: { size: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 21 21\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M10.5 1.6v11.2M6.9 5.1l3.6-3.5 3.6 3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      <path d=\"M5.6 8.1H4.3a1.9 1.9 0 0 0-1.9 1.9v7.6a1.9 1.9 0 0 0 1.9 1.9h12.4a1.9 1.9 0 0 0 1.9-1.9V10a1.9 1.9 0 0 0-1.9-1.9h-1.3\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** SF Symbol \"trash\" look-alike. */\nfunction TrashIcon({ size }: { size: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 21 21\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M3.4 5.3h14.2M8.2 5.3V3.9a1.3 1.3 0 0 1 1.3-1.3h2a1.3 1.3 0 0 1 1.3 1.3v1.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      <path d=\"M5.2 5.3l.8 11.4a1.9 1.9 0 0 0 1.9 1.8h5.2a1.9 1.9 0 0 0 1.9-1.8l.8-11.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      <path d=\"M8.9 8.6v6.6M12.1 8.6v6.6\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** SF Symbol \"arrowshape.turn.up.left\" look-alike, the reply control `CKQLPreviewController -replyButton` puts in the bars. */\nfunction ReplyIcon({ size }: { size: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 21 21\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M8.6 4.2 2.9 9.3l5.7 5.1v-2.9c4 0 6.7 1.1 8.4 4.3.3-5.1-2.1-8.3-8.4-8.7V4.2Z\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\ntype Point = { x: number; y: number };\n\ntype Gesture = {\n  pointers: Map<number, Point>;\n  mode: \"none\" | \"undecided\" | \"page\" | \"dismiss\" | \"pan\" | \"pinch\";\n  start: Point;\n  startPan: Point;\n  startZoom: number;\n  startSpread: number;\n  startMid: Point;\n  last: Point;\n  lastTime: number;\n  velocity: Point;\n  tapTime: number;\n  tapPoint: Point;\n};\n\nfunction spreadOf(points: Point[]) {\n  return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);\n}\n\nfunction midpointOf(points: Point[]): Point {\n  return { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };\n}\n\nexport function ImageViewer({\n  photos,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  sourceRect = null,\n  sourceRadius,\n  open = true,\n  onExited,\n  onClose,\n  progress,\n  title,\n  subtitle,\n  pageIndicator = false,\n  chrome: chromeProp,\n  onChromeChange,\n  zoom: zoomProp,\n  onZoomChange,\n  reaction = null,\n  onReact,\n  tapbackOpen: tapbackOpenProp,\n  onTapbackOpenChange,\n  recent,\n  onShare,\n  onDelete,\n  onReply,\n  safeArea,\n  frame: frameProp,\n  platform: platformProp,\n  className,\n  style,\n  ...props\n}: ImageViewerProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = imageViewerMetrics;\n  const bars = m.bars[platform];\n  const inset = { ...m.safeArea[platform], ...safeArea };\n  const radius = sourceRadius ?? bubbleMetrics[platform].radius;\n  const scrubbed = progress !== undefined;\n  const count = photos.length;\n\n  const root = useRef<HTMLDivElement>(null);\n  const backdrop = useRef<HTMLDivElement>(null);\n  const zoomLayer = useRef<HTMLDivElement>(null);\n  const chromeLayer = useRef<HTMLDivElement>(null);\n  const tapbackButton = useRef<HTMLButtonElement>(null);\n  const exited = useRef(false);\n  const chromeTimer = useRef<number | undefined>(undefined);\n\n  // The viewer lays out in its own coordinates, so it has to know its own box before it can fit a photo\n  // or read `sourceRect`. `frame` covers the render before the observer has fired.\n  const [measuredFrame, setMeasuredFrame] = useState<ImageViewerSize | null>(null);\n  const frame = useMemo(\n    () => measuredFrame ?? frameProp ?? (platform === \"ios\" ? { width: 402, height: 874 } : { width: 960, height: 640 }),\n    [measuredFrame, frameProp, platform],\n  );\n  useLayoutEffect(() => {\n    const node = root.current;\n    if (!node) return;\n    const sync = () => setMeasuredFrame({ width: node.clientWidth, height: node.clientHeight });\n    sync();\n    const observer = new ResizeObserver(sync);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const [internalIndex, setInternalIndex] = useState(defaultIndex);\n  const index = clamp(indexProp ?? internalIndex, 0, Math.max(0, count - 1));\n  const setIndex = useCallback(\n    (next: number) => {\n      const bounded = clamp(next, 0, Math.max(0, count - 1));\n      if (indexProp === undefined) setInternalIndex(bounded);\n      onIndexChange?.(bounded);\n    },\n    [indexProp, onIndexChange, count],\n  );\n\n  const [internalChrome, setInternalChrome] = useState(true);\n  const chromeVisible = chromeProp ?? internalChrome;\n  const setChrome = useCallback(\n    (next: boolean) => {\n      if (chromeProp === undefined) setInternalChrome(next);\n      onChromeChange?.(next);\n    },\n    [chromeProp, onChromeChange],\n  );\n\n  const [internalZoom, setInternalZoom] = useState(1);\n  const zoom = clamp(zoomProp ?? internalZoom, 1, m.maxZoom);\n  const setZoom = useCallback(\n    (next: number) => {\n      const bounded = clamp(next, 1, m.maxZoom);\n      if (zoomProp === undefined) setInternalZoom(bounded);\n      onZoomChange?.(bounded);\n    },\n    [zoomProp, onZoomChange, m.maxZoom],\n  );\n\n  const [internalTapback, setInternalTapback] = useState(false);\n  const tapbackOpen = tapbackOpenProp ?? internalTapback;\n  const setTapbackOpen = useCallback(\n    (next: boolean) => {\n      if (tapbackOpenProp === undefined) setInternalTapback(next);\n      onTapbackOpenChange?.(next);\n    },\n    [tapbackOpenProp, onTapbackOpenChange],\n  );\n  // The same derive-during-render rule the shells use for the long-press overlay: the picker has to\n  // outlive the flag that opened it, because an effect would leave one committed frame with it already\n  // gone and its dismissal would never be seen.\n  const [seenTapbackOpen, setSeenTapbackOpen] = useState(tapbackOpen);\n  const [tapbackClosing, setTapbackClosing] = useState(false);\n  if (seenTapbackOpen !== tapbackOpen) {\n    setSeenTapbackOpen(tapbackOpen);\n    setTapbackClosing(!tapbackOpen && seenTapbackOpen);\n  }\n\n  // Where the footer's tapback button actually sits, so the picker's thought bubble points at it\n  // rather than at the middle of the bar: the footer spreads its controls, and how many it has depends\n  // on whether the caller wired a reply handler.\n  const [tapbackCentre, setTapbackCentre] = useState<number | null>(null);\n  useLayoutEffect(() => {\n    const node = tapbackButton.current;\n    if (!node) return;\n    const sync = () => setTapbackCentre(node.offsetLeft + node.offsetWidth / 2);\n    sync();\n    const observer = new ResizeObserver(sync);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [frame.width]);\n\n  const [pan, setPan] = useState<Point>({ x: 0, y: 0 });\n  const [dragX, setDragX] = useState(0);\n  const [drop, setDrop] = useState<Point>({ x: 0, y: 0 });\n  const [settling, setSettling] = useState(false);\n  const [aspects, setAspects] = useState<Record<string, number>>({});\n\n  const aspectOf = useCallback(\n    (photo: ImageViewerPhoto | undefined) => (photo && photo.width && photo.height ? photo.width / photo.height : (photo && aspects[photo.src]) || 4 / 3),\n    [aspects],\n  );\n  const fit = useMemo(() => fitPhotoRect(aspectOf(photos[index]), frame), [aspectOf, photos, index, frame]);\n\n  const pitch = frame.width + m.interpageSpacing;\n  const trackX = -index * pitch + dragX;\n\n  // How far the drag has carried the dismissal: 0 at rest, 1 where it commits.\n  const dropProgress = clamp(Math.abs(drop.y) / m.dismiss.distance, 0, 1);\n  const dropScale = 1 - (1 - m.dismiss.minScale) * dropProgress;\n\n  /* ------------------------------------------------------------------ entrance and exit */\n\n  const pose = useMemo(() => zoomPose(sourceRect, fit, frame, radius), [sourceRect, fit, frame, radius]);\n  // The keyframes are read from a ref inside the timeline effect, so a re-render that only moves the\n  // page or the pan cannot restart the entrance mid-flight.\n  const poseRef = useRef(pose);\n  // Written in a layout effect rather than in render: layout effects all run before the passive effect\n  // below, so the timeline still reads the pose this render computed.\n  useLayoutEffect(() => {\n    poseRef.current = pose;\n  });\n  // `sourceRect` is a fresh object on every parent render, so the timeline keys off its values instead.\n  const sourceKey = sourceRect ? `${sourceRect.x},${sourceRect.y},${sourceRect.width},${sourceRect.height}` : \"\";\n\n  // Callers normally pass `onExited` inline, and it must not be an effect dependency: a parent\n  // re-render mid-exit would cancel the exit and start it again, and it would never finish.\n  const onExitedRef = useRef(onExited);\n  useLayoutEffect(() => {\n    onExitedRef.current = onExited;\n  });\n\n  useEffect(() => {\n    exited.current = false;\n    return () => window.clearTimeout(chromeTimer.current);\n  }, []);\n\n  useEffect(() => {\n    const stage = zoomLayer.current;\n    const ground = backdrop.current;\n    const chromeNode = chromeLayer.current;\n    if (!stage || !ground) return;\n    const reduced = prefersReducedMotion();\n    const duration = sourceKey ? m.timing.zoom : m.timing.fade;\n    const { start, end } = poseRef.current;\n\n    const finish = () => {\n      if (exited.current) return;\n      exited.current = true;\n      onExitedRef.current?.();\n    };\n\n    if (open) {\n      if (reduced && !scrubbed) return;\n      const running = [\n        stage.animate([start, end], { duration, easing: m.ease, fill: \"both\" }),\n        ground.animate([{ opacity: 0 }, { opacity: 1 }], { duration: m.timing.backdrop, easing: \"linear\", fill: \"both\" }),\n        chromeNode?.animate([{ opacity: 0 }, { opacity: 1 }], { duration, delay: m.timing.chromeDelay, easing: \"linear\", fill: \"both\" }),\n      ].filter(Boolean) as Animation[];\n      if (scrubbed) {\n        // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n        const at = clamp(progress ?? 0, 0, 1) * duration;\n        for (const animation of running) {\n          animation.pause();\n          animation.currentTime = Math.min(at, Number(animation.effect?.getTiming().duration ?? duration));\n        }\n      }\n      return () => running.forEach(animation => animation.cancel());\n    }\n\n    if (reduced) {\n      finish();\n      return;\n    }\n    // The exit reverses the entrance: back down into the tile it came out of, with the ground fading\n    // out under it.\n    const running = [\n      stage.animate([end, start], { duration, easing: m.ease, fill: \"forwards\" }),\n      ground.animate([{ opacity: 1 }, { opacity: 0 }], { duration, easing: \"linear\", fill: \"forwards\" }),\n      chromeNode?.animate([{ opacity: 1 }, { opacity: 0 }], { duration: m.timing.chrome, easing: \"linear\", fill: \"forwards\" }),\n    ].filter(Boolean) as Animation[];\n    running[0].addEventListener(\"finish\", finish);\n    return () => {\n      running[0].removeEventListener(\"finish\", finish);\n      running.forEach(animation => animation.cancel());\n    };\n  }, [open, scrubbed, progress, sourceKey, m.ease, m.timing.zoom, m.timing.fade, m.timing.backdrop, m.timing.chrome, m.timing.chromeDelay]);\n\n  // Modal, so it takes focus when it opens. A scrubbed entrance does not: the harness seeks frames and\n  // must not move the caret.\n  useEffect(() => {\n    if (!open || scrubbed) return;\n    root.current?.focus({ preventScroll: true });\n  }, [open, scrubbed]);\n\n  /* ------------------------------------------------------------------ zoom, pan and paging */\n\n  const panLimit = useCallback(\n    (currentZoom: number) => ({\n      x: Math.max(0, (fit.width * currentZoom - frame.width) / 2),\n      y: Math.max(0, (fit.height * currentZoom - frame.height) / 2),\n    }),\n    [fit.width, fit.height, frame.width, frame.height],\n  );\n\n  const settle = useCallback(\n    (nextZoom: number, nextPan: Point) => {\n      const limit = panLimit(nextZoom);\n      setSettling(true);\n      setZoom(nextZoom);\n      setPan({ x: clamp(nextPan.x, -limit.x, limit.x), y: clamp(nextPan.y, -limit.y, limit.y) });\n    },\n    [panLimit, setZoom],\n  );\n\n  const zoomAbout = useCallback(\n    (nextZoom: number, point: Point) => {\n      // Keep whatever is under the finger under the finger: the photo's centre moves by the change in\n      // scale taken about that point.\n      const centre = { x: frame.width / 2, y: frame.height / 2 };\n      const ratio = nextZoom / zoom;\n      const next = {\n        x: point.x - centre.x - ratio * (point.x - centre.x - pan.x),\n        y: point.y - centre.y - ratio * (point.y - centre.y - pan.y),\n      };\n      settle(nextZoom, nextZoom <= 1 ? { x: 0, y: 0 } : next);\n    },\n    [frame.width, frame.height, pan.x, pan.y, zoom, settle],\n  );\n\n  const page = useCallback(\n    (delta: number) => {\n      const next = index + delta;\n      if (next < 0 || next > count - 1) return;\n      setSettling(true);\n      setDragX(0);\n      setPan({ x: 0, y: 0 });\n      setZoom(1);\n      setIndex(next);\n    },\n    [index, count, setIndex, setZoom],\n  );\n\n  /* ------------------------------------------------------------------ gestures */\n\n  const gesture = useRef<Gesture>({\n    pointers: new Map(),\n    mode: \"none\",\n    start: { x: 0, y: 0 },\n    startPan: { x: 0, y: 0 },\n    startZoom: 1,\n    startSpread: 0,\n    startMid: { x: 0, y: 0 },\n    last: { x: 0, y: 0 },\n    lastTime: 0,\n    velocity: { x: 0, y: 0 },\n    tapTime: 0,\n    tapPoint: { x: 0, y: 0 },\n  });\n\n  const localPoint = useCallback((clientX: number, clientY: number): Point => {\n    const box = root.current?.getBoundingClientRect();\n    return { x: clientX - (box?.left ?? 0), y: clientY - (box?.top ?? 0) };\n  }, []);\n\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n    const g = gesture.current;\n    const point = localPoint(event.clientX, event.clientY);\n    g.pointers.set(event.pointerId, point);\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setSettling(false);\n    if (g.pointers.size === 2) {\n      const points = [...g.pointers.values()];\n      g.mode = \"pinch\";\n      g.startSpread = spreadOf(points) || 1;\n      g.startMid = midpointOf(points);\n      g.startZoom = zoom;\n      g.startPan = { ...pan };\n      return;\n    }\n    if (g.pointers.size > 2) return;\n    // Always undecided to begin with, even zoomed in: a tap on a zoomed photo still has to reach the\n    // double-tap and chrome paths, so the pan only starts once the pointer has moved past the slop.\n    g.mode = \"undecided\";\n    g.start = point;\n    g.startPan = { ...pan };\n    g.last = point;\n    g.lastTime = event.timeStamp;\n    g.velocity = { x: 0, y: 0 };\n  }\n\n  function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    const g = gesture.current;\n    if (!g.pointers.has(event.pointerId)) return;\n    const point = localPoint(event.clientX, event.clientY);\n    g.pointers.set(event.pointerId, point);\n\n    if (g.mode === \"pinch\") {\n      const points = [...g.pointers.values()].slice(0, 2);\n      if (points.length < 2) return;\n      const next = clamp((spreadOf(points) / g.startSpread) * g.startZoom, 1, m.maxZoom);\n      const mid = midpointOf(points);\n      const centre = { x: frame.width / 2, y: frame.height / 2 };\n      const ratio = next / g.startZoom;\n      setZoom(next);\n      setPan({\n        x: mid.x - centre.x - ratio * (g.startMid.x - centre.x - g.startPan.x),\n        y: mid.y - centre.y - ratio * (g.startMid.y - centre.y - g.startPan.y),\n      });\n      return;\n    }\n\n    const dx = point.x - g.start.x;\n    const dy = point.y - g.start.y;\n    const dt = Math.max(1, event.timeStamp - g.lastTime);\n    g.velocity = { x: ((point.x - g.last.x) / dt) * 1000, y: ((point.y - g.last.y) / dt) * 1000 };\n    g.last = point;\n    g.lastTime = event.timeStamp;\n\n    if (g.mode === \"undecided\") {\n      if (Math.hypot(dx, dy) < m.slop) return;\n      // A zoomed photo pans in both axes; at fit, sideways is paging and downwards is a dismissal.\n      g.mode = zoom > 1 ? \"pan\" : Math.abs(dx) > Math.abs(dy) ? \"page\" : \"dismiss\";\n    }\n\n    if (g.mode === \"page\") {\n      // Rubber band at the two ends, where there is no page to bring on.\n      const atStart = index === 0 && dx > 0;\n      const atEnd = index === count - 1 && dx < 0;\n      setDragX(atStart || atEnd ? rubberBand(dx, frame.width) : dx);\n      return;\n    }\n\n    if (g.mode === \"dismiss\") {\n      setDrop({ x: dx, y: dy });\n      return;\n    }\n\n    if (g.mode === \"pan\") {\n      const limit = panLimit(zoom);\n      const wanted = { x: g.startPan.x + dx, y: g.startPan.y + dy };\n      const band = (value: number, edge: number, dimension: number) =>\n        Math.abs(value) > edge ? Math.sign(value) * edge + rubberBand(value - Math.sign(value) * edge, dimension) : value;\n      setPan({ x: band(wanted.x, limit.x, frame.width), y: band(wanted.y, limit.y, frame.height) });\n    }\n  }\n\n  function endGesture(event: ReactPointerEvent<HTMLDivElement>) {\n    const g = gesture.current;\n    const point = g.pointers.get(event.pointerId);\n    if (!point) return;\n    g.pointers.delete(event.pointerId);\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n\n    if (g.mode === \"pinch\") {\n      if (g.pointers.size >= 2) return;\n      g.mode = \"none\";\n      g.pointers.clear();\n      const collapsed = zoom < 1.05;\n      settle(collapsed ? 1 : zoom, collapsed ? { x: 0, y: 0 } : pan);\n      return;\n    }\n\n    const mode = g.mode;\n    g.mode = \"none\";\n\n    if (mode === \"page\") {\n      const commit = Math.abs(dragX) > frame.width * m.pageCommit.distance || Math.abs(g.velocity.x) > m.pageCommit.velocity;\n      setSettling(true);\n      setDragX(0);\n      if (commit) page(dragX < 0 ? 1 : -1);\n      return;\n    }\n\n    if (mode === \"dismiss\") {\n      if (drop.y > m.dismiss.distance || g.velocity.y > m.dismiss.velocity) {\n        onClose?.();\n        return;\n      }\n      setSettling(true);\n      setDrop({ x: 0, y: 0 });\n      return;\n    }\n\n    if (mode === \"pan\") {\n      settle(zoom, pan);\n      return;\n    }\n\n    if (mode !== \"undecided\") return;\n\n    // A tap. Two in a row toggle the zoom; one on its own toggles the chrome, after waiting out the\n    // window in which a second tap could still arrive.\n    const doubled = event.timeStamp - g.tapTime < m.doubleTap.window && Math.hypot(point.x - g.tapPoint.x, point.y - g.tapPoint.y) < m.doubleTap.slop;\n    g.tapTime = doubled ? 0 : event.timeStamp;\n    g.tapPoint = point;\n    window.clearTimeout(chromeTimer.current);\n    if (doubled) {\n      if (m.allowDoubleTapZoom) zoomAbout(zoom > 1 ? 1 : m.doubleTapZoom, point);\n      return;\n    }\n    if (!m.allowChromeHiding) return;\n    const stamp = event.timeStamp;\n    chromeTimer.current = window.setTimeout(() => {\n      if (gesture.current.tapTime === stamp) setChrome(!chromeVisible);\n    }, m.doubleTap.window);\n  }\n\n  // A trackpad pinch arrives as a ctrl-wheel, which is how a photo zooms on macOS. React attaches\n  // `wheel` passively, so preventing the page from zooming needs a listener of our own.\n  useEffect(() => {\n    const node = zoomLayer.current;\n    if (!node) return;\n    const onWheel = (event: WheelEvent) => {\n      if (!event.ctrlKey) return;\n      event.preventDefault();\n      zoomAbout(clamp(zoom * (1 - event.deltaY / 100), 1, m.maxZoom), localPoint(event.clientX, event.clientY));\n    };\n    node.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => node.removeEventListener(\"wheel\", onWheel);\n  }, [zoom, zoomAbout, localPoint, m.maxZoom]);\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      if (tapbackOpen) {\n        setTapbackOpen(false);\n        root.current?.focus({ preventScroll: true });\n      } else onClose?.();\n      return;\n    }\n    if ((event.key === \"ArrowRight\" || event.key === \"ArrowLeft\") && zoom === 1) {\n      event.preventDefault();\n      page(event.key === \"ArrowRight\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"+\" || event.key === \"=\") {\n      event.preventDefault();\n      zoomAbout(clamp(zoom * 1.5, 1, m.maxZoom), { x: frame.width / 2, y: frame.height / 2 });\n      return;\n    }\n    if (event.key === \"-\" || event.key === \"_\") {\n      event.preventDefault();\n      zoomAbout(clamp(zoom / 1.5, 1, m.maxZoom), { x: frame.width / 2, y: frame.height / 2 });\n      return;\n    }\n    if (event.key === \"0\") {\n      event.preventDefault();\n      settle(1, { x: 0, y: 0 });\n    }\n  }\n\n  /* ------------------------------------------------------------------ chrome geometry */\n\n  const headerHeight = inset.top + bars.header;\n  const footerHeight = inset.bottom + bars.footer;\n  // The picker floats above the footer far enough that its thought bubble, which hangs half a Ø44 blob\n  // below the pill, lands on the tapback button rather than under it.\n  const pickerBottom = footerHeight + m.barsOutset + pickerBalloonGeometry.main / 2;\n  const pickerInset = tapbackBarMetrics.ios.edgeInset;\n\n  // The balloon's measured slot hangs it outside the bubble's trailing edge, which works over a bubble\n  // and does not over a photo whose trailing edge can be the screen's. Keep the slot, then pull it back\n  // inside the frame by the same edge inset a bubble keeps, and below the header.\n  const balloon = balloonSlot[platform];\n  const balloonSize = balloonGeometry[platform].main;\n  const balloonLeft = Math.min(fit.x + fit.width + balloon.side, frame.width - balloonSize - bubbleMetrics[platform].edgeInset);\n  const balloonTop = Math.max(headerHeight + m.barsOutset, fit.y + balloon.top);\n\n  const chromeAlpha = chromeVisible && dropProgress === 0 ? 1 : 0;\n  const barTransition = scrubbed ? \"none\" : `opacity ${m.timing.chrome}ms linear`;\n  const trackTransition = scrubbed || !settling ? \"none\" : `transform ${m.timing.page}ms ${m.ease}`;\n  const photoTransition = scrubbed || !settling ? \"none\" : `transform ${m.timing.bounce}ms ${m.ease}`;\n\n  const ink = m.chromeInk;\n  // The ground under the picker is measured black in both themes, so its glass takes the dark tokens\n  // whatever the page's theme is.\n  const vars = tapbackVars(\"dark\", platform) as CSSProperties;\n\n  const barButton: CSSProperties = {\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    border: 0,\n    background: \"transparent\",\n    color: ink,\n    padding: 0,\n    fontFamily: \"inherit\",\n    cursor: \"default\",\n    minWidth: bars.footer,\n    height: bars.footer,\n  };\n\n  const photo = photos[index];\n  const position = count > 1 ? `attachment ${index + 1} of ${count}` : \"\";\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"image-viewer\"\n      data-platform={platform}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={title ?? \"Photo\"}\n      tabIndex={-1}\n      onKeyDown={onKeyDown}\n      className={cn(\"absolute inset-0 select-none overflow-hidden outline-none\", className)}\n      style={{ fontFamily: fontStack, touchAction: \"none\", ...vars, ...style }}\n      {...props}\n    >\n      {/* The ground: measured black in both themes, so it does not follow the palette. */}\n      <div\n        ref={backdrop}\n        aria-hidden=\"true\"\n        data-slot=\"viewer-ground\"\n        style={{ position: \"absolute\", inset: 0, background: m.ground, opacity: 1 - dropProgress }}\n      />\n\n      <div ref={zoomLayer} data-slot=\"viewer-zoom\" style={{ position: \"absolute\", inset: 0, transformOrigin: \"0 0\" }}>\n        <div\n          data-slot=\"viewer-stage\"\n          role=\"group\"\n          aria-label={[\"Photo\", position].filter(Boolean).join(\", \")}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={endGesture}\n          onPointerCancel={endGesture}\n          style={{ position: \"absolute\", inset: 0 }}\n        >\n          <div\n            data-slot=\"viewer-track\"\n            style={{ position: \"absolute\", inset: 0, transform: `translateX(${trackX}px)`, transition: trackTransition, willChange: \"transform\" }}\n          >\n            {photos.map((item, i) => {\n              const active = i === index;\n              // Every page's photo lags its own page by 1/parallaxFactor of that page's offset, so a\n              // swipe does not slide the whole strip as one sheet. At rest the active page's is 0.\n              const parallax = -(trackX + i * pitch) / m.parallaxFactor;\n              const box = fitPhotoRect(aspectOf(item), frame);\n              const offset = active\n                ? { x: pan.x + drop.x + parallax, y: pan.y + drop.y, scale: zoom * dropScale }\n                : { x: parallax, y: 0, scale: 1 };\n              return (\n                <div key={`${item.src}-${i}`} data-slot=\"viewer-page\" style={{ position: \"absolute\", left: i * pitch, top: 0, width: frame.width, height: frame.height }}>\n                  <div\n                    data-slot=\"viewer-photo\"\n                    style={{\n                      position: \"absolute\",\n                      left: box.x,\n                      top: box.y,\n                      width: box.width,\n                      height: box.height,\n                      transform: `translate(${offset.x}px, ${offset.y}px) scale(${offset.scale})`,\n                      transformOrigin: \"center center\",\n                      transition: active ? photoTransition : \"none\",\n                      willChange: \"transform\",\n                    }}\n                  >\n                    {/* eslint-disable-next-line @next/next/no-img-element */}\n                    <img\n                      src={item.src}\n                      alt={item.alt ?? \"Photo\"}\n                      draggable={false}\n                      decoding=\"async\"\n                      onLoad={event => {\n                        const image = event.currentTarget;\n                        if (!image.naturalWidth || !image.naturalHeight) return;\n                        setAspects(known => (known[item.src] ? known : { ...known, [item.src]: image.naturalWidth / image.naturalHeight }));\n                      }}\n                      style={{ width: \"100%\", height: \"100%\", objectFit: \"contain\", display: \"block\", pointerEvents: \"none\" }}\n                    />\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      <div ref={chromeLayer} data-slot=\"viewer-chrome\" style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\" }}>\n        {/* A reaction on the photo: the same balloon a bubble carries, on the photo's top trailing\n            corner and clamped so it never rides under the header or off the frame. The slot is the\n            measured bubble one, because nothing captures a balloon on a full-screen photo, so its\n            placement here is JUDGEMENT. It lives in the chrome layer so it fades up with the rest of\n            the overlay instead of hanging in the air while the photo is still zooming. */}\n        {reaction && (\n          <div data-slot=\"viewer-reaction\" style={{ position: \"absolute\", left: balloonLeft, top: balloonTop, opacity: 1 - dropProgress }}>\n            <Tapback\n              platform={platform}\n              side=\"left\"\n              own\n              reaction={\"type\" in reaction ? reaction.type : undefined}\n              emoji={\"emoji\" in reaction ? reaction.emoji : undefined}\n            />\n          </div>\n        )}\n\n        <div\n          data-slot=\"viewer-header\"\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            top: 0,\n            height: headerHeight,\n            paddingTop: inset.top,\n            display: \"flex\",\n            alignItems: \"center\",\n            color: ink,\n            opacity: chromeAlpha,\n            transition: barTransition,\n            pointerEvents: chromeAlpha ? \"auto\" : \"none\",\n            background: `linear-gradient(to bottom, ${m.barScrim}, transparent)`,\n          }}\n        >\n          <button\n            type=\"button\"\n            data-slot=\"viewer-done\"\n            onClick={onClose}\n            style={{ ...barButton, height: bars.header, paddingInline: 16, fontSize: m.button.fontSize, fontWeight: m.button.weight }}\n          >\n            Done\n          </button>\n          <div\n            data-slot=\"viewer-title\"\n            style={{ position: \"absolute\", left: 0, right: 0, top: inset.top, height: bars.header, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", justifyContent: \"center\", pointerEvents: \"none\" }}\n          >\n            <span style={{ fontSize: m.title.fontSize, fontWeight: m.title.weight, lineHeight: 1.2, color: ink }}>{title ?? \"Photo\"}</span>\n            {(subtitle || (pageIndicator && count > 1)) && (\n              <span style={{ display: \"flex\", gap: 6, fontSize: m.subtitle.fontSize, fontWeight: m.subtitle.weight, lineHeight: 1.2, color: ink, opacity: m.subtitle.opacity }}>\n                {subtitle && <span>{subtitle}</span>}\n                {pageIndicator && count > 1 && <span>{`${index + 1} of ${count}`}</span>}\n              </span>\n            )}\n          </div>\n        </div>\n\n        <div\n          data-slot=\"viewer-footer\"\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            bottom: 0,\n            height: footerHeight,\n            paddingBottom: inset.bottom,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"space-between\",\n            paddingInline: 16,\n            color: ink,\n            opacity: chromeAlpha,\n            transition: barTransition,\n            pointerEvents: chromeAlpha ? \"auto\" : \"none\",\n            background: `linear-gradient(to top, ${m.barScrim}, transparent)`,\n          }}\n        >\n          <button type=\"button\" data-slot=\"viewer-share\" aria-label=\"Share\" onClick={onShare} style={barButton}>\n            <ShareIcon size={m.glyph} />\n          </button>\n          {onReply && (\n            <button type=\"button\" data-slot=\"viewer-reply\" aria-label=\"Reply\" onClick={onReply} style={barButton}>\n              <ReplyIcon size={m.glyph} />\n            </button>\n          )}\n          <button\n            type=\"button\"\n            ref={tapbackButton}\n            data-slot=\"viewer-tapback\"\n            aria-label={reaction ? \"Change reaction\" : \"Add a reaction\"}\n            aria-expanded={tapbackOpen}\n            onClick={() => setTapbackOpen(!tapbackOpen)}\n            style={barButton}\n          >\n            {reaction ? (\n              <TapbackGlyph type={\"type\" in reaction ? reaction.type : undefined} emoji={\"emoji\" in reaction ? reaction.emoji : undefined} size={m.glyph} />\n            ) : (\n              <SmileyIcon size={m.glyph} color={ink} strokeWidth={1.6} />\n            )}\n          </button>\n          <button type=\"button\" data-slot=\"viewer-delete\" aria-label=\"Delete\" onClick={onDelete} style={barButton}>\n            <TrashIcon size={m.glyph} />\n          </button>\n        </div>\n\n        {(tapbackOpen || tapbackClosing) && (\n          <ViewerTapbackPicker\n            platform={platform}\n            open={tapbackOpen}\n            scrubbed={scrubbed}\n            selected={reaction ?? undefined}\n            recent={recent}\n            width={frame.width - pickerInset * 2}\n            bottom={pickerBottom}\n            left={pickerInset}\n            pickerX={(tapbackCentre ?? frame.width / 2) - pickerInset}\n            onSelect={selection => {\n              onReact?.(selection);\n              setTapbackOpen(false);\n              root.current?.focus({ preventScroll: true });\n            }}\n            onClose={() => {\n              setTapbackOpen(false);\n              root.current?.focus({ preventScroll: true });\n            }}\n            onExited={() => setTapbackClosing(false)}\n          />\n        )}\n      </div>\n\n      <span aria-live=\"polite\" className=\"sr-only\">{[photo?.alt || \"Photo\", position].filter(Boolean).join(\", \")}</span>\n    </div>\n  );\n}\n\n/**\n * The reaction picker over a full-screen photo: the same `TapbackBar` a bubble gets, floated above the\n * footer instead of above a balloon, because a full-screen photo has no bubble to hang off.\n * `CKQLPreviewController -tapbackButtonFrameForFullScreenBalloonViewController:` anchors it on the\n * toolbar's tapback button and `-fullScreenBalloonViewControllerPickerViewUsesBottomTail:` points its\n * thought bubble down at that button, which is the placement here. Its dismissal takes\n * `CKUIBehaviorPhone -tapbackDismissalDuration`, 0.5 s. Its entrance has no framework value: JUDGEMENT,\n * the same 0.2 s the chrome fades in over.\n */\nfunction ViewerTapbackPicker({\n  platform,\n  open,\n  scrubbed,\n  selected,\n  recent,\n  width,\n  bottom,\n  left,\n  pickerX,\n  onSelect,\n  onClose,\n  onExited,\n}: {\n  platform: Platform;\n  open: boolean;\n  scrubbed: boolean;\n  selected?: TapbackSelection;\n  recent?: string[];\n  width: number;\n  bottom: number;\n  left: number;\n  pickerX: number;\n  onSelect: (selection: TapbackSelection) => void;\n  onClose: () => void;\n  onExited: () => void;\n}) {\n  const host = useRef<HTMLDivElement>(null);\n  const done = useRef(false);\n  const m = imageViewerMetrics;\n  // Same reason as the viewer's own exit: an inline `onExited` must not restart the dismissal.\n  const onExitedRef = useRef(onExited);\n  useLayoutEffect(() => {\n    onExitedRef.current = onExited;\n  });\n\n  useEffect(() => {\n    const node = host.current;\n    if (!node) return;\n    const reduced = prefersReducedMotion();\n    if (open) {\n      done.current = false;\n      if (reduced || scrubbed) return;\n      const animation = node.animate(\n        [\n          { opacity: 0, transform: \"translateY(8px) scale(0.92)\" },\n          { opacity: 1, transform: \"translateY(0px) scale(1)\" },\n        ],\n        { duration: m.timing.chrome, easing: m.ease, fill: \"both\" },\n      );\n      return () => animation.cancel();\n    }\n    const finish = () => {\n      if (done.current) return;\n      done.current = true;\n      onExitedRef.current();\n    };\n    if (reduced) {\n      finish();\n      return;\n    }\n    const animation = node.animate(\n      [\n        { opacity: 1, transform: \"translateY(0px) scale(1)\" },\n        { opacity: 0, transform: \"translateY(8px) scale(0.92)\" },\n      ],\n      { duration: m.timing.tapbackDismiss, easing: m.ease, fill: \"forwards\" },\n    );\n    animation.addEventListener(\"finish\", finish);\n    return () => {\n      animation.removeEventListener(\"finish\", finish);\n      animation.cancel();\n    };\n  }, [open, scrubbed, m.timing.chrome, m.timing.tapbackDismiss, m.ease]);\n\n  if (platform === \"macos\") {\n    // UNVERIFIED, like the rest of the macOS presentation: the two-row picker lives in a context menu\n    // natively, so over a photo it is given the menu's own surface as a floating panel, centred on the\n    // same tapback button the iOS pill points at. The panel keeps its own centring transform and the\n    // host carries the entrance, because one element cannot hold both: an animated `transform` replaces\n    // the inline one outright.\n    return (\n      <div\n        ref={host}\n        data-slot=\"viewer-tapback-picker\"\n        style={{ position: \"absolute\", bottom, left: 0, right: 0, height: 0, pointerEvents: \"none\", transformOrigin: `${left + pickerX}px 100%` }}\n      >\n        <div\n          data-slot=\"viewer-tapback-panel\"\n          style={{\n            position: \"absolute\",\n            bottom: 0,\n            left: left + pickerX,\n            transform: \"translateX(-50%)\",\n            borderRadius: 12,\n            background: \"var(--im-menu-bg, rgba(30,34,39,0.92))\",\n            boxShadow: \"0 12px 40px rgba(0,0,0,0.5), inset 0 0 0 0.5px var(--im-menu-rim, rgba(255,255,255,0.28))\",\n            backdropFilter: \"blur(30px) saturate(1.6)\",\n            WebkitBackdropFilter: \"blur(30px) saturate(1.6)\",\n            color: \"var(--im-menu-text, #dcddde)\",\n            pointerEvents: \"auto\",\n          }}\n        >\n          <TapbackBar layout=\"macos\" selected={selected} recent={recent} onSelect={onSelect} onClose={onClose} autoFocus={!scrubbed} />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={host}\n      data-slot=\"viewer-tapback-picker\"\n      style={{ position: \"absolute\", bottom, left, width, pointerEvents: \"auto\", transformOrigin: `${pickerX}px 100%` }}\n    >\n      <TapbackBar layout=\"ios\" selected={selected} recent={recent} onSelect={onSelect} onClose={onClose} autoFocus={!scrubbed} pickerX={pickerX} pickerSide=\"left\" width={width} />\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/image-viewer.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-audio",
      "title": "Audio message",
      "description": "A voice message bubble with a waveform, play control, and a seekable position.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/message-bubble.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-audio.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, fontStack, type Direction } from \"@/components/imessage/tokens\";\nimport { MessageBubble } from \"@/components/imessage/message-bubble\";\n\n/**\n * An audio message: a waveform, a play control, and the remaining time, inside a normal bubble.\n *\n * NOT MEASURED. No native capture of an audio message exists in `references/`, so the waveform's bar\n * width and spacing follow the documented look rather than a measurement, and the bar count is only\n * how many of those fit.\n *\n * What is not guessed:\n * - The bubble, its padding (iOS 10/13.85, macOS 7.08/12.5), radius and tail are the measured ones,\n *   because this renders inside `MessageBubble`.\n * - The row height and the play button's diameter are one number, not two: the button is the circle\n *   that fills the row exactly. The number itself is provisional.\n * - The duration label reuses a type size measured on its own platform: iOS 13pt (the \"Send with\n *   effect\" segmented-control label, the one measured step between the 11pt secondary label and the\n *   17pt body) and macOS 11pt (the attachment card's \"Text Document / 275 bytes\" line).\n * - The row's width is the sum of its parts, so the bubble hugs it the way a text bubble hugs its\n *   longest line. `maxWidth` is passed in px: the platform default is a percentage of the bubble's\n *   own shrink-to-fit box, which collapses for content that is not text.\n * - The bubble's line-hugging fit measures the laid-out line boxes, so the row has to be the widest\n *   of them. It is: one inline box holding everything, with no loose text beside it. The duration\n *   still contributes a second, narrower rect of its own, which the fit ignores.\n */\nexport type MessageAudioProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Bar heights, 0..1, oldest first. Pass real peaks, or leave it out for a deterministic stand-in. */\n  peaks?: number[];\n  /** Total length in seconds. */\n  duration: number;\n  direction?: Direction;\n  tail?: boolean;\n  /** Controlled playback position in seconds. */\n  position?: number;\n  playing?: boolean;\n  onPlayChange?: (playing: boolean) => void;\n  onSeek?: (seconds: number) => void;\n  /** Widest the bubble may grow, in px. Defaults to the platform's maximum bubble width. */\n  maxWidth?: number;\n  platform?: Platform;\n};\n\nfunction fallbackPeaks(count: number): number[] {\n  // Deterministic, so screenshots are stable and no Math.random leaks into a render.\n  return Array.from({ length: count }, (_, i) => {\n    const a = Math.sin(i * 0.7) * 0.5 + 0.5;\n    const b = Math.sin(i * 1.9 + 1.1) * 0.5 + 0.5;\n    return 0.18 + Math.min(1, a * 0.6 + b * 0.55) * 0.82;\n  });\n}\n\nfunction clock(seconds: number): string {\n  const whole = Math.max(0, Math.round(seconds));\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}`;\n}\n\nexport function MessageAudio({ peaks, duration, direction = \"outgoing\", tail = false, position = 0, playing = false, onPlayChange, onSeek, maxWidth, platform: platformProp, className, style, ...props }: MessageAudioProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const ios = platform === \"ios\";\n  // UNVERIFIED: the bar count, width and spacing have no capture behind them. The count is the most\n  // bars that fit inside the measured maximum bubble width at that width and spacing, so the row\n  // never has to break the measured 13.85 padding: iOS 280.5 - 27.7 padding - 28 button - 20 gaps -\n  // 28.13 for a \"0:17\" label leaves 176.67, and 32 bars at 3 with 2.6 between them are 176.6 of it.\n  const barCount = ios ? 32 : 28;\n  const bars = useMemo(() => (peaks?.length ? peaks : fallbackPeaks(barCount)), [peaks, barCount]);\n  const barWidth = ios ? 3 : 2.5;\n  const barGap = ios ? 2.6 : 2;\n  /** The row's height, which is also the play button's diameter and the waveform's full-scale peak. */\n  const rowHeight = ios ? 28 : 22;\n  const played = duration > 0 ? Math.max(0, Math.min(1, position / duration)) : 0;\n  const track = useRef<HTMLSpanElement>(null);\n  const outgoing = direction === \"outgoing\";\n  const ink = outgoing ? \"#ffffff\" : \"var(--im-incoming-text)\";\n\n  const seek = (clientX: number) => {\n    const element = track.current;\n    if (!element || !onSeek) return;\n    const rect = element.getBoundingClientRect();\n    onSeek(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * duration);\n  };\n\n  return (\n    <MessageBubble data-slot=\"message-audio\" direction={direction} tail={tail} platform={platform}\n      maxWidth={maxWidth ?? bubbleMetrics[platform].maxWidth}\n      className={cn(className)} style={{ fontFamily: fontStack, ...style }} {...props}>\n      {/* One inline-flex box, so the bubble's text fit sees the whole row as the widest line and hugs\n          it. The name is an aria-label rather than visually hidden text: text inside the bubble lays\n          out its own line box, which the fit would then measure instead of this row. `max-width` is\n          what keeps an inline box, which otherwise overflows rather than wrapping, inside the\n          measured padding; a long duration then narrows the bars instead. */}\n      <span role=\"group\" aria-label={`Audio message, ${clock(duration)}`}\n        className=\"inline-flex items-center align-middle\" style={{ gap: ios ? 10 : 8, maxWidth: \"100%\", whiteSpace: \"nowrap\" }}>\n        <button type=\"button\" aria-label={playing ? \"Pause audio message\" : \"Play audio message\"} aria-pressed={playing}\n          onClick={() => onPlayChange?.(!playing)}\n          className=\"flex shrink-0 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ width: rowHeight, height: rowHeight, background: outgoing ? \"rgba(255,255,255,0.22)\" : \"rgba(0,0,0,0.08)\", color: ink }}>\n          {playing ? (\n            <svg aria-hidden=\"true\" width={ios ? 11 : 9} height={ios ? 12 : 10} viewBox=\"0 0 11 12\"><rect x=\"0\" y=\"0\" width=\"4\" height=\"12\" rx=\"1.2\" fill=\"currentColor\" /><rect x=\"7\" y=\"0\" width=\"4\" height=\"12\" rx=\"1.2\" fill=\"currentColor\" /></svg>\n          ) : (\n            <svg aria-hidden=\"true\" width={ios ? 11 : 9} height={ios ? 12 : 10} viewBox=\"0 0 11 12\"><path d=\"M1 1.1c0-.8.9-1.3 1.6-.9l7 4.9c.6.4.6 1.4 0 1.8l-7 4.9c-.7.4-1.6-.1-1.6-.9z\" fill=\"currentColor\" /></svg>\n          )}\n        </button>\n        <span ref={track} data-slot=\"waveform\" role=\"slider\" tabIndex={0}\n          aria-label=\"Playback position\" aria-valuemin={0} aria-valuemax={Math.round(duration)} aria-valuenow={Math.round(position)} aria-valuetext={clock(position)}\n          onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); seek(event.clientX); }}\n          onPointerMove={event => { if (event.buttons === 1) seek(event.clientX); }}\n          onKeyDown={event => {\n            if (!onSeek) return;\n            if (event.key === \"ArrowRight\") { event.preventDefault(); onSeek(Math.min(duration, position + 1)); }\n            if (event.key === \"ArrowLeft\") { event.preventDefault(); onSeek(Math.max(0, position - 1)); }\n          }}\n          className=\"inline-flex flex-1 cursor-default items-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ height: rowHeight, gap: barGap }}>\n          {bars.map((peak, index) => {\n            const reached = index / bars.length <= played;\n            return (\n              <span key={index} aria-hidden=\"true\" style={{\n                width: barWidth, height: Math.max(3, peak * rowHeight), borderRadius: barWidth / 2,\n                background: ink, opacity: reached ? 1 : outgoing ? 0.45 : 0.28,\n              }} />\n            );\n          })}\n        </span>\n        <span aria-hidden=\"true\" style={{ fontSize: ios ? 13 : 11, color: ink, opacity: 0.75, fontVariantNumeric: \"tabular-nums\" }}>\n          {clock(playing || position > 0 ? duration - position : duration)}\n        </span>\n      </span>\n    </MessageBubble>\n  );\n}\n\n/** Drives `position` while `playing`, without pulling in an audio element. */\nexport function useAudioProgress(duration: number, playing: boolean) {\n  const [position, setPosition] = useState(0);\n  const started = useRef(0);\n  useEffect(() => {\n    if (!playing) return;\n    let raf = 0;\n    const base = position;\n    const step = (now: number) => {\n      if (!started.current) started.current = now;\n      const next = base + (now - started.current) / 1000;\n      setPosition(next >= duration ? duration : next);\n      if (next < duration) raf = requestAnimationFrame(step);\n    };\n    raf = requestAnimationFrame(step);\n    return () => { cancelAnimationFrame(raf); started.current = 0; };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [playing, duration]);\n  return [position, setPosition] as const;\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-audio.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "message-edit",
      "title": "Edit and undo send",
      "description": "Edit a sent message in place, the Edited label, and the undo-send removal.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/bubble-shape.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/message-edit.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, type ComponentProps, type CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, fontStack, type Direction, type Service } from \"@/components/imessage/tokens\";\nimport { bodyClipPath, tailBox, tailPath, tailSeamOverlap } from \"@/components/imessage/bubble-shape\";\n\n/**\n * Edit a sent message in place, and undo sending one.\n *\n * MEASURED, and only this: the macOS context menu's **Edit** row, traced off\n * `references/macos/captures/ctxmenu-with-edit-light-2x.png` (2x; the menu's top border is device row\n * 9 and its trailing border device column 604, so the menu is 302.5 wide and every number below is in\n * points from its top-left). The row is its own separated block between \"Attach Sticker…\" and\n * \"Forward…\": separator lines at 158.75 and 193.75, an 11 pt separator block each, so the row spans\n * 164.25 to 188.25 and its centre is 176.25 down, on the menu's own 24 pt row pitch. Label \"Edit\",\n * no ellipsis, 13 pt, ink left edge 41.75 in from the leading edge like every other row. Icon is\n * SF Symbol `pencil`: ink 8.5 x 8.5, centred 25.5 in from the leading edge and on the row's centre\n * (device box x 42-58, y 354-370). Cross-checked against the same capture's other rows, which land on\n * the same model: Attach Sticker… centre 141.25, Forward… 211.25, both within 0.25 of their ink.\n *\n * That row is data in `context-menu.tsx` (`macosMessageMenu`, `MenuIconName`), which does not carry it\n * yet. `canEdit` below is the gate for showing it.\n *\n * NOT MEASURED: everything else in this file. No capture shows the editor, a caret or a selection\n * inside a balloon, or the undo-send removal. So the editable bubble is the measured bubble (same\n * metrics, clip, seam overlap and screen-space fill as `message-bubble.tsx`), the \"Edited\" label is\n * the measured status label in the measured edited blue, and the behaviour follows Apple's documented\n * rules: 15 minutes and five edits, an \"Edited\" label with its history, Undo Send for two minutes.\n */\nexport const editWindowMs = 15 * 60 * 1000;\nexport const undoSendWindowMs = 2 * 60 * 1000;\nexport const maxEdits = 5;\n\nexport function canEdit(sentAt: Date | number, edits = 0, now: Date | number = Date.now()): boolean {\n  return edits < maxEdits && +now - +sentAt <= editWindowMs;\n}\nexport function canUndoSend(sentAt: Date | number, now: Date | number = Date.now()): boolean {\n  return +now - +sentAt <= undoSendWindowMs;\n}\n\n/**\n * Text selection inside the field. UNVERIFIED: no capture shows a selection in a balloon. These are\n * the measured colours Messages paints over a *selected balloon* (SPEC \"Selected outgoing bubble\n * (flat)\", \"Selected incoming bubble (flat)\", \"Selected SMS green bubble\"), the only measured\n * selection colours that sit on a bubble. The values repeat `selectionOverlayClass` in\n * `message-bubble.tsx` instead of importing it because message-edit does not depend on message-bubble\n * in the registry; if one moves, move both.\n */\nexport const editSelectionClass =\n  \"[--im-edit-sel-blue:#1b60d8] [--im-edit-sel-gray:#c6c6c7] [--im-edit-sel-green:#0a0a7833] \" +\n  \"dark:[--im-edit-sel-blue:#0b50c8] dark:[--im-edit-sel-gray:#55555c]\";\n\nexport type EditableBubbleProps = Omit<ComponentProps<\"div\">, \"onSubmit\" | \"children\"> & {\n  value: string;\n  onChange?: (value: string) => void;\n  onSubmit?: (value: string) => void;\n  onCancel?: () => void;\n  direction?: Direction;\n  service?: Service;\n  tail?: boolean;\n  /**\n   * Screen-space y of the body's bottom edge, in px, for the measured position-dependent fill. The\n   * shared sweep in `use-screen-space.ts` only visits `[data-slot=\"message-bubble\"]`, so a bubble\n   * being edited inside a scrolling list has to be given this.\n   */\n  screenBottom?: number;\n  /** Widest the bubble may grow. Defaults to the same native rule `MessageBubble` uses. */\n  maxWidth?: number | string;\n  platform?: Platform;\n  autoFocus?: boolean;\n};\n\n/**\n * The bubble turned into a field. Return commits, Escape cancels, and the bubble keeps the measured\n * shape, padding, line box and fill while editing so nothing shifts.\n */\nexport function EditableBubble({\n  value, onChange, onSubmit, onCancel, direction = \"outgoing\", service = \"imessage\", tail = false,\n  screenBottom, maxWidth, platform: platformProp, autoFocus = true, className, style, onKeyDown, ...props\n}: EditableBubbleProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const outgoing = direction === \"outgoing\";\n  const side = outgoing ? \"right\" : \"left\";\n  const field = useRef<HTMLTextAreaElement>(null);\n  const frame = useRef<HTMLDivElement>(null);\n  const sizer = useRef<HTMLSpanElement>(null);\n  const composing = useRef(false);\n  const hintId = useId();\n  const hang = tailBox.hang * m.tailScale;\n  const key = direction === \"incoming\" ? \"gray\" : service === \"sms\" ? \"green\" : \"blue\";\n  const ink = outgoing ? \"var(--im-outgoing-text)\" : \"var(--im-incoming-text)\";\n\n  // One gradient in screen coordinates, anchored to the body's bottom, exactly as MessageBubble does\n  // it: the tail hangs `hang` lower, so it needs its own background-position or its fill steps out of\n  // register with the body's.\n  const bottomVar = screenBottom === undefined ? \"var(--bubble-bottom, calc(var(--im-screen-h) * 0.55))\" : `${screenBottom}px`;\n  const fill: CSSProperties = {\n    backgroundImage: \"linear-gradient(var(--im-fill-top), var(--im-fill-bottom))\",\n    backgroundSize: \"100% var(--im-screen-h)\",\n    backgroundRepeat: \"no-repeat\",\n    backgroundColor: \"var(--im-fill-bottom)\",\n  };\n  const bodyFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (var(--im-screen-h) - ${bottomVar}))` };\n  const tailFill: CSSProperties = { ...fill, backgroundPosition: `0 calc(100% + (var(--im-screen-h) - ${bottomVar} - ${hang}px))` };\n\n  useEffect(() => {\n    if (!autoFocus) return;\n    const element = field.current;\n    if (!element) return;\n    element.focus();\n    element.setSelectionRange(element.value.length, element.value.length);\n  }, [autoFocus]);\n\n  // MessageBubble's `useNativeTextFit`, run against a hidden mirror of the value: native hugs the\n  // longest wrapped line, a textarea has no text node to range over, and `field-sizing: content`\n  // stretches to the wrap width instead. Without it an iOS bubble jumped from 250.73 to 280.5 the\n  // moment it became editable (measured on /lab/reply, \"Every detail, down to the last bubble.\").\n  useLayoutEffect(() => {\n    const frameEl = frame.current, textEl = sizer.current, fieldEl = field.current;\n    if (!frameEl || !textEl || !fieldEl) return;\n    const container = frameEl.parentElement;\n    let raf = 0;\n    const measure = () => {\n      frameEl.style.width = \"\";\n      const range = document.createRange();\n      range.selectNodeContents(textEl);\n      const lines: Array<{ top: number; left: number; right: number }> = [];\n      for (const rect of Array.from(range.getClientRects())) {\n        if (rect.width === 0 && rect.height === 0) continue;\n        const line = lines.find(l => Math.abs(l.top - rect.top) < 1);\n        if (line) { line.left = Math.min(line.left, rect.left); line.right = Math.max(line.right, rect.right); }\n        else lines.push({ top: rect.top, left: rect.left, right: rect.right });\n      }\n      if (!lines.length) return;\n      const longest = Math.max(...lines.map(l => l.right - l.left));\n      fieldEl.style.textAlign = lines.length === 1 && longest < m.minWidth - 2 * m.paddingX ? \"center\" : \"\";\n      const hug = Math.ceil((longest + 2 * m.paddingX) * 100) / 100 + 0.05;\n      if (lines.length > 1 && hug < frameEl.getBoundingClientRect().width - 0.1) frameEl.style.width = `${hug}px`;\n    };\n    measure();\n    const observer = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); });\n    if (container) observer.observe(container);\n    document.fonts?.ready.then(() => measure()).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); };\n  }, [value, m.paddingX, m.minWidth, platform, maxWidth]);\n\n  return (\n    // Escape is handled on the root, not the field, so it still cancels while focus is on Cancel or\n    // Done. Return stays on the field: on a button it means \"press this button\".\n    <div data-slot=\"editable-bubble\" data-direction={direction} role=\"group\" aria-label=\"Edit message\"\n      className={cn(\"flex w-full min-w-0 flex-col\", editSelectionClass, outgoing ? \"items-end\" : \"items-start\", className)}\n      style={{\n        fontFamily: fontStack,\n        ...(screenBottom === undefined ? {} : { [\"--bubble-bottom\" as string]: `${screenBottom}px` }),\n        [\"--im-fill-top\" as string]: `var(--im-${key}-top)`,\n        [\"--im-fill-bottom\" as string]: `var(--im-${key}-bottom)`,\n        [\"--im-edit-sel\" as string]: `var(--im-edit-sel-${key})`,\n        ...style,\n      } as CSSProperties}\n      onKeyDown={event => {\n        if (event.key === \"Escape\" && onCancel) { event.preventDefault(); event.stopPropagation(); onCancel(); }\n        onKeyDown?.(event);\n      }}\n      {...props}>\n      <div ref={frame} data-slot=\"bubble-frame\" className=\"relative max-w-full\" style={{ maxWidth: maxWidth ?? (platform === \"ios\" ? m.maxWidth : `${m.maxWidthRatio * 100}%`) }}>\n        <div data-slot=\"bubble\" className=\"relative\" style={{ padding: `${m.paddingY}px ${m.paddingX}px`, minWidth: m.minWidth }}>\n          {/* The fill lives behind the text so clipping the tail corner never clips glyphs. */}\n          <div aria-hidden=\"true\" data-slot=\"fill\" className=\"pointer-events-none absolute inset-0\"\n            style={{ borderRadius: m.radius, clipPath: tail ? bodyClipPath(side, m.tailScale, tailSeamOverlap[platform]) : undefined, ...bodyFill }} />\n          {tail && <div aria-hidden=\"true\" data-slot=\"tail\" className=\"pointer-events-none absolute\" style={{\n            [side]: 0, bottom: -hang, width: tailBox.width * m.tailScale, height: tailBox.height * m.tailScale + hang,\n            clipPath: `path(\"${tailPath(side, m.tailScale)}\")`, ...tailFill,\n          }} />}\n          <textarea ref={field} data-slot=\"edit-field\" aria-label=\"Message\" aria-describedby={hintId} value={value} rows={1}\n            onChange={event => onChange?.(event.target.value)}\n            onCompositionStart={() => { composing.current = true; }}\n            onCompositionEnd={() => { composing.current = false; }}\n            onKeyDown={event => {\n              if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing && !composing.current) {\n                event.preventDefault();\n                onSubmit?.(value.trim());\n              }\n            }}\n            className=\"relative block w-full resize-none border-0 bg-transparent outline-none [field-sizing:content] [overflow-wrap:anywhere] selection:bg-[var(--im-edit-sel)]\"\n            style={{\n              margin: 0, padding: 0, boxSizing: \"border-box\", fontFamily: \"inherit\",\n              fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, letterSpacing: m.letterSpacing,\n              // UNVERIFIED: no capture shows the caret in a balloon. It takes the bubble's measured\n              // text colour, which is the only ink colour measured inside a bubble.\n              color: ink, caretColor: ink,\n            }} />\n          {/* Laid out at the field's own content width, never painted; only the hug measures it. */}\n          <span ref={sizer} aria-hidden=\"true\" data-slot=\"edit-sizer\"\n            className=\"pointer-events-none invisible absolute whitespace-pre-wrap [overflow-wrap:anywhere]\"\n            style={{ top: m.paddingY, left: m.paddingX, right: m.paddingX, fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, letterSpacing: m.letterSpacing }}>{value}</span>\n          <span id={hintId} className=\"sr-only\">Return saves the edit. Escape cancels it.</span>\n        </div>\n      </div>\n      {/*\n        UNVERIFIED: native shows no Cancel/Done pair, so the row is sized and placed from the measured\n        status label (same type, same gap under the body, same inset) rather than from a new number.\n      */}\n      <div className=\"flex items-center gap-3\" style={{\n        fontSize: m.statusFontSize, lineHeight: `${m.statusLineHeight}px`, letterSpacing: m.statusLetterSpacing,\n        fontWeight: 600, marginTop: m.statusGap,\n        paddingInlineEnd: outgoing ? m.statusInset : 0, paddingInlineStart: outgoing ? 0 : m.statusInset,\n      }}>\n        <button type=\"button\" data-slot=\"edit-cancel\" aria-label=\"Cancel editing\" onClick={onCancel} style={{ color: \"var(--im-secondary)\" }}>Cancel</button>\n        <button type=\"button\" data-slot=\"edit-done\" aria-label=\"Done editing\" onClick={() => onSubmit?.(value.trim())} style={{ color: \"var(--im-edited)\" }}>Done</button>\n      </div>\n    </div>\n  );\n}\n\n/**\n * \"Edited\" under a message, and the edit history an app can show when it is tapped.\n *\n * The label's own geometry is not captured, so it reuses the measured status label (\"Delivered\"):\n * 11/13 semibold at a 4.65 gap and a 19.3 inset on iOS, 9/11 semibold at 4 and 15.9 on macOS, in the\n * measured `--im-edited` blue. `MessageBubble`'s own inline `edited` span still hardcodes the iOS\n * 11/13 and a 14 inset on both platforms; the two disagree on macOS.\n */\nexport function EditedLabel({ onShowHistory, direction = \"outgoing\", platform: platformProp, className, style, ...props }: Omit<ComponentProps<\"button\">, \"children\"> & { onShowHistory?: () => void; direction?: Direction; platform?: Platform }) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = bubbleMetrics[platform];\n  const outgoing = direction === \"outgoing\";\n  return (\n    // Without a history handler the label is text, so it is disabled rather than an empty tab stop.\n    <button type=\"button\" data-slot=\"edited\" onClick={onShowHistory} disabled={!onShowHistory}\n      aria-label={onShowHistory ? \"Edited. Show edit history.\" : undefined}\n      className={cn(\"underline-offset-2\", onShowHistory && \"hover:underline\", className)}\n      style={{\n        fontFamily: fontStack,\n        fontSize: m.statusFontSize, lineHeight: `${m.statusLineHeight}px`, letterSpacing: m.statusLetterSpacing,\n        fontWeight: 600, marginTop: m.statusGap,\n        paddingInlineEnd: outgoing ? m.statusInset : 0, paddingInlineStart: outgoing ? 0 : m.statusInset,\n        color: \"var(--im-edited)\",\n        ...style,\n      }} {...props}>\n      Edited\n    </button>\n  );\n}\n\n/**\n * Undo Send timing. UNVERIFIED: nothing here is read off a capture, so it is one named table the\n * harness and the tests can seek rather than numbers spread through the keyframes.\n */\nexport const undoSendPoof = {\n  duration: 420,\n  /** prefers-reduced-motion: the same removal as a plain cross fade, no scale and no blur. */\n  reducedDuration: 180,\n  /** The bubble swells before it collapses. `offset` is a fraction of the timeline, so `progress` hits it. */\n  swell: { offset: 0.35, scale: 1.06, opacity: 0.9 },\n  endScale: 0.2,\n  blur: 6,\n  /** Per-segment, not on the effect: see the keyframes below. */\n  easing: \"cubic-bezier(0.4, 0, 0.2, 1)\",\n} as const;\n\nfunction prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport type UndoSendPoofProps = ComponentProps<\"div\"> & {\n  running?: boolean;\n  /** 0..1: pause the poof and seek it to that fraction instead of playing it. */\n  progress?: number;\n  /** Fires when the bubble is gone, which is when the app should drop the message. */\n  onDone?: () => void;\n};\n\n/**\n * Undo Send: the bubble swells, collapses and blurs away. Built on the Web Animations API with one\n * animation on one element, so `document.getAnimations()` reaches it and `progress` seeks it to the\n * same frame every run. A seeked poof never reports the message as gone: only a played one calls\n * `onDone`, and it calls it once.\n */\nexport function UndoSendPoof({ children, running = true, progress, onDone, className, style, ...props }: UndoSendPoofProps) {\n  const host = useRef<HTMLDivElement>(null);\n  const finished = useRef(onDone);\n  useEffect(() => { finished.current = onDone; }, [onDone]);\n  useEffect(() => {\n    const element = host.current;\n    if (!element || !running) return;\n    const reduced = prefersReducedMotion();\n    const duration = reduced ? undoSendPoof.reducedDuration : undoSendPoof.duration;\n    // The easing sits on the keyframes, not on the effect: an effect-level easing warps the whole\n    // iteration, so `swell.offset` would no longer be the fraction of the timeline the swell happens\n    // at and `progress` would not map linearly onto it. Measured at 0.35 with the easing on the\n    // effect, the frame was already past the swell (scale 0.862); on the keyframes it is scale 1.06.\n    const frames: Keyframe[] = reduced\n      ? [{ opacity: 1 }, { opacity: 0 }]\n      : [\n        { offset: 0, transform: \"scale(1)\", opacity: 1, filter: \"blur(0px)\", easing: undoSendPoof.easing },\n        { offset: undoSendPoof.swell.offset, transform: `scale(${undoSendPoof.swell.scale})`, opacity: undoSendPoof.swell.opacity, filter: \"blur(0px)\", easing: undoSendPoof.easing },\n        { offset: 1, transform: `scale(${undoSendPoof.endScale})`, opacity: 0, filter: `blur(${undoSendPoof.blur}px)` },\n      ];\n    const animation = element.animate(frames, { duration, easing: \"linear\", fill: \"both\" });\n    if (progress !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      animation.pause();\n      animation.currentTime = Math.max(0, Math.min(1, progress)) * duration;\n      return () => animation.cancel();\n    }\n    // `finish` rather than the `finished` promise: it fires once, it never fires on a cancel, and the\n    // cleanup below takes the animation's fill off the element on the way out.\n    const done = () => finished.current?.();\n    animation.addEventListener(\"finish\", done);\n    return () => { animation.removeEventListener(\"finish\", done); animation.cancel(); };\n  }, [running, progress]);\n  return (\n    <div ref={host} data-slot=\"undo-send\"\n      data-state={running ? (progress === undefined ? \"running\" : \"seeking\") : \"idle\"}\n      data-progress={progress === undefined ? undefined : Math.max(0, Math.min(1, progress)).toFixed(3)}\n      className={cn(\"origin-center\", running && \"pointer-events-none\", className)} style={style} {...props}>\n      {children}\n    </div>\n  );\n}\n\n/** Countdown an app can show while Undo Send is still possible. */\nexport function undoSendRemaining(sentAt: Date | number, now: Date | number = Date.now()): number {\n  return Math.max(0, undoSendWindowMs - (+now - +sentAt));\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/message-edit.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-details",
      "title": "iOS conversation details",
      "description": "The details screen: contact, call actions, and the grouped settings cells.",
      "files": [
        {
          "path": "registry/imessage/ios-details.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode, type UIEvent as ReactUIEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 conversation details screen, measured from `references/ios/captures/details-light.png`\n * and `details-dark.png` (402×874 @3x). Every number below is a point value read off those frames:\n *\n * - Back button Ø44 glass circle centred (38, 84); avatar Ø80 centred (201, 102).\n * - Avatar initials: ink 44.00 wide by 26.67 cap, x 178.67–222.33, y 89.00–115.33. Chrome needs\n *   37.5pt at weight 650 to land there; 34pt semibold is 4.33 too narrow and 2.33 too short.\n * - Name 26pt bold centred on x 201, ink y 152.33–177.67.\n * - Three Ø54 glass action circles centred y 222.67 at x 127 / 201 / 275 (74 pitch). Glyphs are\n *   22pt SF Symbols: available ones use the label colour, unavailable ones tertiary label (30%).\n * - Grouped cells span x 16–386, radius 26 with a continuous corner, 20 between groups: phone\n *   269.67–340.33 (70.67 tall), links 360.33–464.33 (two 52 rows, 1pt separator at 411.33 inset to\n *   x 32–370), Hide Alerts 484.33–536.33, Block Contact 556.33–608.33. Row text starts at x 32.\n * - Cell and button fills are translucent: 6% black in light, 12% white in dark (over pure white\n *   that measures #efeff0, over black #1c1c1f — both read off the captures).\n * - Switch: 63×28 track (radius 14) with a 37×24 knob (radius 12) inset 2, trailing edge at x 372.\n *   Re-measured 2026-09-08 on the Hide Alerts row of both captures: track x 309.00–372.00,\n *   y 496.333–524.333; knob x 311.00–348.00, y 498.333–522.333. Both captures show it **off**; no\n *   capture in this repo holds an on switch, so the on colour comes from the framework instead\n *   (`+[UIColor systemGreenColor]` resolved under each `UIUserInterfaceStyle` in a Mac Catalyst\n *   process with the idiom swizzled to phone: #34c759 light, #30d158 dark). `-[UISwitch\n *   intrinsicContentSize]` there is 61×28, which corroborates the measured 28 track height.\n *   The off track darkens the cell fill by 21% in light and lightens it by 28% in dark (sampled\n *   beside the knob against the bare cell on the same row).\n * - RECENT tag: 41×11.33 pill, radius 3.5, #c7c7cc in both themes, 8.5pt bold label (cap 6).\n *\n * The screen sits over the conversation, which is blurred (σ≈18) and washed out by a 49% white /\n * 59% black scrim; pass that conversation as `backdrop`.\n *\n * The presentation is a separate matter, and its timings are UNMEASURED: no capture in this repo\n * records this screen in motion, so nothing below is a reading off a frame. See `iosDetailsMotion`.\n *\n * ## What the capture does not show\n *\n * `details-light.png` is one scroll position of a conversation with no shared content: the cell\n * stack ends at Block Contact and nothing follows it. Three groups are therefore built from the\n * measured *style* rather than from a frame, and are **UNMEASURED**: the shared photos grid, the\n * shared links list and the shared attachments list (`photos`, `sharedLinks`, `attachments`). They\n * sit between Hide Alerts and Block Contact, which is where they push the destructive row down, and\n * with all three absent the stack reproduces the capture point for point. What they reuse:\n *\n * - The cell geometry, the 20 between groups, the 52 row, the 70.67 two-line cell, the 16 row inset\n *   and the 1pt separator inset to x 32–370 are all measured on this screen.\n * - A two-line item row keeps the measured phone cell's three gaps (16.875 above, 1.5 between,\n *   14.29 below) with its 17pt and 13pt lines swapped, so it is 70.67 tall like the measured one.\n * - The grid is 3 across with a 4 gap, from `+[CKUIBehavior sharedBehaviors]` under the phone\n *   idiom: `attachmentBrowserGridInterItemSpacing` and `attachmentBrowserGridMinimumLineSpacing`\n *   are both 4. Inside the 370 cell at the measured 16 inset that makes a 110 tile. The tile radius\n *   12 is the measured Photos-picker tile radius (SPEC, `photo-picker-light.png`).\n * - The chevron on a row that navigates is the nav bar's measured chevron: 4.67 × 12.67 ink, 2.6\n *   round stroke, #bdbdbd / #5d5d5d (`ios-nav-bar.tsx`).\n *\n * ChatKit 26 actually files shared content under tabs, not under more cells on the info list\n * (`DetailsPhotosTab`, `DetailsLinksTab`, `DetailsAttachmentsTab`, `CKDetailsSegmentedControlCell`).\n * Nothing in `references/` shows that surface, so this file stays with the grouped cells.\n *\n * ## Scrolling\n *\n * Also UNMEASURED, and it cannot be measured from a still. The cells scroll; the header collapses\n * into the conversation's nav bar, which is the exact inverse of the pose the presentation grows\n * out of, so the two ends of the collapse are measured even though the travel between them is not.\n * See `iosDetailsCollapse`. It is seekable: `scroll` (in points) drives paused Web Animations, so\n * `document.getAnimations()` reaches every layer and a checkpoint renders the same twice.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/** Light/dark values live in CSS variables so a `.dark` ancestor flips the whole screen. */\nconst vars =\n  \"[--ios-dt-label:#000000] [--ios-dt-secondary:#848488] [--ios-dt-blue:#0088ff] [--ios-dt-red:#ff383c] \" +\n  \"[--ios-dt-fill:rgba(0,0,0,0.06)] [--ios-dt-separator:#dadadb] [--ios-dt-glyph:#000000] [--ios-dt-glyph-off:rgba(0,0,0,0.26)] [--ios-dt-glyph-blend:normal] [--ios-dt-av-top:#a9c2e1] [--ios-dt-av-bottom:#747fb9] \" +\n  \"[--ios-dt-tag:#c7c7cc] [--ios-dt-tag-label:#ffffff] [--ios-dt-track:rgba(0,0,0,0.21)] [--ios-dt-knob:#ffffff] \" +\n  \"[--ios-dt-chevron:#bdbdbd] [--ios-dt-av-shadow:rgba(0,0,0,0.12)] [--ios-dt-glass:rgba(255,255,255,0.9)] [--ios-dt-glass-rim:inset_0_0_0_0_rgba(0,0,0,0)] [--ios-dt-glass-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] \" +\n  \"[--ios-dt-scrim:rgba(255,255,255,0.573)] [--ios-dt-saturate:1] [--ios-dt-page:#ffffff] \" +\n  \"dark:[--ios-dt-label:#ffffff] dark:[--ios-dt-secondary:#98989f] dark:[--ios-dt-blue:#0091ff] dark:[--ios-dt-red:#ff4245] \" +\n  \"dark:[--ios-dt-fill:rgba(235,235,245,0.12)] dark:[--ios-dt-separator:#3a3a3c] dark:[--ios-dt-glyph:#ffffff] dark:[--ios-dt-glyph-off:rgba(255,255,255,0.26)] dark:[--ios-dt-glyph-blend:plus-lighter] dark:[--ios-dt-av-top:#575368] dark:[--ios-dt-av-bottom:#302649] \" +\n  \"dark:[--ios-dt-track:rgba(255,255,255,0.28)] dark:[--ios-dt-chevron:#5d5d5d] dark:[--ios-dt-av-shadow:rgba(0,0,0,0.3)] \" +\n  \"dark:[--ios-dt-glass:rgba(28,28,28,0.9)] dark:[--ios-dt-glass-rim:inset_0_0_0_0.3333px_rgba(255,255,255,0.0385),inset_0_0_0_0.6667px_rgba(255,255,255,0.032),inset_0_0_0_1px_rgba(255,255,255,0.061)] dark:[--ios-dt-glass-shadow:0_0_0_0_rgba(0,0,0,0)] \" +\n  \"dark:[--ios-dt-scrim:rgba(0,0,0,0.587)] dark:[--ios-dt-saturate:1.05] dark:[--ios-dt-page:#000000]\";\n\n/** Apple's continuous corner. Browsers without `corner-shape` fall back to a plain round corner. */\nconst continuous = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/**\n * The grouped-cell geometry, all of it measured off the captures (see the header). `top` is the\n * first cell's top; every later group starts `gap` below the one before it, which reproduces the\n * measured 269.67 / 360.33 / 484.33 / 556.33 stack exactly and lets an unmeasured group in without\n * moving anything above it.\n */\nconst cell = {\n  left: 16,\n  width: 370,\n  radius: 26,\n  gap: 20,\n  top: 269.6667,\n  /** Row text starts at x 32, i.e. 16 inside the cell. */\n  inset: 16,\n  /** One-line row (Hide Alerts, Block Contact, the blue link rows). */\n  row: 52,\n  /** Two-line cell (the phone row). */\n  twoLine: 70.6667,\n} as const;\n\n/** The measured two-line cell's three gaps, with its 17pt and 13pt lines swapped. UNMEASURED. */\nconst twoLineRow = { title: 16.875, detail: 40.375 } as const;\n\n/** 3 across, 4 apart (CKUIBehavior, phone idiom), inside the measured 16 inset: a 110 tile. */\nconst grid = { columns: 3, gap: 4, radius: 12 } as const;\nconst tile = (cell.width - cell.inset * 2 - grid.gap * (grid.columns - 1)) / grid.columns;\n\nexport type IosDetailsAction = {\n  id: string;\n  label: string;\n  icon: \"phone\" | \"video\" | \"mail\";\n  /** Unavailable actions keep the glass circle but drop the glyph to tertiary label (measured). */\n  disabled?: boolean;\n  onPress?: () => void;\n};\n\n/** One tile of the shared photos grid. Pass `node` for a next/image, `src` for a plain one. */\nexport type IosDetailsPhoto = { id: string; src?: string; alt?: string; node?: ReactNode; onPress?: () => void };\n/** One row of the shared links or shared attachments list. */\nexport type IosDetailsItem = { id: string; title: string; detail?: string; onPress?: () => void };\n/** A shared-content group: a header row that navigates, then its items. */\nexport type IosDetailsSection<T> = { title?: string; count?: number | string; items: T[]; onOpen?: () => void };\n\nexport type IosDetailsProps = Omit<ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  name: string;\n  initials?: string;\n  /** Replaces the initials avatar (an <img>, say). */\n  avatar?: ReactNode;\n  /** First cell: a small label (\"phone\") over its value, with an optional tag (\"RECENT\"). */\n  phoneLabel?: string;\n  phone?: string;\n  tag?: string;\n  actions?: IosDetailsAction[];\n  /** Blue link rows in the second cell. */\n  links?: Array<{ id: string; label: string; onPress?: () => void }>;\n  hideAlerts?: boolean;\n  onHideAlertsChange?: (next: boolean) => void;\n  hideAlertsLabel?: string;\n  /** Shared content, below the capture's fold and UNMEASURED. Each one is a grouped cell. */\n  photos?: IosDetailsSection<IosDetailsPhoto>;\n  sharedLinks?: IosDetailsSection<IosDetailsItem>;\n  attachments?: IosDetailsSection<IosDetailsItem>;\n  blockLabel?: string;\n  onBlock?: () => void;\n  onBack?: () => void;\n  /** The conversation behind the screen; rendered blurred and washed out. */\n  backdrop?: ReactNode;\n  /**\n   * Seek the presentation to this fraction instead of playing it, which is what the harness does:\n   * while `open`, 0 is dismissed and 1 is settled; while it is closing, 0 is settled and 1 is gone.\n   * Leave it unset for the real thing.\n   */\n  progress?: number;\n  /**\n   * Seek the scroll position, in points, instead of letting the surface scroll: the header collapse\n   * follows it exactly the way it follows a finger. Leave it unset for the real thing.\n   */\n  scroll?: number;\n  /** False plays the dismissal; `onExited` fires when it is over, and the consumer unmounts then. */\n  open?: boolean;\n  onExited?: () => void;\n};\n\n/**\n * The presentation's timings. UNMEASURED, and they cannot be measured from anything in this repo:\n * no capture records this screen in motion. They sit in the family of the ones that were measured:\n *\n * - The rise takes 320 ms on `cubic-bezier(0.32, 0.72, 0, 1)`, the curve the measured effects screen\n *   (`ios-effects-picker.tsx`, 260 ms), this file's own Hide Alerts switch (220 ms) and the app\n *   shell's screen transitions (`iosScreenTransition`, 400 ms present) all already use.\n * - The scrim and the shared chrome fade over 220 ms, a little ahead of the rise, the way the\n *   long-press overlay's measured dim (150 of its 600 ms) leads its menu.\n * - The way out is 260 ms on the long-press menu's exit curve, between that menu's own 220 ms exit\n *   and the shell's 280 ms dismiss.\n *\n * What IS measured is the pose at each end. The settled pose is `details-light.png`, unchanged: the\n * timeline is cancelled once it lands, so the screen at rest carries no transform at all. The start\n * pose is the conversation's own nav bar, so the screen grows out of the control that opened it:\n * `ios-nav-bar.tsx` puts the avatar Ø60 centred (201, 92) and the name pill's 17pt text centred on\n * y 133.5 (status bar 54 + pill top 63, 32.33 tall, its span nudged 0.33 down); this screen puts the\n * avatar Ø80 centred (201, 102) and the 28pt name centred on y 162.65. So the avatar grows 60 → 80\n * (scale 0.75) across 10 pt of centre, which is the same thing as growing downward from a top edge\n * both screens put on y 62; the name grows 17 → 28 (scale 0.6071) across 29.15; and the back button\n * does not move at all, being Ø44 at (16, 62) on both. The avatar's 37.5pt initials land on 28.1pt\n * at that scale, a third of a point off the nav bar's own 28.\n *\n * The one number here that touches the capture is `backdropScale`. A sheet pushes what it covers\n * back; under σ18 of blur and a 57% scrim the capture cannot tell whether it did. Diffed against\n * `details-light.png` over 0 60 402 560 the settled screen misses 1713 px of 2,026,080 unscaled and\n * 1642 px at 0.96 (both 0.08%; dark is 3136 / 3068, both 0.15%), so the frame does not decide it and\n * this is a presentation choice, not a measurement. The blur is divided by the scale so that what\n * lands on screen is still σ18.\n */\n/** The control points behind `iosDetailsMotion.ease`; the drag inverts the curve through them. */\nconst sheetCurve = [0.32, 0.72, 0, 1] as const;\n\nexport const iosDetailsMotion = {\n  /** The whole entrance, and the sheet's own rise inside it. */\n  enter: 360,\n  sheet: 320,\n  /** The scrim, and the chrome both screens share. */\n  dim: 220,\n  exit: 260,\n  ease: `cubic-bezier(${sheetCurve.join(\", \")})`,\n  exitEase: \"cubic-bezier(0.4, 0, 1, 1)\",\n  /** The conversation behind: its blur, and how far the sheet pushes it back. */\n  blur: 18,\n  backdropScale: 0.96,\n  /** The three glass buttons, then the grouped cells, settle after the header in a short stagger. */\n  actionStart: 60, actionStagger: 22, actionRise: 14, actionDuration: 200,\n  cellStart: 80, cellStagger: 24, cellRise: 22, cellDuration: 200,\n  /** The stagger stops at the measured stack's four cells, so a longer stack still lands by `enter`. */\n  cellStaggerMax: 3,\n  /** A drag past this far, or released faster than this (px/ms), dismisses. */\n  dragCommit: 120, dragVelocity: 0.6,\n} as const;\n\n/** Where the header comes from: the nav bar's own avatar and name pill, both measured (see above). */\nexport const iosDetailsMorph = {\n  avatar: { dy: -10, scale: 60 / 80 },\n  name: { dy: -29.15, scale: 17 / 28 },\n} as const;\n\n/**\n * The header collapse. UNMEASURED — no capture holds this screen scrolled — but both ends of it are:\n * it runs the presentation's morph backwards, so a fully collapsed header is the conversation's own\n * measured nav bar (avatar Ø60 centred (201, 92) under its measured shadow, the name 17pt centred on\n * y 133.5 inside the measured glass pill, the back button Ø44 at (16, 62), which never moves).\n *\n * `travel` is derived rather than chosen: 120.3333 is the scroll that carries the first cell's\n * measured top (269.6667) onto the nav bar pill's measured bottom edge (149.3333), so the collapse\n * finishes exactly as the content reaches the bar it is passing under, and never overlaps it.\n * `actionFade` is derived the same way: the three glass circles are gone by the scroll (74) that\n * brings the first cell's top onto their measured top (195.6667).\n *\n * The pill's own box is the nav bar's measured padding (12.9531 leading, 10.7188 trailing) around\n * the name at the collapsed scale, so a longer or shorter name gets the pill that name would have.\n */\nexport const iosDetailsCollapse = {\n  travel: 120.3333,\n  actionFade: 74,\n  pill: { top: 117, height: 32.3333, radius: 16.1667, padLeft: 12.9531, padRight: 10.7188, centre: 201 },\n  /** Rendering the collapsed pill's 24 blur (measured on the nav bar) through the collapsed scale. */\n  blur: 24,\n} as const;\n\ntype Pose = Record<string, string>;\ntype Layer = { el: HTMLElement; from: Pose; to: Pose; duration: number; delay: number; easing: string };\n\nfunction prefersReducedMotion() {\n  return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\nfunction clamp01(value: number) { return Math.max(0, Math.min(1, value)); }\nfunction stop(animation: Animation) { try { animation.cancel(); } catch { /* already gone */ } }\nfunction seekTo(list: Animation[], time: number) {\n  list.forEach(animation => { animation.pause(); try { animation.currentTime = time; } catch { /* no timeline yet */ } });\n}\n\n/**\n * Where in the rise the sheet stands `covered` of the way up: the entrance curve read backwards.\n * A drag scrubs the timeline, and `cubic-bezier(0.32, 0.72, 0, 1)` spends most of its travel in the\n * first fifth of it, so scrubbing it linearly would leave the screen nearly still under a finger\n * that has already moved 200 pt. Bisect the curve's y for its parameter, then read its x.\n */\nfunction riseSeek(covered: number): number {\n  const [x1, y1, x2, y2] = sheetCurve;\n  const at = (a: number, b: number, t: number) => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t;\n  let low = 0, high = 1, t = 0.5;\n  for (let step = 0; step < 24; step++) { t = (low + high) / 2; if (at(y1, y2, t) < covered) low = t; else high = t; }\n  return at(x1, x2, t);\n}\n\n/** What an element is holding right now, so a dismissal can start from a half-played entrance or a drag. */\nfunction poseNow(el: HTMLElement, shape: Pose): Pose {\n  const style = getComputedStyle(el);\n  const pose: Pose = {};\n  for (const key of Object.keys(shape)) pose[key] = style.getPropertyValue(key.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)) || shape[key];\n  return pose;\n}\n\n/**\n * Every layer of the presentation, as the pose it holds while dismissed and the pose it settles on.\n * The settled pose is what the element already carries at rest, so cancelling the timeline when it\n * lands leaves the measured screen with no animation, no transform and its glass intact (a held\n * animation would promote each layer and cut the glass circles off from the backdrop they blur).\n *\n * `translate` and `scale` are used rather than `transform`, so a layer whose measured position is\n * already carried by a fractional `transform` (the action circles, via `subpixel`) keeps it — and so\n * that the collapse, which owns `transform`, composes with this instead of replacing it.\n */\nfunction detailsLayers(content: HTMLElement, blur: HTMLElement | null, scrim: HTMLElement | null, height: number, saturate: string): Layer[] {\n  const m = iosDetailsMotion;\n  const layers: Layer[] = [];\n  const add = (el: HTMLElement | null, from: Pose, to: Pose, duration: number, delay = 0, easing: string = m.ease) => {\n    if (el) layers.push({ el, from, to, duration, delay, easing });\n  };\n  const own = (slot: string) => content.querySelector<HTMLElement>(`:scope > [data-slot=\"${slot}\"]`);\n  const each = (slot: string) => Array.from(content.querySelectorAll<HTMLElement>(`:scope > [data-slot=\"${slot}\"]`));\n  const cells = () => Array.from(content.querySelectorAll<HTMLElement>(`:scope > [data-slot=\"details-scroll\"] [data-slot=\"cell\"]`));\n  // The chrome that both screens share is already on screen, in the same place: it cancels the\n  // sheet's rise exactly (same duration, same easing) and morphs out of the nav bar instead.\n  const stay = (dy: number) => `0px ${(dy - height).toFixed(3)}px`;\n\n  add(blur,\n    { filter: `blur(0px) saturate(${saturate})`, scale: \"1\" },\n    { filter: `blur(${(m.blur / m.backdropScale).toFixed(3)}px) saturate(${saturate})`, scale: String(m.backdropScale) },\n    m.sheet);\n  add(scrim, { opacity: \"0\" }, { opacity: \"1\" }, m.dim, 0, \"ease-out\");\n  add(content, { translate: `0px ${height}px` }, { translate: \"0px 0px\" }, m.sheet);\n  add(own(\"back\"), { translate: stay(0) }, { translate: \"0px 0px\" }, m.sheet);\n  add(own(\"back\"), { opacity: \"0\" }, { opacity: \"1\" }, m.dim, 0, \"ease-out\");\n  add(own(\"avatar\"), { translate: stay(iosDetailsMorph.avatar.dy), scale: String(iosDetailsMorph.avatar.scale) }, { translate: \"0px 0px\", scale: \"1\" }, m.sheet);\n  add(own(\"name\"), { translate: stay(iosDetailsMorph.name.dy), scale: String(iosDetailsMorph.name.scale) }, { translate: \"0px 0px\", scale: \"1\" }, m.sheet);\n  each(\"action\").forEach((el, index) => add(el, { translate: `0px ${m.actionRise}px` }, { translate: \"0px 0px\" }, m.actionDuration, m.actionStart + index * m.actionStagger));\n  cells().forEach((el, index) => add(el, { translate: `0px ${m.cellRise}px` }, { translate: \"0px 0px\" }, m.cellDuration, m.cellStart + Math.min(index, m.cellStaggerMax) * m.cellStagger));\n  return layers;\n}\n\n/** Web Animations, not a rAF loop or a transition, so `document.getAnimations()` can seek a frame. */\nfunction runLayers(layers: Layer[], phase: \"enter\" | \"exit\"): Animation[] {\n  const m = iosDetailsMotion;\n  return layers.map(({ el, from, to, duration, delay, easing }) => phase === \"enter\"\n    ? el.animate([from, to], { duration, delay, easing, fill: \"both\" })\n    // One flat span on the way out, so the shared chrome's counter-translate still cancels the\n    // sheet's exactly, and it starts from wherever the layer is now (settled, or mid-drag).\n    : el.animate([poseNow(el, from), from], { duration: m.exit, easing: m.exitEase, fill: \"both\" }));\n}\n\n/**\n * The collapse, as paused animations whose clock is the scroll offset in points: one millisecond of\n * timeline per point scrolled, so seeking is `currentTime = scrollTop`. They own `transform` and\n * `opacity`, which composes with the presentation's `translate` / `scale` rather than replacing it,\n * and they exist only while the surface is scrolled — at the top the timeline is cancelled and the\n * measured screen carries no transform at all, exactly as it does with no collapse in the file.\n */\nfunction collapseLayers(content: HTMLElement, pillWidth: number): Layer[] {\n  const c = iosDetailsCollapse;\n  const layers: Layer[] = [];\n  const add = (el: HTMLElement | null, from: Pose, to: Pose, duration: number = c.travel) => {\n    if (el) layers.push({ el, from, to, duration, delay: 0, easing: \"linear\" });\n  };\n  const own = (slot: string) => content.querySelector<HTMLElement>(`:scope > [data-slot=\"${slot}\"]`);\n  const avatar = own(\"avatar\");\n  const shadow = avatar ? getComputedStyle(avatar).getPropertyValue(\"--ios-dt-av-shadow\").trim() || \"rgba(0,0,0,0.12)\" : \"\";\n  add(avatar,\n    { transform: \"translateY(0px) scale(1)\", boxShadow: \"0 2px 4px rgba(0,0,0,0)\" },\n    { transform: `translateY(${iosDetailsMorph.avatar.dy}px) scale(${iosDetailsMorph.avatar.scale})`, boxShadow: `0 2px 4px ${shadow}` });\n  add(own(\"name\"),\n    { transform: \"translateY(0px) scale(1)\" },\n    { transform: `translateY(${iosDetailsMorph.name.dy}px) scale(${iosDetailsMorph.name.scale})` });\n  const pill = own(\"collapsed-pill\");\n  if (pill) {\n    pill.style.width = `${pillWidth.toFixed(4)}px`;\n    pill.style.left = `${(c.pill.centre - pillWidth / 2).toFixed(4)}px`;\n    add(pill, { opacity: \"0\" }, { opacity: \"1\" });\n  }\n  Array.from(content.querySelectorAll<HTMLElement>(`:scope > [data-slot=\"action\"]`))\n    .forEach(el => add(el, { opacity: \"1\" }, { opacity: \"0\" }, c.actionFade));\n  return layers;\n}\n\n/** Swallows the click a drag would otherwise leave behind on whatever control it started on. */\nfunction swallowClick(node: HTMLElement | null) {\n  if (!node) return;\n  const swallow = (event: Event) => { event.stopPropagation(); event.preventDefault(); };\n  node.addEventListener(\"click\", swallow, { capture: true, once: true });\n  setTimeout(() => node.removeEventListener(\"click\", swallow, true), 0);\n}\n\ntype GlassCircleProps = ComponentProps<\"button\"> & { size: number; \"data-slot\"?: string; \"data-action\"?: string };\n\nfunction GlassCircle({ size, className, style, children, ...rest }: GlassCircleProps) {\n  return (\n    <button type=\"button\"\n      className={cn(\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\", className)}\n      style={{ width: size, height: size, background: \"var(--ios-dt-fill)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\", ...style }}\n      {...rest}>\n      {children}\n    </button>\n  );\n}\n\n/**\n * SF Symbol stand-ins, drawn at the ink sizes measured inside the Ø54 circles. Ink boxes read off\n * `details-light.png` (mean-intensity edges, so they are good to about a third of a point):\n * phone x 118.0–136.0 y 213.67–231.33, video x 190.0–213.11 y 215.02–230.33, mail x 263.67–286.33\n * y 214.67–230.67. Blink snaps each `<svg>` box to a whole CSS pixel, so glyphs whose centred\n * position lands on a half point carry the remainder in a transform, the way `subpixel` does.\n */\nfunction ActionGlyph({ icon }: { icon: IosDetailsAction[\"icon\"] }) {\n  const id = useId();\n  if (icon === \"phone\") {\n    // 18.36 wide, not 17: at 17 the ink measures 16.67 across against the capture's 18.0.\n    return (\n      <svg aria-hidden=\"true\" width=\"18.36\" height=\"17.3333\" viewBox=\"1.72 1.25 13.5 13.5\" fill=\"currentColor\">\n        <path d=\"M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.6 17.6 0 0 0 4.168 6.608 17.6 17.6 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.68.68 0 0 0-.58-.122l-2.19.547a1.75 1.75 0 0 1-1.657-.459L5.482 8.062a1.75 1.75 0 0 1-.46-1.657l.548-2.19a.68.68 0 0 0-.122-.58z\" />\n      </svg>\n    );\n  }\n  if (icon === \"video\") {\n    // Body 16.33 wide (capture x 190.0–206.33), a 1.17 gap, then the lens wedge: its neck stands on\n    // x 17.5 running y 4.60–10.70 and its outer edge on the ink's right, y 1.22–14.08. Both slanted\n    // edges measure 0.615 of rise per point across, checked at x 208.0 and x 212.33 top and bottom.\n    // The half-point centring is snapped away by Blink, so the third of a point comes back here.\n    return (\n      <svg aria-hidden=\"true\" width=\"23\" height=\"15.3333\" viewBox=\"0 0 23 15.3333\" fill=\"currentColor\" style={{ transform: \"translateY(0.3333px)\" }}>\n        <rect x=\"0\" y=\"0\" width=\"16.3333\" height=\"15.3333\" rx=\"3.6\" />\n        <path d=\"M17.969 4.312 22.531 1.508Q23 1.22 23 1.77V13.53Q23 14.08 22.531 13.792L17.969 10.988Q17.5 10.7 17.5 10.15V5.15Q17.5 4.6 17.969 4.312Z\" />\n      </svg>\n    );\n  }\n  // The envelope is a 22.67 × 16 rounded rect crossed by four 0.9 wide creases, read off\n  // `details-light.png`: the flap runs corner to corner through (11.33, 10.4), and a short seam\n  // rises from each bottom corner to meet the flap's arm at (8.3, 7.7) / (14.37, 7.7).\n  return (\n    // Centred on 275 a 22.67 wide box starts on 263.667, and Blink snaps both edges inward: the\n    // envelope then renders 22.0 across instead of the capture's 263.67–286.33. A whole 23 wide box\n    // survives the snap (263.5 rounds to 264), the viewBox insets the 22.67 of artwork inside it,\n    // and half a point of transform carries the pair onto the measured edges.\n    <svg aria-hidden=\"true\" width=\"23\" height=\"16\" viewBox=\"-0.1667 0 23 16\" style={{ transform: \"translateX(-0.5px)\" }}>\n      <mask id={id} maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"22.6667\" height=\"16\">\n        <rect width=\"22.6667\" height=\"16\" rx=\"2.6\" fill=\"#ffffff\" />\n        <path d=\"M-0.7 0 11.3333 10.4 23.37 0M0 16 8.3 7.7M22.6667 16 14.3667 7.7\" fill=\"none\" stroke=\"#000000\" strokeWidth=\"0.9\" />\n      </mask>\n      <rect width=\"22.6667\" height=\"16\" rx=\"2.6\" fill=\"currentColor\" mask={`url(#${id})`} />\n    </svg>\n  );\n}\n\n/**\n * The disclosure chevron on a row that navigates. The nav bar's measured one: 4.67 × 12.67 of ink\n * on a 2.6 round stroke, in the measured chevron gray (`ios-nav-bar.tsx`). Its box is centred on the\n * row and its ink sits `cell.inset` in from the cell's trailing edge.\n */\nfunction Chevron() {\n  return (\n    <svg aria-hidden=\"true\" className=\"absolute\" width=\"8.6667\" height=\"16.6667\" viewBox=\"-2 -2 8.6667 16.6667\" fill=\"none\"\n      stroke=\"var(--ios-dt-chevron)\" strokeWidth=\"2.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\"\n      style={{ right: cell.inset - 2, top: \"50%\", transform: \"translateY(-50%)\" }}>\n      <path d=\"M1.3 1.3 3.37 6.3333 1.3 11.37\" />\n    </svg>\n  );\n}\n\nexport type IosSwitchProps = Omit<ComponentProps<\"button\">, \"onChange\"> & {\n  checked?: boolean;\n  onChange?: (next: boolean) => void;\n  label?: string;\n};\n\n/**\n * iOS 26 switch: 63×28 track, 37×24 knob inset 2, so the knob travels 22 (measured on the Hide\n * Alerts row of both captures, which show it off).\n *\n * The on colour is not in any capture and is not a guess either: `+[UIColor systemGreenColor]`,\n * resolved for each `UIUserInterfaceStyle` in a Mac Catalyst process with `-[UIDevice\n * userInterfaceIdiom]` swizzled to phone, is #34c759 light and #30d158 dark.\n *\n * Off, the track is the measured composite over this screen's cell fill (`--ios-dt-track`). Away\n * from that cell there is nothing measured to composite against, so it falls back to the framework's\n * `+[UIColor secondarySystemFillColor]` (#787880 at 16% light, 32% dark).\n */\nexport function IosSwitch({ checked = false, onChange, label, className, style, ...rest }: IosSwitchProps) {\n  return (\n    <button type=\"button\" data-slot=\"ios-switch\" role=\"switch\" aria-checked={checked} aria-label={label} onClick={() => onChange?.(!checked)}\n      className={cn(\n        \"relative shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff] motion-reduce:!transition-none\",\n        \"[--ios-sw-on:#34c759] [--ios-sw-off:rgba(120,120,128,0.16)] dark:[--ios-sw-on:#30d158] dark:[--ios-sw-off:rgba(120,120,128,0.32)]\",\n        className,\n      )}\n      style={{ width: 63, height: 28, borderRadius: 14, overflow: \"hidden\", background: checked ? \"var(--ios-sw-on)\" : \"var(--ios-dt-track, var(--ios-sw-off))\", transition: \"background 200ms ease\", ...style }}\n      {...rest}>\n      {/*\n        The capture shows no shadow outside the switch at all: one pixel past the track the pixels\n        are already the surrounding gradient, and inside the track the knob only darkens it by ~3/255.\n        The track therefore clips the knob's shadow, and that shadow is barely there.\n      */}\n      <span aria-hidden=\"true\" className=\"absolute block motion-reduce:!transition-none\" style={{\n        left: 2, top: 2, width: 37, height: 24, borderRadius: 12, background: \"var(--ios-dt-knob, #ffffff)\",\n        boxShadow: \"0 1px 3px rgba(0,0,0,0.10)\",\n        transform: `translateX(${checked ? 22 : 0}px)`, transition: \"transform 220ms cubic-bezier(0.32,0.72,0,1)\",\n      }} />\n    </button>\n  );\n}\n\n/**\n * Blink snaps a painted box to whole CSS px, so a fill placed straight on a third-of-a-point edge\n * lands a device pixel off at 3x. The fill therefore sits on an integer box and a transform carries\n * the fraction; transforms are composited without snapping. `subpixel` does the same for any element\n * whose measured position is not a whole point.\n */\nexport function subpixel(top: number): CSSProperties {\n  const whole = Math.floor(top);\n  return { top: whole, transform: `translateY(${(top - whole).toFixed(4)}px)` };\n}\n\nfunction Cell({ top, height, children }: { top: number; height: number; children: ReactNode }) {\n  const whole = Math.floor(top);\n  const boxHeight = Math.max(1, Math.round(height));\n  return (\n    <div data-slot=\"cell\" className=\"absolute\" style={{ left: cell.left, top, width: cell.width, height }}>\n      <span aria-hidden=\"true\" data-slot=\"cell-fill\" className=\"absolute\" style={{\n        left: 0, top: whole - top, width: cell.width, height: boxHeight, borderRadius: cell.radius,\n        background: \"var(--ios-dt-fill)\", transformOrigin: \"0 0\",\n        transform: `translateY(${(top - whole).toFixed(4)}px) scaleY(${(height / boxHeight).toFixed(5)})`,\n        ...continuous,\n      }} />\n      {children}\n    </div>\n  );\n}\n\n/** The measured hairline: 1pt at a row boundary, inset to x 32–370. */\nfunction Separator({ top }: { top: number }) {\n  return (\n    <span aria-hidden=\"true\" data-slot=\"separator\" className=\"absolute\"\n      style={{ left: cell.inset, right: cell.inset, top: top - 1.3333, transform: \"translateY(0.3333px)\", height: 1, background: \"var(--ios-dt-separator)\" }} />\n  );\n}\n\nconst rowFocus = \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\";\n/** Row text is 17pt (measured); a second line is 13pt (measured, the phone cell's small label). */\nconst titleType: CSSProperties = { fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-label)\" };\nconst detailType: CSSProperties = { fontSize: 13, lineHeight: \"16px\", color: \"var(--ios-dt-secondary)\" };\nconst clip: CSSProperties = { overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\" };\n\n/**\n * A shared group's header row: the section's name, how many it holds, and a chevron. The whole 52pt\n * row is the hit target when it navigates, and it is a plain heading when it does not. UNMEASURED.\n */\nfunction SectionHeader({ title, count, onOpen }: { title: string; count?: number | string; onOpen?: () => void }) {\n  const body = (\n    <>\n      <span style={{ ...titleType, ...clip }}>{title}</span>\n      {count !== undefined && (\n        <span data-slot=\"section-count\" style={{ ...titleType, color: \"var(--ios-dt-secondary)\", marginLeft: \"auto\", marginRight: onOpen ? 8 : 0 }}>{count}</span>\n      )}\n      {onOpen && <Chevron />}\n    </>\n  );\n  const style: CSSProperties = { paddingLeft: cell.inset, paddingRight: cell.inset + (onOpen ? 14 : 0), transform: \"translateY(0.6667px)\" };\n  return (\n    <div className=\"absolute\" style={{ left: 0, right: 0, top: 0, height: cell.row }}>\n      {onOpen\n        ? <button type=\"button\" data-slot=\"section-header\" onClick={onOpen} className={cn(\"absolute inset-0 flex items-center text-left\", rowFocus)} style={style}>{body}</button>\n        : <h2 data-slot=\"section-header\" className=\"absolute inset-0 m-0 flex items-center font-normal\" style={style}>{body}</h2>}\n    </div>\n  );\n}\n\n/**\n * One row of a shared links or shared attachments list. Two lines keep the measured phone cell's\n * three gaps with its 17pt and 13pt lines swapped, so the row is the measured 70.67 tall; a row with\n * no second line is the measured 52. UNMEASURED.\n */\nfunction ItemRow({ item, top, height }: { item: IosDetailsItem; top: number; height: number }) {\n  const twoLine = item.detail !== undefined;\n  const pad = cell.inset + (item.onPress ? 14 : 0);\n  const body = twoLine ? (\n    <>\n      <span className=\"absolute\" style={{ left: cell.inset, right: pad, top: twoLineRow.title, ...titleType, ...clip }}>{item.title}</span>\n      <span className=\"absolute\" style={{ left: cell.inset, right: pad, top: twoLineRow.detail, ...detailType, ...clip }}>{item.detail}</span>\n      {item.onPress && <Chevron />}\n    </>\n  ) : (\n    <>\n      <span style={{ ...titleType, ...clip }}>{item.title}</span>\n      {item.onPress && <Chevron />}\n    </>\n  );\n  const style: CSSProperties = twoLine\n    ? { transform: \"translateY(0.6667px)\" }\n    : { paddingLeft: cell.inset, paddingRight: pad, transform: \"translateY(0.6667px)\" };\n  return (\n    <div className=\"absolute\" style={{ left: 0, right: 0, top, height }}>\n      {/* Every item row sits under a row: the header above the first one, an item above the rest. */}\n      <Separator top={0} />\n      {item.onPress\n        ? <button type=\"button\" data-slot=\"detail-row\" onClick={item.onPress} className={cn(\"absolute inset-0 text-left\", !twoLine && \"flex items-center\", rowFocus)} style={style}>{body}</button>\n        : <div data-slot=\"detail-row\" className={cn(\"absolute inset-0\", !twoLine && \"flex items-center\")} style={style}>{body}</div>}\n    </div>\n  );\n}\n\n/** How tall a shared group's cell is, given what it holds. UNMEASURED; see the header. */\nfunction photosHeight(section: IosDetailsSection<IosDetailsPhoto>) {\n  const rows = Math.max(1, Math.ceil(section.items.length / grid.columns));\n  return cell.row + cell.inset + rows * tile + (rows - 1) * grid.gap + cell.inset;\n}\nfunction listHeight(section: IosDetailsSection<IosDetailsItem>) {\n  return cell.row + section.items.reduce((total, item) => total + (item.detail === undefined ? cell.row : cell.twoLine), 0);\n}\n\nfunction PhotosCell({ section, top }: { section: IosDetailsSection<IosDetailsPhoto>; top: number }) {\n  return (\n    <Cell top={top} height={photosHeight(section)}>\n      <SectionHeader title={section.title ?? \"Photos\"} count={section.count ?? section.items.length} onOpen={section.onOpen} />\n      <Separator top={cell.row} />\n      {section.items.map((photo, index) => {\n        const column = index % grid.columns;\n        const row = Math.floor(index / grid.columns);\n        const box: CSSProperties = {\n          left: cell.inset + column * (tile + grid.gap),\n          top: cell.row + cell.inset + row * (tile + grid.gap),\n          width: tile, height: tile, borderRadius: grid.radius, background: \"var(--ios-dt-fill)\",\n        };\n        const label = photo.alt ?? `Photo ${index + 1}`;\n        const media = photo.node ?? (photo.src\n          // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral\n          ? <img src={photo.src} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n          : null);\n        return photo.onPress\n          ? (\n            <button key={photo.id} type=\"button\" data-slot=\"photo\" aria-label={label} onClick={photo.onPress}\n              className={cn(\"absolute overflow-hidden\", \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\")} style={box}>\n              {media}\n            </button>\n          )\n          : (\n            <span key={photo.id} data-slot=\"photo\" role=\"img\" aria-label={label} className=\"absolute block overflow-hidden\" style={box}>\n              {media}\n            </span>\n          );\n      })}\n    </Cell>\n  );\n}\n\nfunction ListCell({ section, title, top }: { section: IosDetailsSection<IosDetailsItem>; title: string; top: number }) {\n  // The rows stack under the header, each one as tall as it needs: 52 with one line, 70.67 with two.\n  const rows: Array<{ item: IosDetailsItem; top: number; height: number }> = [];\n  section.items.reduce<number>((cursor, item) => {\n    const height = item.detail === undefined ? cell.row : cell.twoLine;\n    rows.push({ item, top: cursor, height });\n    return cursor + height;\n  }, cell.row);\n  return (\n    <Cell top={top} height={listHeight(section)}>\n      <SectionHeader title={section.title ?? title} count={section.count ?? section.items.length} onOpen={section.onOpen} />\n      {rows.map(row => <ItemRow key={row.item.id} item={row.item} top={row.top} height={row.height} />)}\n    </Cell>\n  );\n}\n\nexport function IosDetails({\n  name, initials, avatar, phoneLabel = \"phone\", phone, tag, actions = [], links = [],\n  hideAlerts = false, onHideAlertsChange, hideAlertsLabel = \"Hide Alerts\", blockLabel = \"Block Contact\", onBlock,\n  photos, sharedLinks, attachments,\n  onBack, backdrop, progress, scroll, open = true, onExited, className, style, ...props\n}: IosDetailsProps) {\n  const letters = initials ?? name.trim().split(/\\s+/).slice(0, 2).map(p => p[0] ?? \"\").join(\"\").toUpperCase();\n  const titleId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const nameInk = useRef<HTMLSpanElement>(null);\n  const blur = useRef<HTMLDivElement>(null);\n  const scrim = useRef<HTMLDivElement>(null);\n  const timeline = useRef<Animation[] | null>(null);\n  const collapse = useRef<Animation[] | null>(null);\n  const scrolled = useRef(0);\n  const landed = useRef(false);\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n\n  // The dismissal is derived during render, not in an effect: an effect leaves one committed frame\n  // with the screen already gone, and the exit never runs. `closing` also separates a screen that is\n  // leaving (fire `onExited`, stop taking clicks) from one mounted closed, which just sits dismissed.\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) { setSeenOpen(open); setClosing(!open); }\n\n  // Whether the surface is off the top, derived during render for a seeked scroll and kept in state\n  // for a live one. The collapsed pill only exists while it is true, so the screen at rest is the\n  // measured one with nothing extra painted over it.\n  const [liveCollapsing, setLiveCollapsing] = useState(false);\n  const collapsing = scroll !== undefined ? scroll > 0 : liveCollapsing;\n\n  const build = (phase: \"enter\" | \"exit\"): Animation[] | null => {\n    const node = content.current;\n    const box = root.current;\n    if (!node || !box) return null;\n    const height = box.getBoundingClientRect().height;\n    if (!height) return null;\n    const saturate = getComputedStyle(box).getPropertyValue(\"--ios-dt-saturate\").trim() || \"1\";\n    return runLayers(detailsLayers(node, blur.current, scrim.current, height, saturate), phase);\n  };\n  const land = (list: Animation[]) => {\n    if (timeline.current !== list) return;\n    list.forEach(stop);\n    timeline.current = null;\n    landed.current = true;\n  };\n\n  /**\n   * The collapse follows the scroll offset with no animation of its own: build the layers if the\n   * surface has left the top, then seek them to the offset. Back at the top the timeline is dropped,\n   * which is what keeps the measured screen free of a composited transform.\n   */\n  const seekCollapse = (top: number) => {\n    scrolled.current = top;\n    const node = content.current;\n    if (!node) return;\n    if (top <= 0) { collapse.current?.forEach(stop); collapse.current = null; return; }\n    let list = collapse.current;\n    if (!list) {\n      const back = node.querySelector<HTMLElement>(`:scope > [data-slot=\"back\"]`);\n      const title = node.querySelector<HTMLElement>(`:scope > [data-slot=\"name\"]`);\n      const ink = nameInk.current;\n      // The back button is Ø44 on both screens and the collapse never scales it, so its box is the\n      // ruler that turns whatever scale an embedding applies back into points. The name's own scale\n      // comes off its computed style, because a half-played entrance is still holding one.\n      const unit = back ? back.getBoundingClientRect().width / 44 : 1;\n      const nameScale = (title && parseFloat(getComputedStyle(title).scale)) || 1;\n      const inkWidth = ink && unit ? ink.getBoundingClientRect().width / unit / nameScale : 0;\n      const c = iosDetailsCollapse;\n      list = collapseLayers(node, inkWidth * iosDetailsMorph.name.scale + c.pill.padLeft + c.pill.padRight)\n        .map(({ el, from, to, duration, easing }) => el.animate([from, to], { duration, easing, fill: \"both\" }));\n      collapse.current = list;\n    }\n    seekTo(list, Math.min(top, iosDetailsCollapse.travel));\n  };\n\n  // Only unmount cancels the timeline. The two phases hand over to each other without one, so a\n  // dismissal can read the pose the entrance, or a drag, is still holding.\n  useEffect(() => () => {\n    timeline.current?.forEach(stop); timeline.current = null;\n    collapse.current?.forEach(stop); collapse.current = null;\n  }, []);\n\n  useLayoutEffect(() => {\n    if (prefersReducedMotion()) { landed.current = true; return; }\n    const previous = timeline.current;\n    const next = build(open ? \"enter\" : \"exit\");\n    previous?.forEach(stop);\n    timeline.current = next;\n    landed.current = false;\n    // One rebuild per phase, and `build` only reads refs.\n  }, [open]);\n\n  useLayoutEffect(() => {\n    // Reduced motion: the screen is simply there, and leaves at once. Its resting styles are the\n    // settled pose, so there is nothing to undo.\n    if (prefersReducedMotion()) { if (!open && closing) exited.current?.(); return; }\n    const list = timeline.current ?? build(open ? \"enter\" : \"exit\");\n    if (!list) return;\n    timeline.current = list;\n    const total = open ? iosDetailsMotion.enter : iosDetailsMotion.exit;\n    if (progress !== undefined) {\n      const time = clamp01(progress) * total;\n      seekTo(list, time);\n      // A settled checkpoint is screenshotted, so drop the timeline there and let the glass breathe.\n      if (open && time >= total) land(list);\n      return;\n    }\n    // Mounted closed rather than closing: hold the dismissed pose instead of playing a dismissal.\n    if (!open && !closing) { seekTo(list, total); return; }\n    let dropped = false;\n    list.forEach(animation => animation.play());\n    Promise.allSettled(list.map(animation => animation.finished)).then(() => {\n      if (dropped) return;\n      if (open) land(list);\n      else if (closing && timeline.current === list) exited.current?.();\n    });\n    return () => { dropped = true; };\n  }, [open, progress, closing]);\n\n  // The collapse is rebuilt whenever the pill comes or goes, and re-seeked whenever the offset is\n  // handed in rather than scrolled. Both paths end in the same paused, seeked timeline.\n  useLayoutEffect(() => {\n    collapse.current?.forEach(stop);\n    collapse.current = null;\n    if (scroll !== undefined && scroller.current) scroller.current.scrollTop = scroll;\n    seekCollapse(scroll ?? scrolled.current);\n    // `seekCollapse` only reads refs; the offset and the pill's presence are the whole input.\n  }, [collapsing, scroll]);\n\n  const onScroll = (event: ReactUIEvent<HTMLDivElement>) => {\n    if (scroll !== undefined) return;\n    const top = event.currentTarget.scrollTop;\n    seekCollapse(top);\n    if (top > 0 !== liveCollapsing) setLiveCollapsing(top > 0);\n  };\n\n  // The screen covers the conversation, so Escape backs out of it the way the back button does.\n  useEffect(() => {\n    if (!open || !onBack) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onBack(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onBack]);\n\n  /**\n   * Drag down to dismiss. It seeks the entrance backwards rather than writing its own styles, so a\n   * half-dragged screen is the same pose as a half-played entrance: the blur, the scrim, the sheet\n   * and the header morph all follow the finger together. Released short of the threshold, it plays\n   * the rest of the entrance forward from where it is. It only starts at the top of the list, so a\n   * drag anywhere below that scrolls instead, the way it does natively.\n   */\n  const drag = useRef<{ id: number; from: number; last: number; at: number; velocity: number; live: boolean } | null>(null);\n  const playIn = () => {\n    const list = timeline.current;\n    if (!list) return;\n    list.forEach(animation => animation.play());\n    Promise.allSettled(list.map(animation => animation.finished)).then(() => land(list));\n  };\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!open || closing || progress !== undefined || !onBack || !landed.current || prefersReducedMotion()) return;\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n    if ((scroller.current?.scrollTop ?? 0) > 0) return;\n    drag.current = { id: event.pointerId, from: event.clientY, last: event.clientY, at: event.timeStamp, velocity: 0, live: false };\n  };\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    const dy = event.clientY - state.from;\n    if (!state.live) {\n      // A tap, or a drag back up, leaves the controls alone.\n      if (dy < 8) return;\n      state.live = true;\n      event.currentTarget.setPointerCapture(state.id);\n      const list = timeline.current ?? build(\"enter\");\n      if (list) { timeline.current = list; seekTo(list, iosDetailsMotion.enter); }\n    }\n    const elapsed = event.timeStamp - state.at;\n    if (elapsed > 0) state.velocity = (event.clientY - state.last) / elapsed;\n    state.last = event.clientY;\n    state.at = event.timeStamp;\n    // The sheet rises by the frame's own height, so tracking the finger means seeking to wherever\n    // the rise stands `1 - dy / height` of the way up.\n    const height = root.current?.getBoundingClientRect().height || 1;\n    const back = riseSeek(clamp01(1 - dy / height)) * iosDetailsMotion.sheet;\n    if (timeline.current) seekTo(timeline.current, back);\n  };\n  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    drag.current = null;\n    if (!state.live) return;\n    swallowClick(root.current);\n    const dy = event.clientY - state.from;\n    // Ask for the dismissal first: the exit builds from the pose this drag is holding. Then play the\n    // entrance back in, which springs the screen home if the consumer does not take the dismissal.\n    if (dy > iosDetailsMotion.dragCommit || state.velocity > iosDetailsMotion.dragVelocity) onBack?.();\n    playIn();\n  };\n\n  // The stack, in order. With no shared content it is the four measured cells on their measured\n  // tops; each group that is present pushes the ones under it down by its height plus the measured\n  // 20, and Block Contact stays last.\n  const groups: ReactNode[] = [];\n  let cursor = cell.top;\n  const place = (height: number, render: (top: number) => ReactNode) => {\n    groups.push(render(cursor));\n    cursor += height + cell.gap;\n  };\n  if (phone !== undefined) {\n    place(cell.twoLine, top => (\n      <Cell key=\"phone\" top={top} height={cell.twoLine}>\n        <span data-slot=\"phone-label\" className=\"absolute\" style={{ left: cell.inset, top: 16.875, fontSize: 13, lineHeight: \"16px\", transform: \"translateY(-0.6667px)\", color: \"var(--ios-dt-secondary)\" }}>{phoneLabel}</span>\n        <span data-slot=\"phone-value\" className=\"absolute\" style={{ left: cell.inset, top: 34.375, fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-label)\" }}>{phone}</span>\n        {tag && (\n          <span data-slot=\"tag\" className=\"absolute flex items-center justify-center\"\n            style={{ left: 312.3333, top: 19.3333, transform: \"translateY(0.3333px)\", width: 41, height: 11.3333, borderRadius: 3.5, background: \"var(--ios-dt-tag)\", color: \"var(--ios-dt-tag-label)\", fontSize: 8.5, lineHeight: 1, fontWeight: 700, letterSpacing: 0 }}>\n            {tag}\n          </span>\n        )}\n      </Cell>\n    ));\n  }\n  if (links.length > 0) {\n    place(links.length * cell.row, top => (\n      <Cell key=\"links\" top={top} height={links.length * cell.row}>\n        {links.map((link, index) => (\n          <div key={link.id} className=\"absolute\" style={{ left: 0, right: 0, top: index * cell.row, height: cell.row }}>\n            {index > 0 && <Separator top={0} />}\n            <button type=\"button\" data-slot=\"link\" onClick={link.onPress}\n              className={cn(\"absolute inset-0 flex items-center text-left\", rowFocus)}\n              style={{ paddingLeft: cell.inset, fontSize: 17, lineHeight: \"22px\", transform: \"translateY(0.6667px)\", color: \"var(--ios-dt-blue)\" }}>\n              {link.label}\n            </button>\n          </div>\n        ))}\n      </Cell>\n    ));\n  }\n  place(cell.row, top => (\n    <Cell key=\"hide-alerts\" top={top} height={cell.row}>\n      <div className=\"absolute inset-0 flex items-center justify-between\" style={{ paddingLeft: cell.inset, paddingRight: 14 }}>\n        <span data-slot=\"hide-alerts-label\" style={{ ...titleType, transform: \"translateY(0.6667px)\" }}>{hideAlertsLabel}</span>\n        <IosSwitch checked={hideAlerts} onChange={onHideAlertsChange} label={hideAlertsLabel} style={{ transform: \"translateY(0.3333px)\" }} />\n      </div>\n    </Cell>\n  ));\n  if (photos && photos.items.length > 0) place(photosHeight(photos), top => <PhotosCell key=\"photos\" section={photos} top={top} />);\n  if (sharedLinks && sharedLinks.items.length > 0) place(listHeight(sharedLinks), top => <ListCell key=\"shared-links\" section={sharedLinks} title=\"Links\" top={top} />);\n  if (attachments && attachments.items.length > 0) place(listHeight(attachments), top => <ListCell key=\"attachments\" section={attachments} title=\"Attachments\" top={top} />);\n  place(cell.row, top => (\n    <Cell key=\"block\" top={top} height={cell.row}>\n      <button type=\"button\" data-slot=\"block\" onClick={onBlock}\n        className={cn(\"absolute inset-0 flex items-center text-left\", rowFocus)}\n        style={{ paddingLeft: cell.inset, fontSize: 17, lineHeight: \"22px\", transform: \"translateY(0.6667px)\", color: \"var(--ios-dt-red)\" }}>\n        {blockLabel}\n      </button>\n    </Cell>\n  ));\n  // The page ends 20 below the last group: the measured gap, used as the bottom inset.\n  const pageHeight = cursor;\n\n  return (\n    <div ref={root} data-slot=\"ios-details\" data-state={open ? \"open\" : \"closing\"}\n      data-progress={progress === undefined ? undefined : clamp01(progress).toFixed(3)}\n      role=\"dialog\" aria-modal=\"true\" aria-labelledby={titleId}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd}\n      className={cn(\"relative isolate size-full select-none overflow-hidden\", vars, className)}\n      style={{ fontFamily: font, ...style }} {...props}>\n      {backdrop !== undefined && (\n        <div aria-hidden=\"true\" data-slot=\"backdrop\" className=\"absolute inset-0 -z-10 overflow-hidden\">\n          {/*\n            Blurring a box larger than the screen keeps the filter's own edge falloff off-screen, and\n            the same box takes the sheet's push-back: 60 of overhang is more than the 4% ever needs,\n            so no edge of the conversation comes into view. The blur is divided by that scale, so\n            what lands on screen is the measured σ18 either way.\n          */}\n          <div ref={blur} data-slot=\"details-blur\" className=\"absolute\"\n            style={{ inset: -60, background: \"var(--ios-dt-page)\", scale: String(iosDetailsMotion.backdropScale), filter: `blur(${(iosDetailsMotion.blur / iosDetailsMotion.backdropScale).toFixed(3)}px) saturate(var(--ios-dt-saturate))` }}>\n            <div className=\"absolute\" style={{ inset: 60 }}>{backdrop}</div>\n          </div>\n          <div ref={scrim} data-slot=\"details-scrim\" className=\"absolute inset-0\" style={{ background: \"var(--ios-dt-scrim)\" }} />\n        </div>\n      )}\n\n      {/* At rest the wrapper carries no transform: a transform node makes Chrome snap descendants to\n          whole CSS px, and the entrance is cancelled the moment it lands for exactly that reason. */}\n      <div ref={content} data-slot=\"details-content\" className=\"absolute inset-0\"\n        style={closing ? { pointerEvents: \"none\" } : undefined}>\n        {actions.map((action, index) => (\n          <GlassCircle key={action.id} size={54} data-slot=\"action\" data-action={action.id} aria-label={action.label}\n            aria-disabled={action.disabled || undefined} onClick={action.disabled ? undefined : action.onPress}\n            style={{ ...subpixel(195.6667), left: 100 + index * 74, color: action.disabled ? \"var(--ios-dt-glyph-off)\" : \"var(--ios-dt-glyph)\", mixBlendMode: action.disabled ? \"var(--ios-dt-glyph-blend)\" as CSSProperties[\"mixBlendMode\"] : undefined }}>\n            <ActionGlyph icon={action.icon} />\n          </GlassCircle>\n        ))}\n\n        {/* The cells scroll; the header above them does not, it collapses. With only the measured\n            stack the page is shorter than the screen, so there is nothing to scroll and nothing to\n            collapse, and the frame is the capture's. */}\n        <div ref={scroller} data-slot=\"details-scroll\" className=\"absolute inset-0\" onScroll={onScroll}\n          style={{ overflowY: scroll === undefined ? \"auto\" : \"hidden\", overscrollBehavior: \"contain\" }}>\n          <div data-slot=\"details-page\" className=\"relative\" style={{ height: pageHeight }}>{groups}</div>\n        </div>\n\n        {collapsing && (\n          // The nav bar's own glass, under the name once the header has collapsed into it: measured\n          // fill, rim and shadow from `ios-nav-bar.tsx`. Its width is the name's, so it is only in\n          // the tree while the collapse is running.\n          <span aria-hidden=\"true\" data-slot=\"collapsed-pill\" className=\"pointer-events-none absolute\" style={{\n            top: iosDetailsCollapse.pill.top, height: iosDetailsCollapse.pill.height, borderRadius: iosDetailsCollapse.pill.radius,\n            opacity: 0, background: \"var(--ios-dt-glass)\", boxShadow: \"var(--ios-dt-glass-rim), var(--ios-dt-glass-shadow)\",\n            backdropFilter: `blur(${iosDetailsCollapse.blur}px)`, WebkitBackdropFilter: `blur(${iosDetailsCollapse.blur}px)`,\n            ...continuous,\n          }} />\n        )}\n\n        <div aria-hidden=\"true\" data-slot=\"avatar\" className=\"absolute flex items-center justify-center overflow-hidden rounded-full text-white\"\n          style={{ left: 161, top: 62, width: 80, height: 80, fontSize: 37.5, lineHeight: 1, fontWeight: 650, background: \"linear-gradient(var(--ios-dt-av-top), var(--ios-dt-av-bottom))\" }}>\n          {avatar ?? letters}\n        </div>\n\n        <h1 id={titleId} data-slot=\"name\" className=\"absolute m-0 whitespace-nowrap text-center\"\n          style={{ left: 0, right: 0, top: 146.15, fontSize: 28, lineHeight: \"33px\", fontWeight: 700, letterSpacing: 0, color: \"var(--ios-dt-label)\" }}>\n          <span ref={nameInk}>{name}</span>\n        </h1>\n\n        <GlassCircle size={44} data-slot=\"back\" aria-label=\"Back\" onClick={onBack} style={{ left: 16, top: 62 }}>\n          <svg aria-hidden=\"true\" width=\"44\" height=\"44\" viewBox=\"0 0 44 44\" fill=\"none\" stroke=\"var(--ios-dt-glyph)\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M24.8 13.87 16.2 22.17 24.8 30.47\" />\n          </svg>\n        </GlassCircle>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-details.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "group-details",
      "title": "iOS group details",
      "description": "A group conversation's details screen: the stacked group photo, an editable name, the members, and the shared photos and links.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/ios-details.json"
      ],
      "files": [
        {
          "path": "registry/imessage/group-details.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { IosSwitch, iosDetailsMorph, iosDetailsMotion, subpixel } from \"@/components/imessage/ios-details\";\n\n/**\n * iOS 26 **group** conversation details, the screen a group's name pill opens.\n *\n * No capture in `references/` shows it. Every number below is therefore one of three things, and the\n * comment on each says which:\n *\n * **A. Measured, carried over from the one-to-one screen** (`ios-details.tsx`, read off\n * `references/ios/captures/details-light.png` and `details-dark.png`). The two screens share their\n * whole frame, so these are used unchanged:\n *\n * - Back button Ø44 glass circle at (16, 62); the header photo slot Ø80 centred (201, 102).\n * - Name 28pt bold, ink centred on x 201, box top 146.15.\n * - Round glass action circles Ø54 with their centres on y 222.67, on a 74 pt pitch about x 201.\n * - Grouped cells span x 16-386 (370 wide), radius 26 with a continuous corner, 20 between groups,\n *   first cell top 269.6667. Row text starts 16 in from the cell's leading edge (x 32), rows that\n *   carry plain text are 52 tall, and a row separator is a 1 pt hairline inset 16 from both edges.\n * - Cell and button fills: 6% black in light, 12% white in dark. Blue #0088ff / #0091ff, red\n *   #ff383c / #ff4245, separator #dadadb / #3a3a3c, secondary label #848488 / #98989f.\n * - The screen sits over the conversation, blurred σ18 and washed by a 49% white / 59% black scrim.\n * - `iosDetailsMotion` and `iosDetailsMorph` are imported rather than restated.\n *\n * **B. Read out of ChatKit 26** (`/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework`,\n * loaded into a Mac Catalyst process with `-[UIDevice userInterfaceIdiom]` swizzled to Phone so\n * `+[CKUIBehavior sharedBehaviors]` returns `CKUIBehaviorPhone`). Class and selector for each:\n *\n * | Value | Where it comes from |\n * |---|---|\n * | Participant row 64 tall | `-[CKUIBehaviorPhone detailsContactCellMinimumHeight]` = 64, and `+[CKDetailsContactsStandardTableViewCell preferredHeight]` returns the same 64 under Phone (40 under Mac) |\n * | Participant avatar Ø37 | `-[CKUIBehaviorPhone detailsViewContactImageDiameter]` = `-detailsAvatarDiameter` = 37 |\n * | Avatar to name gap 12 | `-[CKUIBehaviorPhone detailsContactAvatarLabelSpacing]` = 12 |\n * | Add Member row 44 tall | `+[CKDetailsAddMemberStandardCell preferredHeight]` = 44 |\n * | Add Member button Ø37 | `-[CKUIBehaviorPhone detailsAddButtonDiameter]` = 37 |\n * | Add Member glyph | `-[CKDetailsAddMemberStandardCell initWithStyle:reuseIdentifier:]` loads `+[UIImage systemImageNamed:@\"plus\"]` (the literal is in the disassembly at +216) and tints it `detailsTextColor` |\n * | Add Member circle fill | `-[CKUITheme detailsAddButtonBackgroundColor]` = rgba(118,118,128,0.12) light, 0.24 dark |\n * | Blue for links and Add Member | `-[CKUITheme detailsTextColor]` and `-detailsSeeAllButtonTextColor` = #0088ff light / #0091ff dark, which is exactly the blue already measured off `details-light.png` |\n * | Row chevron colour | `-[CKUITheme detailsContactCellChevronColor]` = rgba(0,0,0,0.259) light, rgba(255,255,255,0.247) dark |\n * | Stacked group photo | `-[CKDetailsAvatarPancakeView addConstraints]` reads `detailsAvatarDiameter` 37, `detailsAvatarCutoutDiameter` 41 and `detailsAvatarPancakeViewOverlapOffset` 13.5. Instantiating the view with three avatars lays them out at x 0 / 13.5 / 27, all on the same y, each behind a Ø41 knockout centred on it, and puts the **leading** avatar in front. So three heads span 64, each one punching a 2 pt ring out of the one behind it |\n * | Copy | `ChatKit.loctable` (en): `ADD_MEMBER` \"Add Member\", `LEAVE_CONVERSATION` \"Leave this Conversation\", `DETAILS_VIEW_HIDE_ALERTS_TOGGLE_TITLE` \"Hide Alerts\", `GROUP_NAME_PLACEHOLDER` \"Enter a Group Name\", `GROUP_NAME_LABEL` \"Name\", `SEE_ALL_PHOTOS_TITLE` \"See All Photos\", `SEE_ALL_LINKS_TITLE` \"See All Links\" |\n * | Two action buttons, not three | `CKDetailsGroupNameCell` carries exactly `_phoneButton` and `_facetimeVideoButton` (with `showPhoneButton` / `showFaceTimeVideoButton`); there is no mail button on a group |\n *\n * Three ChatKit values that were read and deliberately **not** used, so nobody re-derives them:\n * `+[CKDetailsContactsTableViewCell marginWidth]` = 56 (the same under both idioms, and it does not\n * reconcile with this screen's measured 16 pt content inset, so the name column is placed as\n * 16 + 37 + 12 = 65 instead); `-[CKUITheme detailsContactCellTitleColor]` = 84.7% label (the measured\n * screen paints row text at full strength, and one screen cannot have both); and\n * `+[CKDetailsGroupCountCell preferredHeight]` = 22 with `DETAILS_VIEW_GROUP_COUNT_TEXT` \"%lu PEOPLE\",\n * a section header that will not fit in the measured 20 pt gap above the first cell.\n *\n * **C. Judgement**, called out again in the report. Nothing here is measured:\n *\n * - The header stack is the ChatKit pancake scaled ×1.25, so that three heads span exactly the\n *   measured Ø80 header slot: avatars Ø46.25, step 16.875, knockout ring 2.5. The ×1.25 is the\n *   judgement; the proportions inside it are the framework's.\n * - The knockout is transparent rather than filled with `detailsGroupPhotoBackgroundColor`\n *   (#ececec / #1e1e1e), because this screen has no opaque ground: the blurred conversation shows\n *   through the ring.\n * - The row chevron's ink box (6.4 × 11.2, stroke 2.2) and the participant separator's leading inset\n *   (aligned to the name column at 65, not to the measured 16).\n * - The Photos strip: three square tiles across the cell's 16 pt insets with the 4 pt gaps and\n *   radius 12 measured on `photo-picker-light.png`, which works out at 110 pt tiles in a 142 pt row.\n * - The section order (participants, photos, links, Hide Alerts, Leave), chosen to mirror the\n *   measured one-to-one screen, which puts its destructive row last.\n * - Every duration: the presentation is `iosDetailsMotion`, whose timings that file already records\n *   as unmeasured.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * The one-to-one screen's palette verbatim (measured; see `ios-details.tsx`), plus the two fills\n * ChatKit vends for the Add Member button and the row chevron. Light/dark live in CSS variables so a\n * `.dark` ancestor flips the whole screen.\n */\nconst vars =\n  \"[--ios-dt-label:#000000] [--ios-dt-secondary:#848488] [--ios-dt-blue:#0088ff] [--ios-dt-red:#ff383c] \" +\n  \"[--ios-dt-fill:rgba(0,0,0,0.06)] [--ios-dt-separator:#dadadb] [--ios-dt-glyph:#000000] [--ios-dt-glyph-off:rgba(0,0,0,0.26)] [--ios-dt-glyph-blend:normal] [--ios-dt-av-top:#a9c2e1] [--ios-dt-av-bottom:#747fb9] \" +\n  \"[--ios-dt-track:rgba(0,0,0,0.21)] [--ios-dt-knob:#ffffff] [--ios-dt-add:rgba(118,118,128,0.12)] [--ios-dt-chevron:rgba(0,0,0,0.259)] \" +\n  \"[--ios-dt-scrim:rgba(255,255,255,0.573)] [--ios-dt-saturate:1] [--ios-dt-page:#ffffff] \" +\n  \"dark:[--ios-dt-label:#ffffff] dark:[--ios-dt-secondary:#98989f] dark:[--ios-dt-blue:#0091ff] dark:[--ios-dt-red:#ff4245] \" +\n  \"dark:[--ios-dt-fill:rgba(235,235,245,0.12)] dark:[--ios-dt-separator:#3a3a3c] dark:[--ios-dt-glyph:#ffffff] dark:[--ios-dt-glyph-off:rgba(255,255,255,0.26)] dark:[--ios-dt-glyph-blend:plus-lighter] dark:[--ios-dt-av-top:#575368] dark:[--ios-dt-av-bottom:#302649] \" +\n  \"dark:[--ios-dt-track:rgba(255,255,255,0.28)] dark:[--ios-dt-add:rgba(118,118,128,0.24)] dark:[--ios-dt-chevron:rgba(255,255,255,0.247)] \" +\n  \"dark:[--ios-dt-scrim:rgba(0,0,0,0.587)] dark:[--ios-dt-saturate:1.05] dark:[--ios-dt-page:#000000]\";\n\n/** Apple's continuous corner. Browsers without `corner-shape` fall back to a plain round corner. */\nconst continuous = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/** The measured frame this screen shares with `ios-details.tsx`. */\nexport const groupDetailsMetrics = {\n  /** Grouped cells: x 16-386, radius 26, 20 apart, the first one at 269.6667. */\n  cellLeft: 16, cellWidth: 370, cellRadius: 26, cellGap: 20, cellsTop: 269.6667,\n  /** Row text and the separator's trailing inset, both 16 in from the cell's own edge. */\n  rowInset: 16,\n  /** A plain text row (Hide Alerts, Leave, See All), measured. */\n  textRow: 52,\n  /** ChatKit: participant rows are 64 tall with a Ø37 avatar 12 from its name; Add Member is 44. */\n  participantRow: 64, avatar: 37, avatarGap: 12, addRow: 44, addButton: 37,\n  /** ChatKit's pancake, and the ×1.25 that fits three of them across the measured Ø80 slot. */\n  stackScale: 1.25, stackAvatar: 37, stackStep: 13.5, stackRing: 2,\n  /** The header slot, measured: Ø80 centred (201, 102). */\n  headerPhoto: 80, headerCentre: { x: 201, y: 102 },\n} as const;\n\n/**\n * Photos strip: the 4 pt gaps and the radius 12 are measured on `photo-picker-light.png`; three\n * across the cell's own 16 pt insets then gives 110 pt tiles in a 142 pt row. UNVERIFIED.\n */\nconst photoTile = (groupDetailsMetrics.cellWidth - 2 * groupDetailsMetrics.rowInset - 2 * 4) / 3;\n\nexport type GroupParticipant = {\n  id: string;\n  name: string;\n  /** Two letters. Ignored when `avatar` is given. */\n  initials?: string;\n  /** Replaces the initials circle (an <img>, say). */\n  avatar?: ReactNode;\n  onPress?: () => void;\n};\n\nexport type GroupDetailsAction = {\n  id: string;\n  label: string;\n  icon: \"phone\" | \"video\";\n  /** Unavailable actions keep the glass circle and drop the glyph to tertiary label (measured). */\n  disabled?: boolean;\n  onPress?: () => void;\n};\n\nexport type GroupDetailsPhoto = { id: string; src: string; alt: string; onPress?: () => void };\nexport type GroupDetailsLink = { id: string; title: string; host?: string; onPress?: () => void };\n\nexport type GroupDetailsProps = Omit<ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  /** The group's name. Empty shows ChatKit's own placeholder, \"Enter a Group Name\". */\n  name: string;\n  /** Makes the name an editable field, the way a group's name is in native. */\n  onNameChange?: (next: string) => void;\n  namePlaceholder?: string;\n  /** Drawn as ChatKit's stacked pancake: the first three, leading one in front. */\n  participants?: GroupParticipant[];\n  /** Defaults to the two a group gets in ChatKit: audio and FaceTime video. */\n  actions?: GroupDetailsAction[];\n  addMemberLabel?: string;\n  onAddMember?: () => void;\n  photos?: GroupDetailsPhoto[];\n  onSeeAllPhotos?: () => void;\n  seeAllPhotosLabel?: string;\n  links?: GroupDetailsLink[];\n  onSeeAllLinks?: () => void;\n  seeAllLinksLabel?: string;\n  hideAlerts?: boolean;\n  onHideAlertsChange?: (next: boolean) => void;\n  hideAlertsLabel?: string;\n  leaveLabel?: string;\n  onLeave?: () => void;\n  onBack?: () => void;\n  /** The conversation behind the screen; rendered blurred and washed out. */\n  backdrop?: ReactNode;\n  /**\n   * Seek the presentation to this fraction instead of playing it, which is what the harness does:\n   * while `open`, 0 is dismissed and 1 is settled; while it is closing, 0 is settled and 1 is gone.\n   * Leave it unset for the real thing.\n   */\n  progress?: number;\n  /** False plays the dismissal; `onExited` fires when it is over, and the consumer unmounts then. */\n  open?: boolean;\n  onExited?: () => void;\n};\n\ntype Pose = Record<string, string>;\ntype Layer = { el: HTMLElement; from: Pose; to: Pose; duration: number; delay: number; easing: string };\n\nfunction prefersReducedMotion() {\n  return typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\nfunction clamp01(value: number) { return Math.max(0, Math.min(1, value)); }\nfunction stop(animation: Animation) { try { animation.cancel(); } catch { /* already gone */ } }\n\n/**\n * Where in the rise the sheet stands `covered` of the way up: the entrance curve read backwards, so a\n * drag scrubs the timeline instead of leaving the screen nearly still under a finger that has already\n * moved 200 pt. Same control points as `iosDetailsMotion.ease`.\n */\nconst sheetCurve = [0.32, 0.72, 0, 1] as const;\nfunction riseSeek(covered: number): number {\n  const [x1, y1, x2, y2] = sheetCurve;\n  const at = (a: number, b: number, t: number) => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t;\n  let low = 0, high = 1, t = 0.5;\n  for (let step = 0; step < 24; step++) { t = (low + high) / 2; if (at(y1, y2, t) < covered) low = t; else high = t; }\n  return at(x1, x2, t);\n}\n\n/** What an element is holding right now, so a dismissal can start from a half-played entrance or a drag. */\nfunction poseNow(el: HTMLElement, shape: Pose): Pose {\n  const style = getComputedStyle(el);\n  const pose: Pose = {};\n  for (const key of Object.keys(shape)) pose[key] = style.getPropertyValue(key.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`)) || shape[key];\n  return pose;\n}\n\n/**\n * Every layer of the presentation, as the pose it holds while dismissed and the pose it settles on.\n * The settled pose is what the element already carries at rest, so cancelling the timeline when it\n * lands leaves the screen with no animation, no transform and its glass intact.\n *\n * The header morphs out of the conversation's own nav bar, exactly as the one-to-one screen does:\n * the group photo is Ø60 there (`-[CKUIBehaviorPhone groupAvatarViewSize]` = 60 × 60, which is also\n * the measured nav-bar avatar) and Ø80 here, and the name is 17pt there and 28pt here, so\n * `iosDetailsMorph` transfers unchanged.\n */\nfunction groupLayers(content: HTMLElement, blur: HTMLElement | null, scrim: HTMLElement | null, height: number, saturate: string): Layer[] {\n  const m = iosDetailsMotion;\n  const layers: Layer[] = [];\n  const add = (el: HTMLElement | null, from: Pose, to: Pose, duration: number, delay = 0, easing: string = m.ease) => {\n    if (el) layers.push({ el, from, to, duration, delay, easing });\n  };\n  const one = (slot: string) => content.querySelector<HTMLElement>(`[data-slot=\"${slot}\"]`);\n  const each = (slot: string) => Array.from(content.querySelectorAll<HTMLElement>(`[data-slot=\"${slot}\"]`));\n  // The chrome that both screens share is already on screen, in the same place: it cancels the\n  // sheet's rise exactly (same duration, same easing) and morphs out of the nav bar instead.\n  const stay = (dy: number) => `0px ${(dy - height).toFixed(3)}px`;\n\n  add(blur,\n    { filter: `blur(0px) saturate(${saturate})`, scale: \"1\" },\n    { filter: `blur(${(m.blur / m.backdropScale).toFixed(3)}px) saturate(${saturate})`, scale: String(m.backdropScale) },\n    m.sheet);\n  add(scrim, { opacity: \"0\" }, { opacity: \"1\" }, m.dim, 0, \"ease-out\");\n  add(content, { translate: `0px ${height}px` }, { translate: \"0px 0px\" }, m.sheet);\n  add(one(\"back\"), { translate: stay(0) }, { translate: \"0px 0px\" }, m.sheet);\n  add(one(\"back\"), { opacity: \"0\" }, { opacity: \"1\" }, m.dim, 0, \"ease-out\");\n  add(one(\"avatar\"), { translate: stay(iosDetailsMorph.avatar.dy), scale: String(iosDetailsMorph.avatar.scale) }, { translate: \"0px 0px\", scale: \"1\" }, m.sheet);\n  add(one(\"name\"), { translate: stay(iosDetailsMorph.name.dy), scale: String(iosDetailsMorph.name.scale) }, { translate: \"0px 0px\", scale: \"1\" }, m.sheet);\n  each(\"action\").forEach((el, index) => add(el, { translate: `0px ${m.actionRise}px` }, { translate: \"0px 0px\" }, m.actionDuration, m.actionStart + index * m.actionStagger));\n  each(\"cell\").forEach((el, index) => add(el, { translate: `0px ${m.cellRise}px` }, { translate: \"0px 0px\" }, m.cellDuration, m.cellStart + index * m.cellStagger));\n  return layers;\n}\n\n/** Web Animations, not a rAF loop or a transition, so `document.getAnimations()` can seek a frame. */\nfunction runLayers(layers: Layer[], phase: \"enter\" | \"exit\"): Animation[] {\n  const m = iosDetailsMotion;\n  return layers.map(({ el, from, to, duration, delay, easing }) => phase === \"enter\"\n    ? el.animate([from, to], { duration, delay, easing, fill: \"both\" })\n    // One flat span on the way out, so the shared chrome's counter-translate still cancels the\n    // sheet's exactly, and it starts from wherever the layer is now (settled, or mid-drag).\n    : el.animate([poseNow(el, from), from], { duration: m.exit, easing: m.exitEase, fill: \"both\" }));\n}\n\n/** Swallows the click a drag would otherwise leave behind on whatever control it started on. */\nfunction swallowClick(node: HTMLElement | null) {\n  if (!node) return;\n  const swallow = (event: Event) => { event.stopPropagation(); event.preventDefault(); };\n  node.addEventListener(\"click\", swallow, { capture: true, once: true });\n  setTimeout(() => node.removeEventListener(\"click\", swallow, true), 0);\n}\n\ntype GlassCircleProps = ComponentProps<\"button\"> & { size: number; \"data-slot\"?: string; \"data-action\"?: string };\n\nfunction GlassCircle({ size, className, style, children, ...rest }: GlassCircleProps) {\n  return (\n    <button type=\"button\"\n      className={cn(\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\", className)}\n      style={{ width: size, height: size, background: \"var(--ios-dt-fill)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\", ...style }}\n      {...rest}>\n      {children}\n    </button>\n  );\n}\n\n/**\n * The two glyphs a group's header carries, at the ink sizes measured inside the Ø54 circles of\n * `details-light.png` (phone x 118.0-136.0 y 213.67-231.33, video x 190.0-213.11 y 215.02-230.33).\n * Copied from `ios-details.tsx` rather than re-derived; the envelope has no place on a group.\n */\nfunction ActionGlyph({ icon }: { icon: GroupDetailsAction[\"icon\"] }) {\n  if (icon === \"phone\") {\n    return (\n      <svg aria-hidden=\"true\" width=\"18.36\" height=\"17.3333\" viewBox=\"1.72 1.25 13.5 13.5\" fill=\"currentColor\">\n        <path d=\"M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.6 17.6 0 0 0 4.168 6.608 17.6 17.6 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.68.68 0 0 0-.58-.122l-2.19.547a1.75 1.75 0 0 1-1.657-.459L5.482 8.062a1.75 1.75 0 0 1-.46-1.657l.548-2.19a.68.68 0 0 0-.122-.58z\" />\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden=\"true\" width=\"23\" height=\"15.3333\" viewBox=\"0 0 23 15.3333\" fill=\"currentColor\" style={{ transform: \"translateY(0.3333px)\" }}>\n      <rect x=\"0\" y=\"0\" width=\"16.3333\" height=\"15.3333\" rx=\"3.6\" />\n      <path d=\"M17.969 4.312 22.531 1.508Q23 1.22 23 1.77V13.53Q23 14.08 22.531 13.792L17.969 10.988Q17.5 10.7 17.5 10.15V5.15Q17.5 4.6 17.969 4.312Z\" />\n    </svg>\n  );\n}\n\n/** Trailing chevron on a participant row. UNVERIFIED geometry; the colour is ChatKit's. */\nfunction RowChevron() {\n  return (\n    <svg aria-hidden=\"true\" width=\"6.4\" height=\"11.2\" viewBox=\"0 0 6.4 11.2\" fill=\"none\"\n      stroke=\"var(--ios-dt-chevron)\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M1.1 1.1 5.3 5.6 1.1 10.1\" />\n    </svg>\n  );\n}\n\nfunction initialsOf(name: string) {\n  return name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\ntype CircleProps = { size: number; initials?: string; avatar?: ReactNode; style?: CSSProperties };\n\n/**\n * One head. The gradient and the 7/15 initials ratio are `avatar.tsx`'s measured values, restated\n * here through this screen's own `--ios-dt-av-*` variables so the file installs on its own.\n */\nfunction Head({ size, initials, avatar, style }: CircleProps) {\n  return (\n    <span aria-hidden=\"true\" className=\"absolute flex items-center justify-center overflow-hidden rounded-full text-white\"\n      style={{\n        width: size, height: size, fontSize: (size * 7) / 15, fontWeight: 600, lineHeight: 1, letterSpacing: 0,\n        background: \"linear-gradient(var(--ios-dt-av-top), var(--ios-dt-av-bottom))\", ...style,\n      }}>\n      {avatar ?? <span style={{ transform: \"translateY(0.02em)\" }}>{initials}</span>}\n    </span>\n  );\n}\n\n/**\n * ChatKit's `CKDetailsAvatarPancakeView`: up to three heads on one row, each stepped 13.5/37 of a\n * diameter to the trailing side, the leading one in front, and each punching a ring 2/37 of a\n * diameter wide out of the head behind it. Scaled ×1.25 here (judgement) so three of them span the\n * measured Ø80 header slot.\n */\nfunction GroupPhotoStack({ participants, scale }: { participants: GroupParticipant[]; scale: number }) {\n  const { stackAvatar, stackStep, stackRing } = groupDetailsMetrics;\n  const diameter = stackAvatar * scale;\n  const step = stackStep * scale;\n  const ring = stackRing * scale;\n  const heads = participants.slice(0, 3);\n  const count = Math.max(heads.length, 1);\n  const width = diameter + (count - 1) * step;\n  // The hole a head punches in the one behind it, in that trailing head's own box.\n  const hole = diameter / 2 + ring;\n  const cut = `radial-gradient(circle at ${(diameter / 2 - step).toFixed(4)}px ${(diameter / 2).toFixed(4)}px, transparent ${(hole - 0.25).toFixed(4)}px, #000 ${(hole + 0.25).toFixed(4)}px)`;\n  return (\n    <span aria-hidden=\"true\" className=\"relative block\" style={{ width, height: diameter }}>\n      {/* Painted back to front, so the leading head ends up on top the way the framework stacks it. */}\n      {heads.slice().reverse().map((person, index) => {\n        const position = heads.length - 1 - index;\n        return (\n          <Head key={person.id} size={diameter} initials={person.initials ?? initialsOf(person.name)} avatar={person.avatar}\n            style={position === 0 ? { left: 0, top: 0 } : { left: position * step, top: 0, maskImage: cut, WebkitMaskImage: cut }} />\n        );\n      })}\n      {heads.length === 0 && <Head size={diameter} initials=\"\" style={{ left: 0, top: 0 }} />}\n    </span>\n  );\n}\n\n/**\n * A grouped cell. Blink snaps a painted box to whole CSS px, so the fill sits on an integer box and\n * a transform carries the fraction, exactly as the one-to-one screen's cells do.\n */\nfunction Cell({ children }: { children: ReactNode }) {\n  return (\n    <div data-slot=\"cell\" className=\"relative\" style={{ width: groupDetailsMetrics.cellWidth }}>\n      <span aria-hidden=\"true\" data-slot=\"cell-fill\" className=\"absolute inset-0\" style={{\n        borderRadius: groupDetailsMetrics.cellRadius, background: \"var(--ios-dt-fill)\", ...continuous,\n      }} />\n      <div className=\"relative\">{children}</div>\n    </div>\n  );\n}\n\n/** The 1 pt hairline between rows. `inset` is how far its leading end sits in from the cell's edge. */\nfunction Separator({ inset }: { inset: number }) {\n  return <span aria-hidden=\"true\" data-slot=\"separator\" className=\"absolute\" style={{ left: inset, right: groupDetailsMetrics.rowInset, top: 0, height: 1, background: \"var(--ios-dt-separator)\" }} />;\n}\n\ntype RowProps = ComponentProps<\"button\"> & { height: number; \"data-slot\"?: string };\n\n/**\n * One row. A row with no handler is not a control: it renders as a plain block rather than an empty\n * button, so nothing lands on a focus stop that does nothing.\n */\nfunction Row({ height, className, style, children, onClick, ...rest }: RowProps) {\n  const shape = cn(\"relative flex w-full items-center text-left\", className);\n  const box: CSSProperties = { height, paddingLeft: groupDetailsMetrics.rowInset, paddingRight: groupDetailsMetrics.rowInset, ...style };\n  if (!onClick) return <div className={shape} style={box} {...(rest as ComponentProps<\"div\">)}>{children}</div>;\n  return (\n    <button type=\"button\" onClick={onClick}\n      className={cn(shape, \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\")}\n      style={{ ...box, borderRadius: groupDetailsMetrics.cellRadius }}\n      {...rest}>\n      {children}\n    </button>\n  );\n}\n\nexport function GroupDetails({\n  name, onNameChange, namePlaceholder = \"Enter a Group Name\",\n  participants = [], actions,\n  addMemberLabel = \"Add Member\", onAddMember,\n  photos = [], onSeeAllPhotos, seeAllPhotosLabel = \"See All Photos\",\n  links = [], onSeeAllLinks, seeAllLinksLabel = \"See All Links\",\n  hideAlerts = false, onHideAlertsChange, hideAlertsLabel = \"Hide Alerts\",\n  leaveLabel = \"Leave this Conversation\", onLeave,\n  onBack, backdrop, progress, open = true, onExited, className, style, ...props\n}: GroupDetailsProps) {\n  const nameId = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const blur = useRef<HTMLDivElement>(null);\n  const scrim = useRef<HTMLDivElement>(null);\n  const timeline = useRef<Animation[] | null>(null);\n  const landed = useRef(false);\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n\n  // ChatKit gives a group exactly two of these: `CKDetailsGroupNameCell` carries a phone button and a\n  // FaceTime video button and no mail button.\n  const buttons: GroupDetailsAction[] = actions ?? [\n    { id: \"audio\", label: \"Audio\", icon: \"phone\" },\n    { id: \"video\", label: \"FaceTime\", icon: \"video\" },\n  ];\n\n  // The dismissal is derived during render, not in an effect: an effect leaves one committed frame\n  // with the screen already gone, and the exit never runs. `closing` also separates a screen that is\n  // leaving (fire `onExited`, stop taking clicks) from one mounted closed, which just sits dismissed.\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) { setSeenOpen(open); setClosing(!open); }\n\n  const build = (phase: \"enter\" | \"exit\"): Animation[] | null => {\n    const node = content.current;\n    const box = root.current;\n    if (!node || !box) return null;\n    const height = box.getBoundingClientRect().height;\n    if (!height) return null;\n    const saturate = getComputedStyle(box).getPropertyValue(\"--ios-dt-saturate\").trim() || \"1\";\n    return runLayers(groupLayers(node, blur.current, scrim.current, height, saturate), phase);\n  };\n  const land = (list: Animation[]) => {\n    if (timeline.current !== list) return;\n    list.forEach(stop);\n    timeline.current = null;\n    landed.current = true;\n  };\n\n  // Only unmount cancels the timeline. The two phases hand over to each other without one, so a\n  // dismissal can read the pose the entrance, or a drag, is still holding.\n  useEffect(() => () => { timeline.current?.forEach(stop); timeline.current = null; }, []);\n\n  useLayoutEffect(() => {\n    if (prefersReducedMotion()) { landed.current = true; return; }\n    const previous = timeline.current;\n    const next = build(open ? \"enter\" : \"exit\");\n    previous?.forEach(stop);\n    timeline.current = next;\n    landed.current = false;\n    // One rebuild per phase, and `build` only reads refs.\n  }, [open]);\n\n  useLayoutEffect(() => {\n    // Reduced motion: the screen is simply there, and leaves at once. Its resting styles are the\n    // settled pose, so there is nothing to undo.\n    if (prefersReducedMotion()) { if (!open && closing) exited.current?.(); return; }\n    const list = timeline.current ?? build(open ? \"enter\" : \"exit\");\n    if (!list) return;\n    timeline.current = list;\n    const total = open ? iosDetailsMotion.enter : iosDetailsMotion.exit;\n    const seek = (time: number) => list.forEach(animation => { animation.pause(); try { animation.currentTime = time; } catch { /* no timeline yet */ } });\n    if (progress !== undefined) {\n      const time = clamp01(progress) * total;\n      seek(time);\n      // A settled checkpoint is screenshotted, so drop the timeline there and let the glass breathe.\n      if (open && time >= total) land(list);\n      return;\n    }\n    // Mounted closed rather than closing: hold the dismissed pose instead of playing a dismissal.\n    if (!open && !closing) { seek(total); return; }\n    let dropped = false;\n    list.forEach(animation => animation.play());\n    Promise.allSettled(list.map(animation => animation.finished)).then(() => {\n      if (dropped) return;\n      if (open) land(list);\n      else if (closing && timeline.current === list) exited.current?.();\n    });\n    return () => { dropped = true; };\n  }, [open, progress, closing]);\n\n  // The screen covers the conversation, so Escape backs out of it the way the back button does.\n  useEffect(() => {\n    if (!open || !onBack) return;\n    const onKey = (event: KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onBack(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onBack]);\n\n  /**\n   * Drag down to dismiss. It seeks the entrance backwards rather than writing its own styles, so a\n   * half-dragged screen is the same pose as a half-played entrance. A group's list can be longer than\n   * the screen, so the drag only starts when the list is already at its top, which is what native\n   * does too; below that the finger scrolls.\n   */\n  const drag = useRef<{ id: number; from: number; last: number; at: number; velocity: number; live: boolean } | null>(null);\n  const playIn = () => {\n    const list = timeline.current;\n    if (!list) return;\n    list.forEach(animation => animation.play());\n    Promise.allSettled(list.map(animation => animation.finished)).then(() => land(list));\n  };\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!open || closing || progress !== undefined || !onBack || !landed.current || prefersReducedMotion()) return;\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n    if ((scroller.current?.scrollTop ?? 0) > 0) return;\n    drag.current = { id: event.pointerId, from: event.clientY, last: event.clientY, at: event.timeStamp, velocity: 0, live: false };\n  };\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    const dy = event.clientY - state.from;\n    if (!state.live) {\n      // A tap, or a drag back up, leaves the controls alone.\n      if (dy < 8) return;\n      state.live = true;\n      event.currentTarget.setPointerCapture(state.id);\n      const list = timeline.current ?? build(\"enter\");\n      if (list) { timeline.current = list; list.forEach(animation => { animation.pause(); try { animation.currentTime = iosDetailsMotion.enter; } catch { /* no timeline yet */ } }); }\n    }\n    const elapsed = event.timeStamp - state.at;\n    if (elapsed > 0) state.velocity = (event.clientY - state.last) / elapsed;\n    state.last = event.clientY;\n    state.at = event.timeStamp;\n    // The sheet rises by the frame's own height, so tracking the finger means seeking to wherever\n    // the rise stands `1 - dy / height` of the way up.\n    const height = root.current?.getBoundingClientRect().height || 1;\n    const back = riseSeek(clamp01(1 - dy / height)) * iosDetailsMotion.sheet;\n    timeline.current?.forEach(animation => { animation.pause(); try { animation.currentTime = back; } catch { /* no timeline yet */ } });\n  };\n  const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state || event.pointerId !== state.id) return;\n    drag.current = null;\n    if (!state.live) return;\n    swallowClick(root.current);\n    const dy = event.clientY - state.from;\n    // Ask for the dismissal first: the exit builds from the pose this drag is holding. Then play the\n    // entrance back in, which springs the screen home if the consumer does not take the dismissal.\n    if (dy > iosDetailsMotion.dragCommit || state.velocity > iosDetailsMotion.dragVelocity) onBack?.();\n    playIn();\n  };\n\n  const { cellLeft, cellGap, cellsTop, rowInset, textRow, participantRow, avatar, avatarGap, addRow, addButton, headerPhoto, stackScale } = groupDetailsMetrics;\n  const nameColumn = rowInset + avatar + avatarGap;\n  const stackWidth = (avatar + Math.max(Math.min(participants.length, 3) - 1, 0) * groupDetailsMetrics.stackStep) * stackScale;\n\n  return (\n    <div ref={root} data-slot=\"group-details\" data-state={open ? \"open\" : \"closing\"}\n      data-progress={progress === undefined ? undefined : clamp01(progress).toFixed(3)}\n      role=\"dialog\" aria-modal=\"true\" aria-label={name || namePlaceholder}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd}\n      className={cn(\"relative isolate size-full select-none overflow-hidden\", vars, className)}\n      style={{ fontFamily: font, ...style }} {...props}>\n      {backdrop !== undefined && (\n        <div aria-hidden=\"true\" data-slot=\"backdrop\" className=\"absolute inset-0 -z-10 overflow-hidden\">\n          {/*\n            Blurring a box larger than the screen keeps the filter's own edge falloff off-screen, and\n            the same box takes the sheet's push-back. The blur is divided by that scale, so what lands\n            on screen is the measured σ18 either way.\n          */}\n          <div ref={blur} data-slot=\"details-blur\" className=\"absolute\"\n            style={{ inset: -60, background: \"var(--ios-dt-page)\", scale: String(iosDetailsMotion.backdropScale), filter: `blur(${(iosDetailsMotion.blur / iosDetailsMotion.backdropScale).toFixed(3)}px) saturate(var(--ios-dt-saturate))` }}>\n            <div className=\"absolute\" style={{ inset: 60 }}>{backdrop}</div>\n          </div>\n          <div ref={scrim} data-slot=\"details-scrim\" className=\"absolute inset-0\" style={{ background: \"var(--ios-dt-scrim)\" }} />\n        </div>\n      )}\n\n      {/* At rest the wrapper carries no transform: a transform node makes Chrome snap descendants to\n          whole CSS px, and the entrance is cancelled the moment it lands for exactly that reason. */}\n      <div ref={content} data-slot=\"details-content\" className=\"absolute inset-0\"\n        style={closing ? { pointerEvents: \"none\" } : undefined}>\n        <GlassCircle size={44} data-slot=\"back\" aria-label=\"Back\" onClick={onBack} style={{ left: 16, top: 62 }}>\n          <svg aria-hidden=\"true\" width=\"44\" height=\"44\" viewBox=\"0 0 44 44\" fill=\"none\" stroke=\"var(--ios-dt-glyph)\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M24.8 13.87 16.2 22.17 24.8 30.47\" />\n          </svg>\n        </GlassCircle>\n\n        {/* The stack is centred on the measured slot's own centre, (201, 102). */}\n        <div data-slot=\"avatar\" className=\"absolute flex items-center justify-center\"\n          style={{ left: 201 - stackWidth / 2, top: 62, width: stackWidth, height: headerPhoto }}>\n          <GroupPhotoStack participants={participants} scale={stackScale} />\n        </div>\n\n        {onNameChange ? (\n          <input id={nameId} data-slot=\"name\" value={name} placeholder={namePlaceholder} aria-label=\"Name\"\n            onChange={event => onNameChange(event.target.value)}\n            className=\"absolute m-0 select-text bg-transparent p-0 text-center outline-none placeholder:text-[color:var(--ios-dt-secondary)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#0088ff]\"\n            style={{ left: 16, right: 16, top: 146.15, width: \"auto\", fontSize: 28, lineHeight: \"33px\", fontWeight: 700, letterSpacing: 0, color: \"var(--ios-dt-label)\", fontFamily: font }} />\n        ) : (\n          <h1 id={nameId} data-slot=\"name\" className=\"absolute m-0 whitespace-nowrap text-center\"\n            style={{ left: 0, right: 0, top: 146.15, fontSize: 28, lineHeight: \"33px\", fontWeight: 700, letterSpacing: 0, color: name ? \"var(--ios-dt-label)\" : \"var(--ios-dt-secondary)\" }}>\n            {name || namePlaceholder}\n          </h1>\n        )}\n\n        {buttons.map((action, index) => (\n          <GlassCircle key={action.id} size={54} data-slot=\"action\" data-action={action.id} aria-label={action.label}\n            aria-disabled={action.disabled || undefined} onClick={action.disabled ? undefined : action.onPress}\n            style={{\n              ...subpixel(195.6667),\n              // Measured 74 pt pitch about x 201: two circles sit at 164 and 238.\n              left: 201 - (buttons.length * 74 - 20) / 2 + index * 74,\n              color: action.disabled ? \"var(--ios-dt-glyph-off)\" : \"var(--ios-dt-glyph)\",\n              mixBlendMode: action.disabled ? \"var(--ios-dt-glyph-blend)\" as CSSProperties[\"mixBlendMode\"] : undefined,\n            }}>\n            <ActionGlyph icon={action.icon} />\n          </GlassCircle>\n        ))}\n\n        {/*\n          A group's member list runs past the bottom of the screen, so the cells scroll under a\n          header that stays put. The scroller starts at the measured first-cell top, so a cell\n          scrolling away is clipped there rather than sliding behind the name. Two things depend on\n          the header being outside it: the chrome that morphs out of the nav bar counter-translates\n          the sheet's whole rise, which a scroller would clip away, and the glass circles keep the\n          conversation as their backdrop.\n        */}\n        <div ref={scroller} data-slot=\"scroll\" className=\"absolute inset-x-0 bottom-0 overflow-y-auto overscroll-contain\" style={{ top: cellsTop }}>\n          <div className=\"relative\" style={{ paddingBottom: 24 }}>\n            <div className=\"flex flex-col items-start\" style={{ paddingLeft: cellLeft, gap: cellGap }}>\n              {(participants.length > 0 || onAddMember) && (\n                <Cell>\n                  <ul className=\"m-0 list-none p-0\">\n                    {participants.map((person, index) => (\n                      <li key={person.id} className=\"relative\">\n                        {index > 0 && <Separator inset={nameColumn} />}\n                        <Row height={participantRow} data-slot=\"participant\" onClick={person.onPress}>\n                          <Head size={avatar} initials={person.initials ?? initialsOf(person.name)} avatar={person.avatar}\n                            style={{ left: rowInset, top: (participantRow - avatar) / 2 }} />\n                          <span className=\"truncate\" style={{ paddingLeft: avatar + avatarGap, fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-label)\" }}>{person.name}</span>\n                          {person.onPress && <span className=\"ml-auto flex items-center pl-2\"><RowChevron /></span>}\n                        </Row>\n                      </li>\n                    ))}\n                    {onAddMember && (\n                      <li className=\"relative\">\n                        {participants.length > 0 && <Separator inset={nameColumn} />}\n                        <Row height={addRow} data-slot=\"add-member\" onClick={onAddMember}>\n                          <span aria-hidden=\"true\" className=\"absolute flex items-center justify-center rounded-full\"\n                            style={{ left: rowInset, top: (addRow - addButton) / 2, width: addButton, height: addButton, background: \"var(--ios-dt-add)\" }}>\n                            {/* ChatKit loads the \"plus\" SF Symbol here and tints it detailsTextColor. */}\n                            <svg aria-hidden=\"true\" width=\"17\" height=\"17\" viewBox=\"0 0 17 17\" fill=\"none\" stroke=\"var(--ios-dt-blue)\" strokeWidth=\"2.1\" strokeLinecap=\"round\">\n                              <path d=\"M8.5 2.4v12.2M2.4 8.5h12.2\" />\n                            </svg>\n                          </span>\n                          <span style={{ paddingLeft: addButton + avatarGap, fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-blue)\" }}>{addMemberLabel}</span>\n                        </Row>\n                      </li>\n                    )}\n                  </ul>\n                </Cell>\n              )}\n\n              {photos.length > 0 && (\n                <Cell>\n                  <ul className=\"m-0 flex list-none p-0\" style={{ gap: 4, padding: rowInset }}>\n                    {photos.slice(0, 3).map(photo => (\n                      <li key={photo.id}>\n                        <button type=\"button\" data-slot=\"photo\" onClick={photo.onPress} aria-label={photo.alt}\n                          className=\"block overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n                          style={{ width: photoTile, height: photoTile, borderRadius: 12 }}>\n                          {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral */}\n                          <img src={photo.src} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n                        </button>\n                      </li>\n                    ))}\n                  </ul>\n                  {onSeeAllPhotos && (\n                    <div className=\"relative\">\n                      <Separator inset={rowInset} />\n                      <Row height={textRow} data-slot=\"see-all-photos\" onClick={onSeeAllPhotos}\n                        style={{ fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-blue)\" }}>\n                        {seeAllPhotosLabel}\n                      </Row>\n                    </div>\n                  )}\n                </Cell>\n              )}\n\n              {links.length > 0 && (\n                <Cell>\n                  <ul className=\"m-0 list-none p-0\">\n                    {links.map((link, index) => (\n                      <li key={link.id} className=\"relative\">\n                        {index > 0 && <Separator inset={rowInset} />}\n                        <Row height={textRow} data-slot=\"link\" onClick={link.onPress}\n                          aria-label={link.host ? `${link.title}, ${link.host}` : undefined}>\n                          <span className=\"truncate\" style={{ fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-label)\" }}>{link.title}</span>\n                          {link.onPress && <span className=\"ml-auto flex items-center pl-2\"><RowChevron /></span>}\n                        </Row>\n                      </li>\n                    ))}\n                    {onSeeAllLinks && (\n                      <li className=\"relative\">\n                        <Separator inset={rowInset} />\n                        <Row height={textRow} data-slot=\"see-all-links\" onClick={onSeeAllLinks}\n                          style={{ fontSize: 17, lineHeight: \"22px\", color: \"var(--ios-dt-blue)\" }}>\n                          {seeAllLinksLabel}\n                        </Row>\n                      </li>\n                    )}\n                  </ul>\n                </Cell>\n              )}\n\n              <Cell>\n                <div className=\"flex items-center justify-between\" style={{ height: textRow, paddingLeft: rowInset, paddingRight: 14 }}>\n                  <span data-slot=\"hide-alerts-label\" style={{ fontSize: 17, lineHeight: \"22px\", transform: \"translateY(0.6667px)\", color: \"var(--ios-dt-label)\" }}>{hideAlertsLabel}</span>\n                  <IosSwitch checked={hideAlerts} onChange={onHideAlertsChange} label={hideAlertsLabel} style={{ transform: \"translateY(0.3333px)\" }} />\n                </div>\n              </Cell>\n\n              <Cell>\n                <Row height={textRow} data-slot=\"leave\" onClick={onLeave}\n                  style={{ fontSize: 17, lineHeight: \"22px\", transform: \"translateY(0.6667px)\", color: \"var(--ios-dt-red)\" }}>\n                  {leaveLabel}\n                </Row>\n              </Cell>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/group-details.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-plus-menu",
      "title": "iOS plus menu",
      "description": "The attachment sheet with its app rows and the embedded photo grid.",
      "files": [
        {
          "path": "registry/imessage/ios-plus-menu.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 `+` menu and the embedded Photos picker, measured from\n * `references/ios/captures/plus-menu-open-light.png` and `photo-picker-light.png` (402×874 @3x).\n *\n * Sheet: x 8.67–331.33, y 371–831.67 (322.67 × 460.67), continuous corner ≈24. It is glass over the\n * conversation, which stays sharp outside the sheet: the screen is not dimmed, but the sheet does\n * cast a soft shadow (see `--ios-pm-shadow-alpha`). Rows are 66.5 apart, the first centred at y 425.83;\n * the Ø39 app icon is centred at x 63.17 and the label starts at x 108 (ink cap height 17.5, i.e.\n * ≈25pt). \"Check In\" is clipped by the sheet's bottom edge, which is how the capture shows it.\n *\n * Known gap: the glass under-blurs. Native dissolves the 17pt bubble text behind the sheet\n * completely; ours still passes about 55% more high-frequency detail (high-pass std 9.6 against the\n * capture's 6.2 over x 200–330, y 380–700). Raising the radius cannot close it: on this element\n * Chromium renders `backdrop-filter: blur()` identically for every value from 2px to 200px, so the\n * 45px below is only nominal.\n *\n * Photos grid: three 129.67 square tiles per row with 1.33 gaps, x 5.33–396.67, first row top 485,\n * radius 12, with a 35 × 5 sheet grabber over the middle of the first row. Tiles here are solid\n * placeholders — the registry never ships photographs — so a raw diff of the grid against\n * `photo-picker-light.png` is comparing gradients with landscapes and says nothing about fidelity.\n *\n * ## Opening it\n *\n * Nothing in `references/` records this sheet in motion, so **no timing below is measured**. Both\n * ends are: at `progress` 0 the sheet *is* the composer's `+`, a Ø40 glass circle centred (48, 826)\n * with the composer's own 0.9 fill and 24 blur (all measured in `ios-composer.tsx`), and at 1 it is\n * the sheet measured above. In between the box grows between those two rectangles — a layout\n * interpolation, not a scale, so the rows never squash — its corner runs 20 → 24, its glass ramps to\n * the sheet's fill and 45 blur, its shadow fades up from nothing, and the rows fade and lift into\n * their settled places in a stagger. The composer's `+` fades out under the growing sheet.\n *\n * Durations are borrowed from the kit's measured neighbours, which is the whole of their authority.\n * The entrance is nine tenths of the way in about 200 ms and settled by 350, in the family of the\n * long-press menu's measured 380 ms open (`nativeMotion.longPressOpen` in `harness/scenarios.ts`);\n * the dismissal is back inside the `+` in about 200 ms, against that menu's measured 220 ms exit\n * (`messageActionsTiming.exit`). The row stagger copies the shape of the same menu's glyph stagger\n * (17 ms apart there) but its own numbers are a fit, not a measurement.\n *\n * Nothing behind the sheet dims. That is measured, and it is why there is no scrim to fade in:\n * `plus-menu-open-light.png` shows the conversation outside the sheet at full contrast, sharp to the\n * sheet's edge. The only backdrop that ramps is the sheet's own.\n *\n * Every animated value is a pure function of `progress`, so a seeked checkpoint renders the same\n * frame on every run. Leaving `progress` unset hands the sheet its own spring instead, which is the\n * live path and the only one that touches a clock; `prefers-reduced-motion` skips it to the end pose.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * `box-shadow` takes a comma separated list and `none` is only legal on its own, so `<shadow>, none`\n * throws the whole declaration away. Both slots therefore always hold a real shadow and the unused\n * one is fully transparent. Every class below is written out in full: Tailwind only compiles class\n * names it can read literally in the source.\n *\n * The light shadow is fitted to `plus-menu-open-light.png`: beside the sheet the white page darkens\n * by 11/255, above it by 9/255, and it is back to #ffffff about 30 past either edge. Dark shows the\n * bright rim the other glass surfaces use instead of a shadow.\n *\n * The fill is a channel triple plus an alpha, and the shadow keeps only its alpha, because both are\n * interpolated while the sheet opens: the fill runs from the composer's glass to this one, the shadow\n * from nothing to this one. A theme still owns the colour; only the alpha is animated, in a `calc()`\n * the theme's own value feeds. The dark rim is the same one the composer's `+` already draws, so it\n * needs no ramp of its own.\n */\nconst vars =\n  \"[--ios-pm-label:#000000] [--ios-pm-glass:255_255_255] [--ios-pm-alpha:0.62] [--ios-pm-shadow-alpha:0.066] [--ios-pm-rim:0_0_0_0_rgba(0,0,0,0)] \" +\n  \"dark:[--ios-pm-label:#f4f3f4] dark:[--ios-pm-glass:28_28_28] dark:[--ios-pm-alpha:0.72] dark:[--ios-pm-shadow-alpha:0] dark:[--ios-pm-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)]\";\n\n/**\n * Where the sheet grows from, and how the parts of the entrance are spaced inside it.\n *\n * `button`, `buttonAlpha` and `buttonBlur` are measured (`ios-composer.tsx`); everything else is a\n * fit. The stagger is in units of the entrance's own progress rather than milliseconds so that a\n * seeked frame and a played frame are the same function of one scalar: at the entrance's rate the\n * 0.045 step between rows is roughly 25 ms, in the family of the long-press menu's measured 17.\n */\nexport const plusMenuMotion = {\n  button: { centerX: 48, centerY: 826, size: 40 },\n  buttonAlpha: 0.9,\n  buttonBlur: 24,\n  /** Nominal: Chromium renders every blur radius alike on this element (see the file comment). */\n  sheetBlur: 45,\n  sheetSaturate: 1.9,\n  /** The sheet's glass takes over from the button's under it, before the box has grown enough to see. */\n  glassFade: 0.12,\n  /** A row fades and lifts through a rowSpan-long window, rowStagger apart, the bottom row first. */\n  rowStart: 0.16,\n  rowStagger: 0.045,\n  rowSpan: 0.34,\n  rowLift: 10,\n  /** The `+` glyph is gone by the time the sheet has grown past the button it came out of. */\n  attachFade: 0.3,\n} as const;\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\n/** Straight lerp, except that a finished sub-animation returns `b` itself and not `a + (b - a)`. */\nconst lerp = (a: number, b: number, u: number) => (u >= 1 ? b : a + (b - a) * u);\n/** Quadratic ease-out, the shape the kit's short fades use. */\nconst easeOut = (u: number) => 1 - (1 - u) * (1 - u);\n\n/** Apple's continuous corner. Browsers without `corner-shape` fall back to a plain round corner. */\nconst continuous = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\nexport type PlusMenuIcon = \"camera\" | \"photos\" | \"stickers\" | \"cash\" | \"audio\" | \"images\" | \"checkin\";\n\nexport type PlusMenuItem = {\n  id: string;\n  label: string;\n  icon: PlusMenuIcon;\n  /** Overrides the row's Ø39 artwork box. See `defaultPlusMenuItems` for the one row that needs it. */\n  iconSize?: number;\n  iconCenterX?: number;\n  onSelect?: () => void;\n};\n\n/**\n * Apple Cash is the one row whose artwork is not the shared Ø39 disc: in\n * `plus-menu-open-light.png` its black disc measures 35.0 across (x 49.33–84.33 on the row's\n * centre line, y 607.67–642.67), so it is both smaller and 3.67 further right than the Audio and\n * #images discs, which both land on the documented 43.67–82.67.\n */\nexport const defaultPlusMenuItems: PlusMenuItem[] = [\n  { id: \"camera\", label: \"Camera\", icon: \"camera\" },\n  { id: \"photos\", label: \"Photos\", icon: \"photos\" },\n  { id: \"stickers\", label: \"Stickers\", icon: \"stickers\" },\n  { id: \"cash\", label: \"Apple Cash\", icon: \"cash\", iconSize: 35, iconCenterX: 66.8333 },\n  { id: \"audio\", label: \"Audio\", icon: \"audio\" },\n  { id: \"images\", label: \"#images\", icon: \"images\" },\n  { id: \"checkin\", label: \"Check In\", icon: \"checkin\" },\n];\n\n/**\n * Sheet geometry, in points, read off the capture.\n *\n * `labelMaxWidth` is iOS shrink-to-fit. Six of the seven labels measure the same in the capture as\n * they do here at 24pt; \"Apple Cash\", the only one wider than 107.9, is drawn at 0.904 of that size\n * (its ink runs 108.0–214.33 rather than the 117.67 the full size needs, and its cap height is\n * 16.0 against 17.67 on \"Camera\"). Anything at or under the column keeps the full 24.\n */\nexport const plusMenuMetrics = {\n  left: 8.6667, top: 371, width: 322.6667, height: 460.6667, radius: 24,\n  rowPitch: 66.5, firstRowCenter: 425.8333, iconSize: 39, iconCenterX: 63.1667, labelX: 108, labelSize: 24,\n  labelMaxWidth: 107.9,\n} as const;\n\n/**\n * Photos-picker geometry, in points, read off `photo-picker-light.png`: three 129.67 tiles per row\n * with 1.33 gaps (x 5.33–396.67, first row top 485, radius 12) under a sheet grabber whose bar\n * measures 35.0 × 5.0 with its top 4.87 below the grid and its centre on x 201.\n *\n * The grabber's colour cannot be measured: it sits on a photograph, and beside it that photograph\n * reads (197,190,223) while under it it reads (137,133,167) — a drop of 60/57/56, which no single\n * translucent fill reproduces on all three channels. 30% black (and 30% white in dark) is the\n * system value that lands closest, and it is a fit, not a measurement.\n */\nexport const photoPickerMetrics = {\n  tileSize: 129.6667, gap: 1.3333, radius: 12,\n  grabberWidth: 35, grabberHeight: 5, grabberTop: 4.8333,\n} as const;\n\n/**\n * Spring toward `target`, and the only clock in this file. It starts at 0 when it is enabled, so a\n * sheet mounted open plays its entrance instead of appearing settled.\n *\n * Opening is ζ 0.86, ω 15 rad/s: measured off the running component, 0.90 at 200 ms and settled by\n * 350. Closing is stiffer, ζ 1, ω 24, which is back inside the `+` in about 200 ms. Both were chosen\n * to land in the family of the long-press menu's measured 380 ms open and 220 ms exit, and that is\n * the whole of their provenance: no capture in this repo records this sheet moving.\n *\n * `onSettled` fires when the spring reaches the target, which is what lets a caller unmount the sheet\n * after it has folded back into the `+`. A spring that starts already on its target never ran, so it\n * never reports. `prefers-reduced-motion` jumps to the target and reports on the next frame.\n */\nexport function usePlusMenuSpring(target: number, enabled: boolean, onSettled?: () => void) {\n  const [value, setValue] = useState(enabled ? 0 : target);\n  const state = useRef({ value: enabled ? 0 : target, velocity: 0, raf: 0, last: 0 });\n  const settled = useRef(onSettled);\n  useEffect(() => { settled.current = onSettled; });\n  useEffect(() => {\n    if (!enabled) return;\n    const s = state.current;\n    const rest = Math.abs(target - s.value) < 0.0005 && Math.abs(s.velocity) < 0.005;\n    const finish = () => { s.value = target; s.velocity = 0; s.raf = 0; setValue(target); settled.current?.(); };\n    if (rest) { s.value = target; s.velocity = 0; return; }\n    if (typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      s.raf = requestAnimationFrame(finish);\n      return () => { cancelAnimationFrame(s.raf); s.raf = 0; };\n    }\n    // Chosen once, from the direction this run travels: a dismissal that interrupts an entrance takes\n    // the stiffer spring from wherever the entrance had got to, so it never has to rewind slowly.\n    const closing = target < s.value;\n    const omega = closing ? 24 : 15;\n    const zeta = closing ? 1 : 0.86;\n    s.last = performance.now();\n    const step = (now: number) => {\n      const dt = Math.min(0.032, (now - s.last) / 1000);\n      s.last = now;\n      s.velocity += (omega * omega * (target - s.value) - 2 * zeta * omega * s.velocity) * dt;\n      s.value += s.velocity * dt;\n      if (Math.abs(target - s.value) < 0.0005 && Math.abs(s.velocity) < 0.005) { finish(); return; }\n      setValue(s.value);\n      s.raf = requestAnimationFrame(step);\n    };\n    s.raf = requestAnimationFrame(step);\n    return () => { cancelAnimationFrame(s.raf); s.raf = 0; };\n  }, [target, enabled]);\n  return enabled ? value : target;\n}\n\n/**\n * The seven app glyphs, traced from the capture: a silver camera lens, the Photos flower, a peeling\n * sticker, the Apple Cash disc, an audio waveform, the #images magnifier, and the Check In tick.\n * They are approximations of Apple's artwork, drawn at the measured Ø39 and colour-sampled from the\n * frame, not copies of the shipped icons.\n */\nfunction AppIcon({ icon, size = plusMenuMetrics.iconSize }: { icon: PlusMenuIcon; size?: number }) {\n  // Gradient ids are per instance: two mounted menus would otherwise share one id and one `defs`.\n  const id = useId();\n  const s = size;\n  const common = { width: s, height: s, viewBox: \"0 0 39 39\" } as const;\n  if (icon === \"camera\") {\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <defs>\n          <linearGradient id={`${id}-cam`} x1=\"0\" y1=\"0\" x2=\"0.3\" y2=\"1\"><stop offset=\"0\" stopColor=\"#eeeeee\" /><stop offset=\"0.5\" stopColor=\"#b6b6b6\" /><stop offset=\"1\" stopColor=\"#8f8f8f\" /></linearGradient>\n          <radialGradient id={`${id}-lens`} cx=\"0.38\" cy=\"0.32\" r=\"0.75\"><stop offset=\"0\" stopColor=\"#4a6fa8\" /><stop offset=\"0.45\" stopColor=\"#141b2c\" /><stop offset=\"1\" stopColor=\"#05070d\" /></radialGradient>\n        </defs>\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill={`url(#${id}-cam)`} />\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"13.4\" fill=\"#333333\" />\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"8.6\" fill={`url(#${id}-lens)`} />\n        <ellipse cx=\"17.6\" cy=\"16.6\" rx=\"2.2\" ry=\"3.4\" fill=\"#e8f1ff\" transform=\"rotate(-38 17.6 16.6)\" />\n        <ellipse cx=\"21.3\" cy=\"22.6\" rx=\"1.2\" ry=\"2.4\" fill=\"#7fc0ff\" opacity=\"0.75\" transform=\"rotate(-38 21.3 22.6)\" />\n      </svg>\n    );\n  }\n  if (icon === \"photos\") {\n    const petals = [\"#f7c942\", \"#f4842f\", \"#ef4b57\", \"#d3479f\", \"#8c5ad4\", \"#3f8ae0\", \"#43b8c6\", \"#77c34a\"];\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill=\"#fdfdfd\" />\n        <g style={{ mixBlendMode: \"multiply\" }} opacity=\"0.82\">\n          {petals.map((c, i) => (\n            <ellipse key={c} cx=\"19.5\" cy=\"12.4\" rx=\"5.0\" ry=\"7.4\" fill={c} transform={`rotate(${i * 45} 19.5 19.5)`} />\n          ))}\n        </g>\n      </svg>\n    );\n  }\n  if (icon === \"stickers\") {\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <defs>\n          <linearGradient id={`${id}-stk`} x1=\"0.1\" y1=\"1\" x2=\"0.9\" y2=\"0\"><stop offset=\"0\" stopColor=\"#5b93ef\" /><stop offset=\"1\" stopColor=\"#c3b4f6\" /></linearGradient>\n          <linearGradient id={`${id}-curl`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"><stop offset=\"0\" stopColor=\"#efeaff\" /><stop offset=\"1\" stopColor=\"#b9aef1\" /></linearGradient>\n        </defs>\n        {/* body: a disc whose top-right corner has peeled away */}\n        <path d=\"M19.5 0A19.5 19.5 0 1 0 39 19.5c0-1.5-.2-3-.5-4.4-7 6.6-14 4.5-17.6.8S16.6 6 24.4.8A19.4 19.4 0 0 0 19.5 0Z\" fill={`url(#${id}-stk)`} />\n        <path d=\"M24.4.8C16.6 6 17.3 12.2 20.9 15.9s10.6 5.8 17.6-.8A19.6 19.6 0 0 0 24.4.8Z\" fill={`url(#${id}-curl)`} />\n      </svg>\n    );\n  }\n  if (icon === \"cash\") {\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <defs><linearGradient id={`${id}-cash`} x1=\"0\" y1=\"0\" x2=\"0.3\" y2=\"1\"><stop offset=\"0\" stopColor=\"#ffffff\" /><stop offset=\"0.55\" stopColor=\"#dcdcdc\" /><stop offset=\"1\" stopColor=\"#f4f4f4\" /></linearGradient></defs>\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill=\"#1a1a1a\" />\n        <text x=\"19.5\" y=\"30.4\" textAnchor=\"middle\" fill={`url(#${id}-cash)`} style={{ fontFamily: font, fontSize: 30, fontWeight: 600 }}>$</text>\n      </svg>\n    );\n  }\n  if (icon === \"audio\") {\n    const bars = [5.2, 9.4, 13.4, 17.6, 13.4, 9.4, 5.2];\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <defs><linearGradient id={`${id}-aud`} x1=\"0\" y1=\"0\" x2=\"0.3\" y2=\"1\"><stop offset=\"0\" stopColor=\"#ff9a63\" /><stop offset=\"1\" stopColor=\"#f4593c\" /></linearGradient></defs>\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill={`url(#${id}-aud)`} />\n        {bars.map((h, i) => (\n          <rect key={i} x={19.5 + (i - 3) * 3.6 - 1.05} y={19.5 - h / 2} width=\"2.1\" height={h} rx=\"1.05\" fill=\"#ffffff\" />\n        ))}\n      </svg>\n    );\n  }\n  if (icon === \"images\") {\n    return (\n      <svg aria-hidden=\"true\" {...common}>\n        <defs><linearGradient id={`${id}-img`} x1=\"0\" y1=\"0\" x2=\"0.3\" y2=\"1\"><stop offset=\"0\" stopColor=\"#ff5f86\" /><stop offset=\"1\" stopColor=\"#ec1f52\" /></linearGradient></defs>\n        <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill={`url(#${id}-img)`} />\n        <g fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"1.5\">\n          <circle cx=\"17.6\" cy=\"17.4\" r=\"7.6\" />\n          <path d=\"M10 17.4h15.2M17.6 9.8c3.2 4.6 3.2 10.6 0 15.2M17.6 9.8c-3.2 4.6-3.2 10.6 0 15.2\" strokeWidth=\"1.15\" />\n          <path d=\"M23.2 23 29.4 29.2\" strokeWidth=\"2.6\" strokeLinecap=\"round\" />\n        </g>\n      </svg>\n    );\n  }\n  return (\n    <svg aria-hidden=\"true\" {...common}>\n      <circle cx=\"19.5\" cy=\"19.5\" r=\"19.5\" fill=\"#ffd42e\" />\n      <path d=\"M12.2 20.4 17.3 25.5 28.2 12.4\" fill=\"none\" stroke=\"#3a2a06\" strokeWidth=\"3.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * A row label, shrunk to fit `labelMaxWidth` the way iOS shrinks one. The font size is what changes,\n * not a transform: the line box keeps its 66.5 pitch, so a smaller face grows its own half-leading\n * by exactly as much as its ascent loses and the cap stays centred on the row (checked against both\n * \"Camera\" at 24 and \"Apple Cash\" at 21.7 in `plus-menu-open-light.png`).\n */\nfunction RowLabel({ label }: { label: string }) {\n  const m = plusMenuMetrics;\n  const ref = useRef<HTMLSpanElement>(null);\n  const [fontSize, setFontSize] = useState<number>(m.labelSize);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const fit = () => {\n      // The used width, not `getBoundingClientRect`: a row carries a lift and a fade while the sheet\n      // opens, and a rect would report the label mid-animation and re-fit it on every frame. Advance\n      // widths scale with the size and the tracking is zero, so one measurement at whatever size is\n      // currently applied recovers the natural width, and re-running is idempotent.\n      const style = getComputedStyle(el);\n      const applied = Number.parseFloat(style.fontSize) || m.labelSize;\n      const natural = (Number.parseFloat(style.width) * m.labelSize) / applied;\n      if (!Number.isFinite(natural) || natural <= 0) return;\n      setFontSize(natural > m.labelMaxWidth ? (m.labelSize * m.labelMaxWidth) / natural : m.labelSize);\n    };\n    fit();\n    document.fonts?.ready.then(fit).catch(() => {});\n  }, [label, m.labelMaxWidth, m.labelSize]);\n  return (\n    <span ref={ref} data-slot=\"label\" className=\"absolute whitespace-nowrap\"\n      style={{ left: m.labelX - m.left, top: 0, lineHeight: `${m.rowPitch}px`, fontSize, letterSpacing: 0, transform: \"translateY(-0.6667px)\", color: \"var(--ios-pm-label)\" }}>\n      {label}\n    </span>\n  );\n}\n\nexport type IosPlusMenuProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  items?: PlusMenuItem[];\n  /**\n   * 0 = the sheet is still the composer's `+`, 1 = settled. Leave unset to play `open` on a spring;\n   * pass it to seek the entrance instead, which is what the harness does.\n   */\n  progress?: number;\n  open?: boolean;\n  /**\n   * Fires once a dismissal has folded the sheet back into the `+`, so the caller can unmount it. The\n   * caller keeps the sheet mounted until then, exactly as `ios-messages-app.tsx` keeps the effects\n   * screen mounted after its own prop has cleared.\n   */\n  onExited?: () => void;\n  /**\n   * The composer row. Rendered under the sheet with its `+` faded out, as the capture shows. A menu\n   * handed to `IosMessagesApp` as an `overlay` leaves this unset: that shell draws its own composer,\n   * and the `+` in it fades all the same.\n   */\n  composer?: ReactNode;\n  /** Called by Escape, by the close control over the `+`, and by a tap anywhere outside the sheet. */\n  onDismiss?: () => void;\n};\n\nexport function IosPlusMenu({ items = defaultPlusMenuItems, progress, open = true, onExited, composer, onDismiss, className, style, ...props }: IosPlusMenuProps) {\n  const m = plusMenuMetrics;\n  const mo = plusMenuMotion;\n  const id = useId();\n\n  // The sheet outlives the `open` prop so the dismissal has frames to run in, and `closing` is\n  // derived during render rather than in an effect: an effect leaves one committed frame with the\n  // sheet already gone and the exit never runs. Same rule as `ios-messages-app.tsx`.\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) {\n    setSeenOpen(open);\n    setClosing(!open);\n  }\n\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; });\n  // Read by the spring when it settles, which happens a frame after this render has committed.\n  const openNow = useRef(open);\n  useEffect(() => { openNow.current = open; }, [open]);\n  const onSettled = useCallback(() => {\n    // A spring that settles on 1 has just opened; only the one that lands back on the `+` reports.\n    if (openNow.current) return;\n    exited.current?.();\n  }, []);\n\n  const spring = usePlusMenuSpring(open ? 1 : 0, progress === undefined, onSettled);\n  const t = progress === undefined ? spring : clamp01(progress);\n  const sheet = useRef<HTMLDivElement>(null);\n  // One tab stop for the sheet; the arrow keys walk the rows, as a menu does.\n  const [focusIndex, setFocusIndex] = useState(0);\n\n  // A seeked dismissal has no spring to report it: the caller owns the clock, so it is over when the\n  // sheet reads 0 and is back inside the `+`. Reported once, and from a ref rather than state, so\n  // finishing an exit never schedules a render of its own.\n  const reported = useRef(false);\n  useEffect(() => {\n    if (progress === undefined || !closing || t > 0) { reported.current = false; return; }\n    if (reported.current) return;\n    reported.current = true;\n    exited.current?.();\n  }, [progress, closing, t]);\n\n  // Escape closes it, so the sheet never depends on a tap outside to get out of the way.\n  useEffect(() => {\n    if (!open || !onDismiss) return;\n    const onKey = (event: globalThis.KeyboardEvent) => { if (event.key === \"Escape\") { event.preventDefault(); onDismiss(); } };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onDismiss]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const rows = Array.from(sheet.current?.querySelectorAll<HTMLButtonElement>('[role=\"menuitem\"]') ?? []);\n    if (!rows.length) return;\n    event.preventDefault();\n    const from = rows.indexOf(document.activeElement as HTMLButtonElement);\n    const current = from < 0 ? focusIndex : from;\n    const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? rows.length - 1 : event.key === \"ArrowDown\" ? (current + 1) % rows.length : (current - 1 + rows.length) % rows.length;\n    setFocusIndex(next);\n    rows[next]?.focus();\n  }\n\n  // The box the sheet grows out of: the composer's Ø40 `+`, in the same screen points the sheet uses.\n  const from = mo.button;\n  const fromLeft = from.centerX - from.size / 2;\n  const fromTop = from.centerY - from.size / 2;\n  const box = {\n    left: lerp(fromLeft, m.left, t),\n    top: lerp(fromTop, m.top, t),\n    width: lerp(from.size, m.width, t),\n    height: lerp(from.size, m.height, t),\n    radius: lerp(from.size / 2, m.radius, t),\n  };\n  // The sheet's fill takes over from the `+`'s while the two shapes still coincide, so the handover\n  // between the two measured glasses is never visible as a change of colour. The blur and the shadow\n  // ramp over the whole entrance instead: both reach past the box and both are what makes it read as\n  // a sheet rather than a white card.\n  const glass = easeOut(clamp01(t / mo.glassFade));\n  const attach = easeOut(clamp01(t / mo.attachFade));\n  const filter = `blur(${lerp(mo.buttonBlur, mo.sheetBlur, t).toFixed(2)}px) saturate(${lerp(1, mo.sheetSaturate, t).toFixed(2)})`;\n  const alive = t > 0 || open;\n\n  return (\n    <div data-slot=\"ios-plus-menu\" data-plus-menu={id} data-progress={t.toFixed(3)}\n      data-state={open ? \"open\" : t > 0 ? \"closing\" : \"closed\"}\n      className={cn(\"absolute inset-0 z-20 select-none\", vars, className)}\n      style={{ fontFamily: font, pointerEvents: \"none\", ...style }} {...props}>\n      {/* The `+` fades out under the sheet growing over it. Two rules, not one selector list: the\n          composer is inside this menu when a caller passes it to `composer`, and a sibling under the\n          app frame when the menu is handed to `IosMessagesApp` as an overlay. An unsupported `:has()`\n          would take a whole selector list down with it, so the reachable case stands on its own. */}\n      <style>{`[data-plus-menu=\"${id}\"] [data-slot=\"attach\"]{opacity:${(1 - attach).toFixed(3)};transform:scale(${(1 - 0.35 * attach).toFixed(3)})}\n[data-slot=\"ios-messages-app\"]:has([data-plus-menu=\"${id}\"]) [data-slot=\"attach\"]{opacity:${(1 - attach).toFixed(3)};transform:scale(${(1 - 0.35 * attach).toFixed(3)})}`}</style>\n      {composer !== undefined && <div data-slot=\"composer-slot\" className=\"pointer-events-auto absolute bottom-0 left-0 w-full\">{composer}</div>}\n      {/* A scrim, not a control: it is invisible, it fills the screen, and both Escape and the close\n          control below do the same job, so it stays out of the tab order and the accessibility tree. */}\n      {alive && <div aria-hidden=\"true\" data-slot=\"dismiss\" onClick={onDismiss} className=\"pointer-events-auto absolute inset-0 cursor-default\" />}\n      {/* The control that opened the sheet closes it again. It paints nothing: the `+` it stands on is\n          the composer's own, faded out above, and the capture shows no other control while the sheet\n          is open. The sheet covers its top once it has grown, which is why it is drawn under it. */}\n      {alive && onDismiss && (\n        <button type=\"button\" data-slot=\"close\" aria-label=\"Close attachments\" onClick={onDismiss}\n          className=\"pointer-events-auto absolute cursor-default bg-transparent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ left: fromLeft, top: fromTop, width: from.size, height: from.size, borderRadius: from.size / 2 }} />\n      )}\n      <div ref={sheet} data-slot=\"sheet\" role=\"menu\" aria-label=\"Attachments\" onKeyDown={onKeyDown} className=\"absolute overflow-hidden\"\n        style={{\n          left: box.left, top: box.top, width: box.width, height: box.height, borderRadius: box.radius, ...continuous,\n          background: `rgb(var(--ios-pm-glass) / calc(${(1 - glass).toFixed(4)} * ${mo.buttonAlpha} + ${glass.toFixed(4)} * var(--ios-pm-alpha)))`,\n          boxShadow: `0 5px 30px 6px rgb(0 0 0 / calc(var(--ios-pm-shadow-alpha) * ${t.toFixed(4)})), var(--ios-pm-rim)`,\n          backdropFilter: filter,\n          WebkitBackdropFilter: filter,\n          opacity: glass,\n          // A closed sheet is not just invisible: hidden takes its seven rows out of the tab order\n          // and out of the accessibility tree, which `opacity: 0` on its own would not.\n          visibility: t > 0 ? undefined : \"hidden\",\n          pointerEvents: t > 0.5 ? \"auto\" : \"none\",\n        }}>\n        {/* The rows are laid out against the settled sheet and held there while the box grows around\n            them, so they fade in where they belong instead of sliding out of the `+` with the box. */}\n        <div data-slot=\"sheet-content\" className=\"absolute\"\n          style={{ left: m.left - box.left, top: m.top - box.top, width: m.width, height: m.height }}>\n          {items.map((item, index) => {\n            const center = m.firstRowCenter + index * m.rowPitch - m.top;\n            const iconSize = item.iconSize ?? m.iconSize;\n            const iconCenterX = item.iconCenterX ?? m.iconCenterX;\n            // Staggered away from the `+`, so the row the growing box uncovers first is also the\n            // first to arrive. Which end iOS starts from is not recorded anywhere here; this is the\n            // order the geometry argues for, not a measurement.\n            const row = easeOut(clamp01((t - mo.rowStart - (items.length - 1 - index) * mo.rowStagger) / mo.rowSpan));\n            return (\n              <button key={item.id} type=\"button\" role=\"menuitem\" data-slot=\"item\" data-item={item.id} onClick={item.onSelect}\n                tabIndex={index === focusIndex ? 0 : -1} onFocus={() => setFocusIndex(index)}\n                className=\"absolute left-0 flex w-full items-center text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\"\n                style={{\n                  top: center - m.rowPitch / 2, height: m.rowPitch,\n                  // A settled row carries neither, so the sheet at rest renders exactly as it did\n                  // before there was an entrance at all.\n                  opacity: row >= 1 ? undefined : row,\n                  transform: row >= 1 ? undefined : `translateY(${((1 - row) * mo.rowLift).toFixed(2)}px)`,\n                }}>\n                <span aria-hidden=\"true\" className=\"absolute flex items-center justify-center\"\n                  style={{ left: iconCenterX - m.left - iconSize / 2, top: (m.rowPitch - iconSize) / 2, width: iconSize, height: iconSize }}>\n                  <AppIcon icon={item.icon} size={iconSize} />\n                </span>\n                <RowLabel label={item.label} />\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport type PhotoPickerGridProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Solid placeholder fills, one per tile. Never ship photographs in the registry. */\n  tiles?: string[];\n  columns?: number;\n  tileSize?: number;\n  gap?: number;\n  /** The sheet's drag bar, drawn over the first row of tiles as the capture shows it. */\n  grabber?: boolean;\n  onSelect?: (index: number) => void;\n};\n\n/** Measured placeholders that sit in the same tonal range as the capture's landscape thumbnails. */\nexport const photoPickerPlaceholders = [\n  \"linear-gradient(160deg, #c8175f 0%, #e8408a 45%, #7a8f2e 100%)\",\n  \"linear-gradient(180deg, #8fa2ad 0%, #4e6b5a 55%, #26361f 100%)\",\n  \"linear-gradient(200deg, #8d9aa1 0%, #55605c 50%, #2a3128 100%)\",\n  \"linear-gradient(170deg, #9fb0b8 0%, #5c7a5f 45%, #2c3a24 100%)\",\n  \"linear-gradient(190deg, #b7c9cf 0%, #7f9a63 55%, #b52f57 100%)\",\n  \"linear-gradient(150deg, #7fa04a 0%, #3f5c25 60%, #d9c02f 100%)\",\n];\n\nexport function PhotoPickerGrid({\n  tiles = photoPickerPlaceholders, columns = 3, tileSize = photoPickerMetrics.tileSize, gap = photoPickerMetrics.gap,\n  grabber = true, onSelect, className, style, ...props\n}: PhotoPickerGridProps) {\n  const g = photoPickerMetrics;\n  return (\n    // A group of buttons, not a listbox: choosing a photo inserts it, it does not leave one option\n    // marked selected, and a listbox would owe the keyboard a roving selection it never has.\n    <div data-slot=\"photo-picker-grid\" role=\"group\" aria-label=\"Recent photos\"\n      className={cn(\"relative grid select-none\", className)}\n      style={{ gridTemplateColumns: `repeat(${columns}, ${tileSize}px)`, gap, fontFamily: font, ...style }} {...props}>\n      {tiles.map((fill, index) => (\n        <button key={index} type=\"button\" aria-label={`Photo ${index + 1}`} data-slot=\"tile\"\n          onClick={() => onSelect?.(index)}\n          className=\"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{ width: tileSize, height: tileSize, borderRadius: g.radius, background: fill, ...continuous }} />\n      ))}\n      {grabber && (\n        <span aria-hidden=\"true\" data-slot=\"grabber\" className=\"pointer-events-none absolute left-1/2 [background:rgba(0,0,0,0.3)] dark:[background:rgba(255,255,255,0.3)]\"\n          style={{ top: g.grabberTop, width: g.grabberWidth, height: g.grabberHeight, marginLeft: -g.grabberWidth / 2, borderRadius: g.grabberHeight / 2 }} />\n      )}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-plus-menu.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "photo-picker",
      "title": "iOS photo picker",
      "description": "The Photos panel under the composer: a scrolling three-column grid with the sheet grabber and tile selection.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/photo-picker.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * The Photos picker iOS 26 opens inside the Messages composer.\n *\n * Measured from `references/ios/captures/photo-picker-light.png` (iOS 26.0, iPhone 17 Pro, 402×874 pt\n * at 3x). Every number below is a sub-pixel read of that frame unless it says otherwise; device pixels\n * are quoted where the point value is a third.\n *\n * ## What the capture actually shows\n *\n * A white panel, inset **5.333 (16 px)** from the screen's left, right and bottom edges, holding a\n * three-column grid of square thumbnails flush to its own left, right and top edges. Over the middle\n * of the first row sits a drag grabber. That is the whole of it: **no header, no title, no \"Recents\"\n * label, no \"All Photos\" button, no camera tile, no search field, no send or count button, and no\n * selection indicator on any tile.** Anything this file draws beyond the panel, the grid and the\n * grabber is marked unverified below and is not in `SPEC.md` as a measurement.\n *\n * The capture shows only two rows with empty white under them. That is the library, not the layout:\n * the iOS Simulator ships exactly six sample photos. The grid scrolls **vertically** and a real\n * library keeps going; at the capture's panel height a third row is visible for 121.3 of its 129.56.\n *\n * ## Panel\n *\n * | Part | Value | How |\n * |---|---|---|\n * | Inset (left, right, bottom) | 5.3333 (16 px) | white run x 16–1189, bottom-most white row 2605 of 2622 |\n * | Width at a 402 screen | 391.3333 | 1174 px |\n * | Top / height in the capture | 485 / 383.6667 | grid top 1455 px, panel bottom 2606 px |\n * | Fill | `#ffffff` | flat, sampled all over the empty area below the grid |\n * | Top corners | superellipse n 2.204, R **39.0** (rms 0.84 px) | 109 sub-pixel boundary points on the top-right corner |\n * | Bottom corners | superellipse n 2.204, R **57.5833** (rms 0.90 px) | 183 points on the bottom-left corner |\n * | Shadow / dim | none | the blurred backdrop reads a flat 244–246 right up to the panel edge |\n *\n * The plain circle is fitted separately, because it is what the panel actually renders (see the\n * clip-path note in the panel's own style): **36.1667** top (rms 0.94 px) and **53.5** bottom (rms\n * 1.27 px). The bottom corner is the device's own corner made concentric with the panel: 57.58 + 5.33\n * = 62.9, which is the iPhone 17 Pro display radius.\n *\n * ## Grid\n *\n * Three columns, flush to the panel on three sides, **1.6207 (4.862 px)** between tiles on both axes.\n * The three gaps are measured independently over 100+ sub-pixel boundary points each and agree:\n * 4.870, 4.859 across, 4.858 down. Measured edges at 3x: columns 16.0 | 404.238 → 409.108 |\n * 796.900 → 801.759 | 1190.0, rows 1455.0 → 1843.636 | 1848.494 → 2237.24.\n *\n * That makes a tile **129.364 wide** (`(width - 2 × gap) / 3`) and **129.5633 tall**: the columns\n * average 388.09 device px and the rows 388.69, a real 0.6 px difference that shows up the same way in\n * both rows. Treating the tile as square puts the second row's bottom 1.2 px above the capture, so the\n * grid keeps the measured aspect instead. Tile corners are **≈2.1** (a circle fitted to the sub-pixel\n * coverage of the highest-contrast interior corner bottoms out at 6.3 device px); they are not the 12\n * the spec's prose carries, which is off by a factor of six and obvious in a side-by-side.\n *\n * ## Grabber\n *\n * **36 × 5**, pill radius 2.5, centred on the panel with its top **4.6667 (14 px)** below the panel\n * top. The size is a framework number, not a fit: `-[_UIGrabber intrinsicContentSize]` in UIKitCore\n * returns exactly `{36, 5}`, and the capture's ink (106 × 14 px at a 25/255 threshold, centred on\n * x 602.5 of a panel centred on 603.0) is that pill minus its anti-aliased rim.\n *\n * Its colour is **not** a flat fill. `_UIGrabber` builds itself out of a luma-tracking vibrancy view\n * (`-[_UIGrabber _visualEffectView]`, `_lumaTrackingEnabled`, `_setBackgroundLuminanceLevel:`), and the\n * capture agrees: over the photo it sits on, the pill darkens all three channels by the same 60/255\n * (196,190,223 → 136,129,163), which no single translucent colour reproduces. The closest flat fill is\n * 30% black, which lands the red and green channels within 2/255 and leaves blue 7/255 short. That is\n * a fit, and it is the same one `ios-plus-menu.tsx` already uses.\n *\n * ## Selection, unverified\n *\n * Nothing in `references/` captures a selected tile, and the iOS 26 picker's own grid is SwiftUI\n * (`PhotosUICore.LemonadePickerView`), so there is no Objective-C metric to read the badge out of\n * either. `PXPhotosGridMessagesLayoutSpec`, which does expose `itemCornerRadius`, `interItemSpacing`\n * and `padding`, is the wrong grid: its `numberOfColumnsForNumberOfItems:` reads\n * `PXMessagesUISettings.minItemSize/minColumns/maxColumns`, so it lays out the photo stack inside a\n * balloon, not this picker. So `photoPickerMetrics.badge` is a **fit, not a measurement**: the ordinary\n * iOS selection badge, a filled disc at the tile's bottom trailing corner carrying a white check, or\n * the 1-based pick order when `ordered` is set. Its blue is the kit's measured `#0088ff`.\n *\n * ## Motion, unverified\n *\n * No capture or recording in this repo holds the picker opening or closing, so both durations are\n * borrowed from the kit's measured neighbours (the long-press menu's 380 ms open and 220 ms exit) and\n * that is the whole of their authority. The panel translates up from its own height and fades; the\n * animation is a single Web Animations timeline so `progress` can pause and seek it to one exact\n * frame, and `prefers-reduced-motion` skips it. Closing is driven by the `open` prop during render, so\n * the exit always gets a committed frame before `onExited` fires.\n */\nexport const photoPickerMetrics = {\n  /** The screen the numbers were measured on. Points equal CSS px. */\n  screen: { width: 402, height: 874 },\n  /** Left, right and bottom inset from the screen edge (16 device px at 3x). */\n  inset: 5.3333,\n  /** Where the panel's top edge falls in the capture, and how tall it is there. */\n  top: 485,\n  height: 383.6667,\n  width: 391.3333,\n  /**\n   * `topRadius` and `bottomRadius` are the continuous corner the capture actually draws. `round*` is\n   * the best plain circle for the same arc, and it is what the panel renders: see the clip-path note\n   * on the panel for why the shape gives way to sub-pixel edges here.\n   */\n  topRadius: 39,\n  bottomRadius: 57.5833,\n  roundTopRadius: 36.1667,\n  roundBottomRadius: 53.5,\n  columns: 3,\n  gap: 1.6207,\n  /** Implied by width, columns and gap; quoted because the capture measures it directly. */\n  tileWidth: 129.364,\n  /**\n   * Tiles are not quite square in the capture, and both axes are measured over 100+ boundary points:\n   * columns average 388.09 device px, rows 388.69. `tileAspect` is that ratio, so a second row lands\n   * on the measured 2237.24 instead of 1.2 px above it.\n   */\n  tileHeight: 129.5633,\n  tileAspect: 129.364 / 129.5633,\n  tileRadius: 2.1,\n  grabber: { width: 36, height: 5, radius: 2.5, top: 4.6667 },\n  /** Unverified: nothing captures a selected tile. See the file comment. */\n  badge: { size: 22, inset: 6, fontSize: 13, checkStroke: 2 },\n  /** Unverified: borrowed from the long-press menu's measured 380 / 220. */\n  timing: { enter: 380, exit: 220 },\n} as const;\n\n/**\n * Light values are measured off the capture. The dark set is unverified: no dark capture of the picker\n * exists, so the panel takes iOS's grouped-background dark grey and the grabber flips to 30% white.\n * They are custom properties so a `.dark` ancestor flips the panel without the caller passing anything.\n */\nconst vars =\n  \"[--ios-pp-panel:#ffffff] [--ios-pp-grabber:rgba(0,0,0,0.3)] [--ios-pp-tile:#e9e9eb] [--ios-pp-badge:#0088ff] [--ios-pp-glyph:#ffffff] [--ios-pp-rim:rgba(0,0,0,0.22)] \" +\n  \"dark:[--ios-pp-panel:#1c1c1e] dark:[--ios-pp-grabber:rgba(255,255,255,0.3)] dark:[--ios-pp-tile:#2c2c2e] dark:[--ios-pp-badge:#0088ff] dark:[--ios-pp-glyph:#ffffff] dark:[--ios-pp-rim:rgba(0,0,0,0.35)]\";\n\n/** Keeps a hidden string in the accessible name without depending on the consumer's utility classes. */\nconst offscreen: CSSProperties = {\n  position: \"absolute\",\n  width: 1,\n  height: 1,\n  margin: -1,\n  padding: 0,\n  overflow: \"hidden\",\n  clipPath: \"inset(50%)\",\n  whiteSpace: \"nowrap\",\n  border: 0,\n};\n\nexport type PhotoPickerPhoto = {\n  /** Stable identity for selection. Falls back to the index. */\n  id?: string;\n  /** A URL, or nothing for a solid `fill`. The registry itself ships no photographs. */\n  src?: string;\n  alt?: string;\n  /** Any CSS background, used when there is no `src`. */\n  fill?: string;\n};\n\n/**\n * Placeholder tiles in the tonal range of the capture's landscapes, so the geometry can be reviewed\n * without shipping anyone's photographs in a registry item.\n */\nexport const photoPickerSamples: PhotoPickerPhoto[] = [\n  { id: \"bloom\", fill: \"linear-gradient(160deg, #c8175f 0%, #e8408a 45%, #7a8f2e 100%)\" },\n  { id: \"falls\", fill: \"linear-gradient(180deg, #8fa2ad 0%, #4e6b5a 55%, #26361f 100%)\" },\n  { id: \"cascade\", fill: \"linear-gradient(200deg, #8d9aa1 0%, #55605c 50%, #2a3128 100%)\" },\n  { id: \"canyon\", fill: \"linear-gradient(170deg, #9fb0b8 0%, #5c7a5f 45%, #2c3a24 100%)\" },\n  { id: \"dune\", fill: \"linear-gradient(190deg, #b7c9cf 0%, #7f9a63 55%, #b52f57 100%)\" },\n  { id: \"leaf\", fill: \"linear-gradient(150deg, #7fa04a 0%, #3f5c25 60%, #d9c02f 100%)\" },\n];\n\nexport type PhotoPickerProps = Omit<ComponentProps<\"div\">, \"onSelect\" | \"children\"> & {\n  photos?: PhotoPickerPhoto[];\n  /** Controlled selection, as photo ids, in the order they were picked. */\n  selected?: string[];\n  defaultSelected?: string[];\n  /** Fires with the whole selection, plus the photo that just changed and its new state. */\n  onSelectionChange?: (selected: string[], photo: PhotoPickerPhoto, isSelected: boolean) => void;\n  /** False keeps one tile chosen at a time. */\n  multiple?: boolean;\n  /** Number the badges in pick order, the way an ordered multi-select does. */\n  ordered?: boolean;\n  columns?: number;\n  /** Panel box. Defaults are the capture's; the tile size follows from `width`, `columns` and the gap. */\n  width?: number;\n  height?: number;\n  inset?: number;\n  /** The drag bar over the first row. */\n  grabber?: boolean;\n  /** False plays the exit timeline and then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  /**\n   * Seek whichever direction `open` selects to this fraction (0..1) instead of playing it, which is\n   * what the harness does. A seeked exit poses the panel and never reports through `onExited`.\n   */\n  progress?: number;\n  /** Accessible name for the grid. */\n  label?: string;\n};\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\nconst idOf = (photo: PhotoPickerPhoto, index: number) => photo.id ?? String(index);\n\nfunction Check({ size }: { size: number }) {\n  // Drawn, not an SF Symbol, and unverified like the rest of the badge: a 2 pt stroke on its own box.\n  return (\n    <svg aria-hidden=\"true\" width={size} height={size} viewBox=\"0 0 22 22\" fill=\"none\">\n      <path\n        d=\"M6 11.4l3.4 3.4L16.2 8\"\n        stroke=\"currentColor\"\n        strokeWidth={photoPickerMetrics.badge.checkStroke}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function PhotoPicker({\n  photos = photoPickerSamples,\n  selected: selectedProp,\n  defaultSelected,\n  onSelectionChange,\n  multiple = true,\n  ordered = false,\n  columns = photoPickerMetrics.columns,\n  width = photoPickerMetrics.width,\n  height = photoPickerMetrics.height,\n  inset = photoPickerMetrics.inset,\n  grabber = true,\n  open = true,\n  onExited,\n  progress,\n  label = \"Recent photos\",\n  className,\n  style,\n  ...props\n}: PhotoPickerProps) {\n  const m = photoPickerMetrics;\n  const [internal, setInternal] = useState<string[]>(defaultSelected ?? []);\n  const selected = selectedProp ?? internal;\n  const [active, setActive] = useState(0);\n  const panel = useRef<HTMLDivElement>(null);\n  const exited = useRef(false);\n  /**\n   * The callback lives in a ref, not in the timeline effect's dependencies. A caller that passes an\n   * inline arrow hands us a new identity on the render `onExited` itself causes, and a dependency on\n   * it would tear the finished exit down and start it again from the top.\n   */\n  const exitedCallback = useRef(onExited);\n  useEffect(() => {\n    exitedCallback.current = onExited;\n  });\n\n  const toggle = useCallback(\n    (photo: PhotoPickerPhoto, index: number) => {\n      const id = idOf(photo, index);\n      const isSelected = selected.includes(id);\n      const next = isSelected\n        ? selected.filter(entry => entry !== id)\n        : multiple\n          ? [...selected, id]\n          : [id];\n      if (selectedProp === undefined) setInternal(next);\n      onSelectionChange?.(next, photo, !isSelected);\n    },\n    [selected, selectedProp, multiple, onSelectionChange],\n  );\n\n  /**\n   * One tab stop for the whole grid, arrows to move inside it, the way a native grid behaves. The\n   * roving index is clamped during render, so a library that shrinks under it cannot strand the tab\n   * stop on a tile that is gone.\n   */\n  const activeIndex = Math.min(active, Math.max(0, photos.length - 1));\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step =\n      event.key === \"ArrowRight\" ? 1\n      : event.key === \"ArrowLeft\" ? -1\n      : event.key === \"ArrowDown\" ? columns\n      : event.key === \"ArrowUp\" ? -columns\n      : 0;\n    let next = step ? activeIndex + step : activeIndex;\n    if (event.key === \"Home\") next = 0;\n    if (event.key === \"End\") next = photos.length - 1;\n    if (!step && event.key !== \"Home\" && event.key !== \"End\") return;\n    if (next < 0 || next >= photos.length) return;\n    event.preventDefault();\n    setActive(next);\n    event.currentTarget.querySelector<HTMLButtonElement>(`[data-index=\"${next}\"]`)?.focus();\n  };\n\n  /**\n   * One Web Animations timeline, run forwards or backwards, so `document.getAnimations()` reaches it\n   * and `progress` can pause and seek either direction to a frame that renders identically every run.\n   * Which direction it runs is read off the `open` prop during render, so the closing pose gets its\n   * committed frames before `onExited` lets the caller unmount anything.\n   */\n  useEffect(() => {\n    const node = panel.current;\n    if (!node) return;\n    // Reopening arms the exit again, so a panel that opens and closes twice reports twice.\n    if (open) exited.current = false;\n    const closing = !open;\n    const duration = closing ? m.timing.exit : m.timing.enter;\n    const away = { transform: `translateY(${height}px)`, opacity: 0 };\n    const settled = { transform: \"translateY(0px)\", opacity: 1 };\n    const finish = () => {\n      if (exited.current) return;\n      exited.current = true;\n      exitedCallback.current?.();\n    };\n    // Scrubbing is inspection, not a dismissal: a seeked exit poses the panel and reports nothing.\n    const reports = closing && progress === undefined;\n    const reduced = typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    if (reduced) {\n      if (!reports) return;\n      const frame = requestAnimationFrame(finish);\n      return () => cancelAnimationFrame(frame);\n    }\n    const animation = node.animate(closing ? [settled, away] : [away, settled], {\n      duration,\n      easing: closing ? \"cubic-bezier(0.4, 0, 1, 1)\" : \"cubic-bezier(0.32, 0.72, 0, 1)\",\n      fill: \"both\",\n    });\n    if (progress !== undefined) {\n      animation.pause();\n      animation.currentTime = clamp01(progress) * duration;\n      return () => animation.cancel();\n    }\n    if (!closing) return () => animation.cancel();\n    animation.addEventListener(\"finish\", finish);\n    return () => animation.removeEventListener(\"finish\", finish);\n  }, [open, progress, height, m.timing.enter, m.timing.exit]);\n\n  /**\n   * The row height comes from the tile's own measured aspect, not from `aspect-square`: the capture's\n   * rows really are 0.6 device px taller than its columns are wide.\n   */\n  const gap = m.gap;\n  const tileWidth = (width - (columns - 1) * gap) / columns;\n  const tileHeight = tileWidth / m.tileAspect;\n\n  return (\n    <div\n      ref={panel}\n      data-slot=\"photo-picker\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\"ios-photo-picker absolute\", vars, className)}\n      style={{\n        // Left plus width, never left plus right: two fractional insets resolve independently and the\n        // panel ends up a device pixel wider than the capture's 1174.\n        left: inset,\n        bottom: inset,\n        width,\n        height,\n        background: \"var(--ios-pp-panel)\",\n        fontFamily: fontStack,\n        /*\n         * The rounding is a clip path, not `border-radius`, and that is a fidelity decision rather\n         * than a style one. Chrome paints a `border-radius` box snapped out to whole device pixels: at\n         * this panel's measured left of 15.984 device px it fills pixel 15 completely, and the same\n         * snapping on each tile eats the gaps, turning the measured 4.86 device px into 3. `clip-path`\n         * renders sub-pixel, so both land where the capture puts them (pixel 15 comes out 241 against\n         * the backdrop's 244, which is the 1.6% of a pixel the panel really covers).\n         *\n         * The cost is the corner profile. `clip-path: inset(... round)` can only draw a circle, and the\n         * capture's corner is one of Apple's continuous ones: fitting the traced arc gives the\n         * superellipse 0.84 device px rms on the top corner and 0.90 on the bottom, against the best\n         * circle's 0.94 and 1.27. Trading 0.1 to 0.37 px of rms on two arcs for a device pixel on every\n         * straight edge and every gap is the right way round, so the circle wins here.\n         */\n        clipPath: `inset(0 round ${m.roundTopRadius}px ${m.roundTopRadius}px ${m.roundBottomRadius}px ${m.roundBottomRadius}px)`,\n        ...style,\n      }}\n      {...props}\n    >\n      <div\n        data-slot=\"photo-picker-scroller\"\n        className=\"h-full w-full overflow-y-auto overflow-x-hidden [overscroll-behavior:contain]\"\n      >\n        {/*\n          A group of toggles, not a listbox: each tile keeps its own pressed state and the grid never\n          owes the keyboard a single mandatory selection.\n        */}\n        <div\n          data-slot=\"photo-picker-grid\"\n          role=\"group\"\n          aria-label={label}\n          onKeyDown={onKeyDown}\n          className=\"grid select-none\"\n          style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridAutoRows: `${tileHeight}px`, gap }}\n        >\n          {photos.map((photo, index) => {\n            const id = idOf(photo, index);\n            const order = selected.indexOf(id);\n            const isSelected = order >= 0;\n            const name = photo.alt ?? `Photo ${index + 1}`;\n            return (\n              <button\n                key={id}\n                type=\"button\"\n                data-slot=\"photo-picker-tile\"\n                data-index={index}\n                data-selected={isSelected ? \"true\" : \"false\"}\n                aria-label={name}\n                aria-pressed={isSelected}\n                tabIndex={index === activeIndex ? 0 : -1}\n                onFocus={() => setActive(index)}\n                onClick={() => toggle(photo, index)}\n                className=\"relative block size-full p-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\"\n                style={{\n                  background: photo.src ? \"var(--ios-pp-tile)\" : (photo.fill ?? \"var(--ios-pp-tile)\"),\n                  // Clipped, not rounded, for the same reason the panel is; the focus ring is drawn\n                  // inside the tile so the clip cannot swallow it.\n                  clipPath: `inset(0 round ${m.tileRadius}px)`,\n                }}\n              >\n                {photo.src ? (\n                  // eslint-disable-next-line @next/next/no-img-element\n                  <img src={photo.src} alt=\"\" aria-hidden=\"true\" className=\"absolute inset-0 size-full object-cover\" draggable={false} />\n                ) : null}\n                {/* `aria-pressed` already says selected, so the hidden text only carries the pick order. */}\n                {ordered && isSelected ? <span style={offscreen}>{`${order + 1} of ${selected.length}`}</span> : null}\n                {/*\n                  Unverified geometry and colour, and the 150 ms is a fit too. The badge never\n                  unmounts: it is always in the tree and only its opacity and scale change, so a\n                  deselect gets the same committed frames a select does instead of vanishing on the\n                  render that drops it.\n                */}\n                <span\n                  aria-hidden=\"true\"\n                  data-slot=\"photo-picker-badge\"\n                  className=\"pointer-events-none absolute flex items-center justify-center rounded-full motion-safe:transition-[opacity,transform] motion-safe:duration-150\"\n                  style={{\n                    right: m.badge.inset,\n                    bottom: m.badge.inset,\n                    width: m.badge.size,\n                    height: m.badge.size,\n                    background: \"var(--ios-pp-badge)\",\n                    color: \"var(--ios-pp-glyph)\",\n                    fontSize: m.badge.fontSize,\n                    fontWeight: 600,\n                    lineHeight: 1,\n                    boxShadow: \"0 0 0 0.5px var(--ios-pp-rim)\",\n                    opacity: isSelected ? 1 : 0,\n                    transform: isSelected ? \"scale(1)\" : \"scale(0.6)\",\n                  }}\n                >\n                  {ordered ? (isSelected ? order + 1 : \"\") : <Check size={m.badge.size} />}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </div>\n\n      {grabber ? (\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"photo-picker-grabber\"\n          className=\"pointer-events-none absolute left-1/2\"\n          style={{\n            top: m.grabber.top,\n            width: m.grabber.width,\n            height: m.grabber.height,\n            marginLeft: -m.grabber.width / 2,\n            borderRadius: m.grabber.radius,\n            background: \"var(--ios-pp-grabber)\",\n          }}\n        />\n      ) : null}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/photo-picker.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "sticker-picker",
      "title": "iOS sticker picker",
      "description": "The sheet the plus menu's Stickers row opens: category tabs, a search field, the sticker grid, and drag to place a sticker on a message.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/sticker-picker.tsx",
          "content": "\"use client\";\n\nimport { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { emojiFontStack, fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * The iOS 26 sticker picker, the sheet the plus menu's \"Stickers\" row opens.\n *\n * ## Provenance, in the order this repo prefers it\n *\n * **1. Measured, from `references/ios/captures/plus-menu-open-light.png` (402x874 @3x).** The sheet is\n * the plus menu's own sheet, so its box is the measurement `ios-plus-menu.tsx` already carries:\n * x 8.6667-331.3333, y 371-831.6667 (322.6667 x 460.6667), continuous corner 24, glass\n * rgb(255 255 255 / 0.62) light and rgb(28 28 28 / 0.72) dark over a nominal 45 blur, one soft shadow\n * fitted at 6.6% in light and a bright inset rim in dark. Nothing behind it dims. The picker is\n * presented the same way, so it inherits all of that rather than inventing a second sheet.\n * The entrance grows out of the Stickers row's own artwork: that row is the third of seven, so its\n * icon is the measured Oe39 disc centred (63.1667, 425.8333 + 2 x 66.5 = 558.8333).\n *\n * **2. From the frameworks.** macOS Messages is a Mac Catalyst app on ChatKit, so a Catalyst probe\n * (`clang -target arm64-apple-ios26.0-macabi`) can dlopen\n * `/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework/ChatKit`, swizzle\n * `-[UIDevice userInterfaceIdiom]` to Phone so `+[CKUIBehavior sharedBehaviors]` vends\n * `CKUIBehaviorPhone`, and read the values off the runtime. Everything below names the selector it\n * came from:\n *\n * | Value | Selector |\n * |---|---|\n * | tile corner radius 8 | `-[CKUIBehaviorPhone stickersCellCornerRadius]` |\n * | grid gaps 4 | `-[CKUIBehaviorPhone attachmentBrowserGridInterItemSpacing]` and `attachmentBrowserGridMinimumLineSpacing` |\n * | grid inset 8 on all four sides | `-[CKUIBehaviorPhone attachmentBrowserGridSectionInset]` |\n * | tab strip 38 tall | `+[CKAppStripLayout minHeight]` |\n * | tab 52 x 38, 2 between, 1 x 38 separator | `-[CKAppStripLayout _specForLayoutMode:]` mode 0 (mode 1, the magnified strip, is 84 x 68 with 4 and 6) |\n * | strip gutter 8 | `-[CKUIBehaviorPhone browserSwitcherGutterWidth]` |\n * | emoji sticker box 48 x 48 | `-[CKUIBehaviorPhone emojiStickerTranscriptBalloonSize]` |\n * | drag rotation 3 to 10 degrees | `-[CKUIBehaviorPhone minStickerReactionRotation]` / `maxStickerReactionRotation` |\n * | drag easing cubic-bezier(0.14028, 0.004662, 0.57534, 0.96737) | the `CAMediaTimingFunction` `+[CKBrowserDragStickerView springAnimationWithKeyPath:speed:]` builds, read out of ChatKit's `__const` at the four `adrp`/`ldr` pairs at +128..+156 under lldb |\n * | search row 44, field 34, field type 17pt medium | `-[UISearchBar sizeThatFits:]` at width 322.6667, `-[UISearchTextField sizeThatFits:]`, and `-[UISearchBar searchTextField].font` in the same probe |\n * | field fill rgba(118,118,128,0.12) / (0.24) | `+[UIColor tertiarySystemFillColor]` resolved against each `UIUserInterfaceStyle` |\n * | separator #c6c6c8 / #38383a | `+[UIColor opaqueSeparatorColor]`, same resolution |\n *\n * Two framework numbers that back each other up: six columns inside the measured sheet puts an emoji\n * tile at (322.6667 - 2x8 - 5x4) / 6 = 47.7778, a quarter point off the 48 x 48 ChatKit gives an emoji\n * sticker in the transcript. The column count is still a choice (see below); the tile size it lands on\n * is not an accident.\n *\n * The label colours are NOT taken from that probe. `+[UIColor labelColor]` and `separatorColor` come\n * back with the *macOS* values under Catalyst (0.847 alpha black, 0.098 alpha black), so this file\n * uses the kit's own measured iOS labels instead: #000000 / #f4f3f4 from `ios-plus-menu.tsx` and the\n * #8a8a8e / #97979d search gray from `ios-conversation-list.tsx`. The `systemFill` family does come\n * back with the iOS values (118,118,128 at 0.12 and 0.24), which is why it is used.\n *\n * **3. Reused from measurements elsewhere in the kit.** The grabber is `photo-picker.tsx`'s: 36 x 5,\n * radius 2.5, its top 4.6667 below the panel, the size itself `-[_UIGrabber intrinsicContentSize]`.\n * The magnifier is the one traced for `ios-conversation-list.tsx`'s search pill (circle r 5.883 at\n * (6.745, 7.219), stroke 1.725; handle 11.4,12.01 to 15.17,15.78, stroke 2.467), drawn here at 0.72\n * of that size to suit a 34 pt field instead of a 48 pt one. Enter 380 ms and exit 220 ms are the\n * long-press menu's measured `messageActionsTiming` open and exit, the same borrow `photo-picker.tsx`\n * makes.\n *\n * **4. Judgement, and nothing here is measured.** Marked `@unverified` on each metric as well:\n *\n * - **The column counts.** Three for art stickers (the count the Photos picker measures) and six for\n *   emoji. Nothing in `references/` captures this grid, and the picker's own UI is out of process:\n *   `_UIStickerPickerViewController` is a shell around `_UIStickerPickerServiceRemoteViewController`,\n *   and the service that draws the card (`com.apple.StickerKit.StickerPickerService`) is not\n *   installed on macOS, so no Mac-side framework holds its layout.\n * - **Which tabs exist and their order.** Recents, Emoji, Memoji, Live Stickers, then app packs, from\n *   documented behaviour. The tab artwork is drawn here, not Apple's.\n * - **The vertical stack inside the sheet**: grabber, then the search row at 14, then the grid, then\n *   the strip pinned to the bottom. Only the pieces have measured sizes; their order and the 14 are a\n *   choice.\n * - **The drag lift of 1.4.** `-[CKBrowserDragStickerView animateScaleDown]` settles the drag view at\n *   0.7142857142857143 of its scale (the `__const` double the `ldr d1, [x8, #0x770]` at +164 loads),\n *   and 1 / 0.714285... is 1.4. What that scale is relative to was not traced, so 1.4 is\n *   ChatKit-derived rather than measured, and the ghost's shadow is invented outright.\n * - **Every duration except the 380 and the 220**, and the whole empty state.\n *\n * ## Motion\n *\n * One Web Animations timeline, so `document.getAnimations()` reaches it and `progress` pauses and\n * seeks it to a frame that renders identically on every run: nothing in it reads a clock, a random\n * number or a layout. The direction is read off the `open` prop **during render**, not in an effect,\n * so a dismissal always gets committed frames before `onExited` lets the caller unmount the sheet.\n *\n * The sheet itself never moves: it is already open behind the plus menu's rows. What animates is the\n * contents, scaling up from the Stickers row's own icon centre while they fade in, with the tab strip\n * riding up from the sheet's bottom edge behind them. `prefers-reduced-motion` skips to the end pose.\n *\n * The drag ghost is an interaction, not a presentation, so it is live only: scrubbing `progress`\n * disables it. Its return to the tile is still a Web Animations run for the same reason.\n */\n\n/** Apple's continuous corner. Browsers without `corner-shape` fall back to a plain round corner. */\nconst continuous = { cornerShape: \"superellipse(1.14)\" } as CSSProperties;\n\n/**\n * Light values are measured or framework values; see the file comment for which is which. Written out\n * in full because Tailwind only compiles class names it can read literally in the source.\n *\n * `--ios-sp-rim` and `--ios-sp-shadow-alpha` mirror `ios-plus-menu.tsx`: `box-shadow` takes a comma\n * separated list and `none` is only legal on its own, so both slots always hold a real shadow and the\n * unused one is fully transparent.\n */\nconst vars =\n  \"[--ios-sp-label:#000000] [--ios-sp-glass:255_255_255] [--ios-sp-alpha:0.62] [--ios-sp-shadow-alpha:0.066] [--ios-sp-rim:0_0_0_0_rgba(0,0,0,0)] \" +\n  \"[--ios-sp-field:rgba(118,118,128,0.12)] [--ios-sp-muted:#8a8a8e] [--ios-sp-separator:#c6c6c8] [--ios-sp-tab-selected:rgba(120,120,128,0.16)] [--ios-sp-tile:rgba(118,118,128,0.12)] [--ios-sp-ghost-shadow:rgba(0,0,0,0.28)] \" +\n  \"dark:[--ios-sp-label:#f4f3f4] dark:[--ios-sp-glass:28_28_28] dark:[--ios-sp-alpha:0.72] dark:[--ios-sp-shadow-alpha:0] dark:[--ios-sp-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] \" +\n  \"dark:[--ios-sp-field:rgba(118,118,128,0.24)] dark:[--ios-sp-muted:#97979d] dark:[--ios-sp-separator:#38383a] dark:[--ios-sp-tab-selected:rgba(120,120,128,0.32)] dark:[--ios-sp-tile:rgba(118,118,128,0.24)] dark:[--ios-sp-ghost-shadow:rgba(0,0,0,0.5)]\";\n\nexport const stickerPickerMetrics = {\n  /** The screen the sheet's numbers were measured on. Points equal CSS px. */\n  screen: { width: 402, height: 874 },\n  /** Measured: the plus menu's sheet in `plus-menu-open-light.png`. */\n  sheet: { left: 8.6667, top: 371, width: 322.6667, height: 460.6667, radius: 24 },\n  /** Measured: the plus menu's Stickers row artwork, the third Oe39 disc down. */\n  origin: { centerX: 63.1667, centerY: 558.8333, size: 39 },\n  /** Measured in `photo-picker.tsx`; the 36 x 5 is `-[_UIGrabber intrinsicContentSize]`. */\n  grabber: { width: 36, height: 5, radius: 2.5, top: 4.6667 },\n  /**\n   * `rowHeight` and `fieldHeight` are `-[UISearchBar sizeThatFits:]` and\n   * `-[UISearchTextField sizeThatFits:]`; `fontSize` and `weight` are that bar's own search field\n   * font (SF Medium 17), which is also the weight `ios-conversation-list.tsx` measured on the list's\n   * search pill. `top`, `radius` and `glyphScale` are judgement: iOS 26 draws every field as a\n   * capsule, so the radius is half the height.\n   * @unverified top, radius, glyphScale\n   */\n  search: { top: 14, rowHeight: 44, fieldHeight: 34, fontSize: 17, weight: 500, radius: 17, glyphScale: 0.72, textInset: 34 },\n  /**\n   * `inset` and `gap` are `-[CKUIBehaviorPhone attachmentBrowserGridSectionInset]` and\n   * `attachmentBrowserGridInterItemSpacing`; `tileRadius` is `stickersCellCornerRadius`. The two\n   * column counts are judgement, and `emojiGlyph` (the glyph's share of its tile) is too.\n   * @unverified columns, emojiColumns, emojiGlyph\n   */\n  grid: { inset: 8, gap: 4, tileRadius: 8, columns: 3, emojiColumns: 6, emojiGlyph: 0.68 },\n  /**\n   * The minified app strip out of `-[CKAppStripLayout _specForLayoutMode:]` mode 0, whose height is\n   * also `+[CKAppStripLayout minHeight]`, with `-[CKUIBehaviorPhone browserSwitcherGutterWidth]` for\n   * the gutter. `iconSize` and `selectionInset` are judgement, and the hairline over the strip is one\n   * device pixel at 3x.\n   * @unverified iconSize, selectionInset, hairline\n   */\n  strip: { height: 38, itemWidth: 52, itemHeight: 38, spacing: 2, separatorWidth: 1, gutter: 8, iconSize: 26, selectionInset: 3, hairline: 0.3333 },\n  /**\n   * `minRotation` and `maxRotation` are ChatKit's sticker reaction rotation bounds. `liftScale` is\n   * derived from ChatKit (see the file comment); the threshold and the shadow are invented.\n   * @unverified threshold, shadowBlur, shadowLift\n   */\n  drag: { liftScale: 1.4, minRotation: 3, maxRotation: 10, threshold: 6, shadowBlur: 18, shadowLift: 8 },\n  /**\n   * 380 and 220 are the long-press menu's measured open and exit, borrowed exactly as\n   * `photo-picker.tsx` borrows them. `settle` is judgement.\n   * @unverified settle\n   */\n  timing: { enter: 380, exit: 220, settle: 260 },\n  /** The timing function `+[CKBrowserDragStickerView springAnimationWithKeyPath:speed:]` installs. */\n  dragEasing: \"cubic-bezier(0.14028, 0.004662, 0.57534, 0.96737)\",\n  /** The kit's entrance curve, shared with `photo-picker.tsx`. */\n  enterEasing: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n  exitEasing: \"cubic-bezier(0.4, 0, 1, 1)\",\n} as const;\n\nexport type StickerKind = \"emoji\" | \"art\";\n\nexport type Sticker = {\n  id: string;\n  /** `emoji` draws `glyph` as text; `art` draws a placeholder tile with the glyph on top. */\n  kind?: StickerKind;\n  glyph: string;\n  /** Accessible name, and what the search field matches against together with `keywords`. */\n  label: string;\n  keywords?: string;\n  /** Any CSS background for an `art` sticker. The registry ships no photographs. */\n  fill?: string;\n};\n\nexport type StickerTabIcon = \"recents\" | \"emoji\" | \"memoji\" | \"live\" | \"pack\";\n\nexport type StickerTab = {\n  id: string;\n  label: string;\n  icon: StickerTabIcon;\n  stickers: Sticker[];\n  /** Overrides the tab's column count. Emoji tabs default to six, everything else to three. */\n  columns?: number;\n  /** Draws the strip's 1 x 38 separator before this tab, the way the app strip splits its sections. */\n  separatorBefore?: boolean;\n};\n\nconst art = (id: string, glyph: string, label: string, fill: string, keywords?: string): Sticker => ({ id, kind: \"art\", glyph, label, fill, keywords });\nconst emoji = (id: string, glyph: string, label: string, keywords?: string): Sticker => ({ id, kind: \"emoji\", glyph, label, keywords });\n\n/**\n * Fixture stickers. The people are the kit's own fixture cast (Alex Morgan, Jamie Chen, Sam Rivera)\n * and every \"photo\" sticker is a gradient, because a registry item never ships anybody's pictures.\n *\n * Recents is filled in below from the tabs it is a recency view over, so the same sticker carries one\n * id everywhere and a search across every tab cannot return it twice.\n */\nexport const stickerPickerTabs: StickerTab[] = [\n  {\n    id: \"recents\",\n    label: \"Recents\",\n    icon: \"recents\",\n    stickers: [],\n  },\n  {\n    id: \"emoji\",\n    label: \"Emoji\",\n    icon: \"emoji\",\n    separatorBefore: true,\n    stickers: [\n      emoji(\"e-joy\", \"\\u{1F602}\", \"Face with tears of joy\", \"laugh haha\"),\n      emoji(\"e-love\", \"\\u{1F60D}\", \"Smiling face with heart eyes\", \"love\"),\n      emoji(\"e-wink\", \"\\u{1F609}\", \"Winking face\"),\n      emoji(\"e-cool\", \"\\u{1F60E}\", \"Smiling face with sunglasses\", \"cool\"),\n      emoji(\"e-think\", \"\\u{1F914}\", \"Thinking face\"),\n      emoji(\"e-party\", \"\\u{1F973}\", \"Partying face\", \"celebrate\"),\n      emoji(\"e-heart\", \"❤️\", \"Red heart\", \"love\"),\n      emoji(\"e-sparkle\", \"✨\", \"Sparkles\"),\n      emoji(\"e-fire\", \"\\u{1F525}\", \"Fire\"),\n      emoji(\"e-clap\", \"\\u{1F44F}\", \"Clapping hands\"),\n      emoji(\"e-pray\", \"\\u{1F64F}\", \"Folded hands\", \"thanks\"),\n      emoji(\"e-rocket\", \"\\u{1F680}\", \"Rocket\"),\n      emoji(\"e-cake\", \"\\u{1F382}\", \"Birthday cake\", \"party\"),\n      emoji(\"e-coffee\", \"☕\", \"Hot beverage\", \"coffee\"),\n      emoji(\"e-dog\", \"\\u{1F415}\", \"Dog\"),\n      emoji(\"e-cat\", \"\\u{1F408}\", \"Cat\"),\n      emoji(\"e-sun\", \"☀️\", \"Sun\"),\n      emoji(\"e-thumbs\", \"\\u{1F44D}\", \"Thumbs up\", \"yes ok\"),\n    ],\n  },\n  {\n    id: \"memoji\",\n    label: \"Memoji\",\n    icon: \"memoji\",\n    stickers: [\n      art(\"m-alex-wave\", \"\\u{1F44B}\", \"Alex Morgan waving\", \"linear-gradient(165deg, #ffe0b8 0%, #f0a566 55%, #b96a35 100%)\"),\n      art(\"m-alex-laugh\", \"\\u{1F604}\", \"Alex Morgan laughing\", \"linear-gradient(165deg, #ffe0b8 0%, #f0a566 55%, #b96a35 100%)\"),\n      art(\"m-jamie-thumbs\", \"\\u{1F44D}\", \"Jamie Chen giving a thumbs up\", \"linear-gradient(165deg, #d9e8ff 0%, #92aee0 55%, #4f5f96 100%)\"),\n      art(\"m-jamie-wow\", \"\\u{1F62E}\", \"Jamie Chen looking surprised\", \"linear-gradient(165deg, #d9e8ff 0%, #92aee0 55%, #4f5f96 100%)\"),\n      art(\"m-sam-heart\", \"\\u{1F60D}\", \"Sam Rivera sending love\", \"linear-gradient(165deg, #ffd7e2 0%, #e08aa8 55%, #944763 100%)\"),\n      art(\"m-sam-shrug\", \"\\u{1F937}\", \"Sam Rivera shrugging\", \"linear-gradient(165deg, #ffd7e2 0%, #e08aa8 55%, #944763 100%)\"),\n    ],\n  },\n  {\n    id: \"live\",\n    label: \"Live Stickers\",\n    icon: \"live\",\n    stickers: [\n      art(\"l-pup\", \"\\u{1F415}\", \"Puppy cutout\", \"linear-gradient(170deg, #cfe4f6 0%, #8fb6d8 50%, #4d6f90 100%)\", \"dog\"),\n      art(\"l-shore\", \"\\u{1F3D6}️\", \"Shore cutout\", \"linear-gradient(180deg, #bfe3ef 0%, #6fae9b 55%, #2f5a4a 100%)\", \"beach\"),\n      art(\"l-ridge\", \"⛰️\", \"Ridge cutout\", \"linear-gradient(200deg, #cdd6dd 0%, #7f8f97 50%, #38434a 100%)\", \"mountain\"),\n      art(\"l-bloom\", \"\\u{1F339}\", \"Bloom cutout\", \"linear-gradient(160deg, #ffc9de 0%, #e0567f 55%, #8d2544 100%)\", \"flower\"),\n      art(\"l-mug\", \"☕\", \"Coffee cutout\", \"linear-gradient(170deg, #e7d3bd 0%, #b58a5f 55%, #6b4a2c 100%)\"),\n      art(\"l-bike\", \"\\u{1F6B2}\", \"Bicycle cutout\", \"linear-gradient(190deg, #d5e9c9 0%, #86ac6a 55%, #40603a 100%)\"),\n    ],\n  },\n  {\n    id: \"doodles\",\n    label: \"Doodles\",\n    icon: \"pack\",\n    separatorBefore: true,\n    stickers: [\n      art(\"d-star\", \"⭐\", \"Doodled star\", \"linear-gradient(160deg, #fff2c4 0%, #f7c948 60%, #b98d10 100%)\"),\n      art(\"d-bolt\", \"⚡\", \"Doodled bolt\", \"linear-gradient(160deg, #d8ecff 0%, #6aa9f0 60%, #2b5ea8 100%)\"),\n      art(\"d-moon\", \"\\u{1F319}\", \"Doodled moon\", \"linear-gradient(160deg, #e6e2ff 0%, #9d94ee 60%, #4d449c 100%)\"),\n      art(\"d-wave\", \"\\u{1F30A}\", \"Doodled wave\", \"linear-gradient(160deg, #cdf2f7 0%, #56b7cf 60%, #1c6a83 100%)\"),\n    ],\n  },\n];\n\nconst byId = new Map(stickerPickerTabs.flatMap(entry => entry.stickers.map(sticker => [sticker.id, sticker] as const)));\n/** The six most recently used, taken from the packs they belong to rather than copied. */\nstickerPickerTabs[0].stickers = [\"m-alex-wave\", \"e-heart\", \"l-pup\", \"e-joy\", \"d-star\", \"e-thumbs\"].flatMap(id => {\n  const sticker = byId.get(id);\n  return sticker ? [sticker] : [];\n});\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\n\n/**\n * A stable angle per sticker, inside ChatKit's 3 to 10 degree band. Deterministic on the id and not on\n * a clock or `Math.random`, so a lifted sticker poses identically every run and a seeked checkpoint\n * stays byte-identical.\n */\nexport function stickerDragRotation(id: string): number {\n  let hash = 2166136261;\n  for (let index = 0; index < id.length; index += 1) {\n    hash ^= id.charCodeAt(index);\n    hash = Math.imul(hash, 16777619) >>> 0;\n  }\n  const { minRotation, maxRotation } = stickerPickerMetrics.drag;\n  const magnitude = minRotation + ((hash >>> 8) % 1000) / 1000 * (maxRotation - minRotation);\n  return (hash & 1 ? 1 : -1) * magnitude;\n}\n\nfunction TabGlyph({ icon, size }: { icon: StickerTabIcon; size: number }) {\n  const common = { width: size, height: size, viewBox: \"0 0 26 26\", fill: \"none\", \"aria-hidden\": true } as const;\n  if (icon === \"recents\") {\n    return (\n      <svg {...common} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <circle cx=\"13\" cy=\"13\" r=\"9.6\" />\n        <path d=\"M13 7.6V13l3.9 2.5\" />\n      </svg>\n    );\n  }\n  if (icon === \"emoji\") {\n    return (\n      <svg {...common} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\">\n        <circle cx=\"13\" cy=\"13\" r=\"9.6\" />\n        <path d=\"M9.4 11.2v.6M16.6 11.2v.6\" strokeWidth=\"2.4\" />\n        <path d=\"M8.9 15.4a5.2 5.2 0 0 0 8.2 0\" />\n      </svg>\n    );\n  }\n  if (icon === \"memoji\") {\n    return (\n      <svg {...common} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <ellipse cx=\"13\" cy=\"13.2\" rx=\"6.4\" ry=\"7.4\" />\n        <path d=\"M6.6 11.4a1.9 1.9 0 0 0 0 3.8M19.4 11.4a1.9 1.9 0 0 1 0 3.8\" />\n        <path d=\"M10.8 12v1M15.2 12v1\" strokeWidth=\"2.2\" />\n        <path d=\"M11 16.4a3.4 3.4 0 0 0 4 0\" />\n      </svg>\n    );\n  }\n  if (icon === \"live\") {\n    // A sticker with its corner peeled up, the shape the plus menu's Stickers row draws, as an\n    // outline at strip size.\n    return (\n      <svg {...common} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M5 8.4A3.4 3.4 0 0 1 8.4 5h9.2A3.4 3.4 0 0 1 21 8.4v5.2L13.6 21H8.4A3.4 3.4 0 0 1 5 17.6Z\" />\n        <path d=\"M21 13.6h-4a3.4 3.4 0 0 0-3.4 3.4v4\" />\n      </svg>\n    );\n  }\n  return (\n    <svg {...common} stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinejoin=\"round\">\n      <rect x=\"4.6\" y=\"4.6\" width=\"7.4\" height=\"7.4\" rx=\"2.1\" />\n      <rect x=\"14\" y=\"4.6\" width=\"7.4\" height=\"7.4\" rx=\"2.1\" />\n      <rect x=\"4.6\" y=\"14\" width=\"7.4\" height=\"7.4\" rx=\"2.1\" />\n      <rect x=\"14\" y=\"14\" width=\"7.4\" height=\"7.4\" rx=\"2.1\" />\n    </svg>\n  );\n}\n\n/** The magnifier traced for the conversation list's search pill, at 0.72 of the size it is drawn there. */\nfunction SearchGlyph({ scale }: { scale: number }) {\n  return (\n    <svg aria-hidden=\"true\" width={18.3333 * scale} height={18.6667 * scale} viewBox=\"-1 -1 18.3333 18.6667\" fill=\"none\" stroke=\"var(--ios-sp-muted)\" strokeLinecap=\"round\">\n      <circle cx=\"6.745\" cy=\"7.219\" r=\"5.883\" strokeWidth=\"1.725\" />\n      <path d=\"M11.4 12.01 15.17 15.78\" strokeWidth=\"2.467\" />\n    </svg>\n  );\n}\n\n/** One sticker's artwork, at whatever box the grid or the drag ghost gives it. */\nfunction StickerArt({ sticker, size }: { sticker: Sticker; size: number }) {\n  const m = stickerPickerMetrics;\n  if (sticker.kind === \"emoji\") {\n    return (\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"sticker-art\"\n        className=\"flex size-full items-center justify-center\"\n        style={{ fontFamily: emojiFontStack, fontSize: size * m.grid.emojiGlyph, lineHeight: 1 }}\n      >\n        {sticker.glyph}\n      </span>\n    );\n  }\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"sticker-art\"\n      className=\"flex size-full items-center justify-center\"\n      style={{\n        background: sticker.fill ?? \"var(--ios-sp-tile)\",\n        borderRadius: m.grid.tileRadius,\n        ...continuous,\n        fontFamily: emojiFontStack,\n        fontSize: size * 0.46,\n        lineHeight: 1,\n      }}\n    >\n      {sticker.glyph}\n    </span>\n  );\n}\n\ntype DragState = {\n  sticker: Sticker;\n  pointerId: number;\n  /** Root relative, so the ghost and the drop point share one coordinate space. */\n  x: number;\n  y: number;\n  size: number;\n  rotation: number;\n  source: { left: number; top: number; size: number };\n};\n\nexport type StickerPlacement = {\n  /** Where the sticker was let go, in the picker root's own coordinates. */\n  x: number;\n  y: number;\n  /** The angle it was carrying, in degrees. */\n  rotation: number;\n  /** Its drawn size at the moment of the drop. */\n  size: number;\n};\n\nexport type StickerPickerProps = Omit<ComponentProps<\"div\">, \"onSelect\" | \"children\"> & {\n  tabs?: StickerTab[];\n  /** Controlled active tab id. */\n  tab?: string;\n  defaultTab?: string;\n  onTabChange?: (tabId: string) => void;\n  /** Controlled search text. A non-empty query searches every tab, the way the field says it does. */\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  /** A tap, Enter or Space on a sticker. This is the path the keyboard has to the same result. */\n  onSelect?: (sticker: Sticker) => void;\n  /** A sticker dragged out of the sheet and let go. Pointer only; see the file comment. */\n  onPlace?: (sticker: Sticker, placement: StickerPlacement) => void;\n  /** Escape and a tap outside the sheet. */\n  onDismiss?: () => void;\n  /** False plays the exit and then calls `onExited`, so the caller can unmount the sheet. */\n  open?: boolean;\n  onExited?: () => void;\n  /**\n   * Seek whichever direction `open` selects to this fraction (0..1) instead of playing it, which is\n   * what the harness does. A seeked exit poses the sheet and never reports through `onExited`, and it\n   * turns the drag affordance off.\n   */\n  progress?: number;\n  /** The sheet box. Defaults are the plus menu's measured one. */\n  left?: number;\n  top?: number;\n  width?: number;\n  height?: number;\n  /** The sheet's drag bar. */\n  grabber?: boolean;\n  /** False drops the search row and gives the grid its height. */\n  search?: boolean;\n  /** Accessible name for the sheet. */\n  label?: string;\n  searchPlaceholder?: string;\n  emptyLabel?: string;\n};\n\nexport function StickerPicker({\n  tabs = stickerPickerTabs,\n  tab: tabProp,\n  defaultTab,\n  onTabChange,\n  query: queryProp,\n  defaultQuery,\n  onQueryChange,\n  onSelect,\n  onPlace,\n  onDismiss,\n  open = true,\n  onExited,\n  progress,\n  left = stickerPickerMetrics.sheet.left,\n  top = stickerPickerMetrics.sheet.top,\n  width = stickerPickerMetrics.sheet.width,\n  height = stickerPickerMetrics.sheet.height,\n  grabber = true,\n  search = true,\n  label = \"Stickers\",\n  searchPlaceholder = \"Search\",\n  emptyLabel = \"No Stickers\",\n  className,\n  style,\n  ...props\n}: StickerPickerProps) {\n  const m = stickerPickerMetrics;\n  const id = useId();\n\n  const [internalTab, setInternalTab] = useState(defaultTab ?? tabs[0]?.id ?? \"\");\n  const activeTabId = tabProp ?? internalTab;\n  const [internalQuery, setInternalQuery] = useState(defaultQuery ?? \"\");\n  const query = queryProp ?? internalQuery;\n\n  const root = useRef<HTMLDivElement>(null);\n  const sheet = useRef<HTMLDivElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const strip = useRef<HTMLDivElement>(null);\n\n  /**\n   * The sheet outlives the `open` prop so the dismissal has frames to run in, and `closing` is derived\n   * DURING RENDER rather than in an effect: an effect leaves one committed frame with the sheet\n   * already gone and the exit never runs. Same rule as `ios-plus-menu.tsx`.\n   */\n  const [seenOpen, setSeenOpen] = useState(open);\n  const [closing, setClosing] = useState(false);\n  if (seenOpen !== open) {\n    setSeenOpen(open);\n    setClosing(!open);\n  }\n\n  /**\n   * The callback lives in a ref, not in the timeline effect's dependencies. A caller that passes an\n   * inline arrow hands us a new identity on the render `onExited` itself causes, and a dependency on\n   * it would tear the finished exit down and start it again from the top.\n   */\n  const exitedCallback = useRef(onExited);\n  useEffect(() => {\n    exitedCallback.current = onExited;\n  });\n  const reported = useRef(false);\n\n  const activeTab = tabs.find(entry => entry.id === activeTabId) ?? tabs[0];\n  const searching = query.trim().length > 0;\n  const matches = useMemo(() => {\n    if (!searching) return activeTab?.stickers ?? [];\n    const needle = query.trim().toLowerCase();\n    const seen = new Set<string>();\n    const found: Sticker[] = [];\n    for (const entry of tabs) {\n      for (const sticker of entry.stickers) {\n        if (seen.has(sticker.id)) continue;\n        if (!`${sticker.label} ${sticker.keywords ?? \"\"}`.toLowerCase().includes(needle)) continue;\n        seen.add(sticker.id);\n        found.push(sticker);\n      }\n    }\n    return found;\n  }, [searching, query, tabs, activeTab]);\n\n  const columns = searching ? m.grid.columns : (activeTab?.columns ?? (activeTab?.icon === \"emoji\" ? m.grid.emojiColumns : m.grid.columns));\n  const tileSize = (width - 2 * m.grid.inset - (columns - 1) * m.grid.gap) / columns;\n\n  const searchHeight = search ? m.search.top + m.search.rowHeight : 0;\n\n  /**\n   * One Web Animations timeline over the sheet's contents and its strip, run forwards or backwards.\n   * Both keyframe sets are constants, so `document.getAnimations()` reaches them and a seeked frame is\n   * a pure function of `progress`. The direction comes off `open`, which is a render value.\n   */\n  useEffect(() => {\n    const sheetNode = sheet.current;\n    const contentNode = content.current;\n    const stripNode = strip.current;\n    if (!sheetNode || !contentNode || !stripNode) return;\n    if (open) reported.current = false;\n    const isClosing = !open;\n    const duration = isClosing ? m.timing.exit : m.timing.enter;\n    const originX = m.origin.centerX - left;\n    const originY = m.origin.centerY - top;\n    // The contents grow out of the Stickers row's icon rather than out of the sheet's own centre.\n    const away: Keyframe = { opacity: 0, transform: \"scale(0.92)\" };\n    const settled: Keyframe = { opacity: 1, transform: \"scale(1)\" };\n    const stripAway: Keyframe = { opacity: 0, transform: `translateY(${m.strip.height}px)` };\n    const stripSettled: Keyframe = { opacity: 1, transform: \"translateY(0px)\" };\n    const finish = () => {\n      if (reported.current) return;\n      reported.current = true;\n      exitedCallback.current?.();\n    };\n    // Scrubbing is inspection, not a dismissal: a seeked exit poses the sheet and reports nothing.\n    const reports = isClosing && progress === undefined;\n    const reduced = typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    contentNode.style.transformOrigin = `${originX}px ${originY}px`;\n    if (reduced) {\n      if (!reports) return;\n      const frame = requestAnimationFrame(finish);\n      return () => cancelAnimationFrame(frame);\n    }\n    const easing = isClosing ? m.exitEasing : m.enterEasing;\n    const options: KeyframeAnimationOptions = { duration, easing, fill: \"both\" };\n    const fade: Keyframe[] = [{ opacity: 0 }, { opacity: 1 }];\n    const sheetAnimation = sheetNode.animate(isClosing ? [...fade].reverse() : fade, options);\n    const contentAnimation = contentNode.animate(isClosing ? [settled, away] : [away, settled], options);\n    const stripAnimation = stripNode.animate(isClosing ? [stripSettled, stripAway] : [stripAway, stripSettled], options);\n    const animations = [sheetAnimation, contentAnimation, stripAnimation];\n    if (progress !== undefined) {\n      const time = clamp01(progress) * duration;\n      for (const animation of animations) {\n        animation.pause();\n        animation.currentTime = time;\n      }\n      return () => {\n        for (const animation of animations) animation.cancel();\n      };\n    }\n    if (!isClosing) {\n      return () => {\n        for (const animation of animations) animation.cancel();\n      };\n    }\n    contentAnimation.addEventListener(\"finish\", finish);\n    return () => contentAnimation.removeEventListener(\"finish\", finish);\n  }, [open, progress, left, top, m.timing.enter, m.timing.exit, m.origin.centerX, m.origin.centerY, m.strip.height, m.enterEasing, m.exitEasing]);\n\n  // Escape closes it, so the sheet never depends on a tap outside to get out of the way.\n  useEffect(() => {\n    if (!open || !onDismiss) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      onDismiss();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onDismiss]);\n\n  const selectTab = useCallback(\n    (next: string) => {\n      if (tabProp === undefined) setInternalTab(next);\n      onTabChange?.(next);\n    },\n    [tabProp, onTabChange],\n  );\n\n  const setQuery = useCallback(\n    (next: string) => {\n      if (queryProp === undefined) setInternalQuery(next);\n      onQueryChange?.(next);\n    },\n    [queryProp, onQueryChange],\n  );\n\n  // One tab stop for the grid, arrows to move inside it, the way a native grid behaves. The roving\n  // index is clamped during render, so a tab that shrinks under it cannot strand the tab stop.\n  const [active, setActive] = useState(0);\n  const activeIndex = Math.min(active, Math.max(0, matches.length - 1));\n  const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const step =\n      event.key === \"ArrowRight\" ? 1\n      : event.key === \"ArrowLeft\" ? -1\n      : event.key === \"ArrowDown\" ? columns\n      : event.key === \"ArrowUp\" ? -columns\n      : 0;\n    let next = step ? activeIndex + step : activeIndex;\n    if (event.key === \"Home\") next = 0;\n    if (event.key === \"End\") next = matches.length - 1;\n    if (!step && event.key !== \"Home\" && event.key !== \"End\") return;\n    if (next < 0 || next >= matches.length) return;\n    event.preventDefault();\n    setActive(next);\n    event.currentTarget.querySelector<HTMLButtonElement>(`[data-index=\"${next}\"]`)?.focus();\n  };\n\n  const onTabsKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const index = tabs.findIndex(entry => entry.id === activeTabId);\n    const step = event.key === \"ArrowRight\" ? 1 : event.key === \"ArrowLeft\" ? -1 : 0;\n    let next = step ? index + step : index;\n    if (event.key === \"Home\") next = 0;\n    if (event.key === \"End\") next = tabs.length - 1;\n    if (!step && event.key !== \"Home\" && event.key !== \"End\") return;\n    if (next < 0 || next >= tabs.length) return;\n    event.preventDefault();\n    selectTab(tabs[next].id);\n    event.currentTarget.querySelector<HTMLButtonElement>(`[data-tab=\"${tabs[next].id}\"]`)?.focus();\n  };\n\n  /**\n   * The drag affordance. Pointer only, and off while `progress` is scrubbing: a drag is an interaction\n   * and would put a pointer's position into a frame the harness expects to be a pure function of one\n   * scalar. Everything it can do, a tap can also do, so the keyboard loses nothing.\n   */\n  const [drag, setDrag] = useState<DragState | null>(null);\n  const pending = useRef<{ sticker: Sticker; pointerId: number; startX: number; startY: number; source: DragState[\"source\"] } | null>(null);\n  const suppressClick = useRef(false);\n  const ghost = useRef<HTMLDivElement>(null);\n  const dragEnabled = progress === undefined && (onPlace !== undefined);\n\n  const rootPoint = (clientX: number, clientY: number) => {\n    const rect = root.current?.getBoundingClientRect();\n    return { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) };\n  };\n\n  const onTilePointerDown = (event: ReactPointerEvent<HTMLButtonElement>, sticker: Sticker) => {\n    // Cleared here, not after the click it suppresses: a drag that ends off the tile may never produce\n    // a click at all, and a flag left standing would swallow the next real tap.\n    suppressClick.current = false;\n    if (!dragEnabled || event.button !== 0) return;\n    const rect = root.current?.getBoundingClientRect();\n    const tile = event.currentTarget.getBoundingClientRect();\n    if (!rect) return;\n    pending.current = {\n      sticker,\n      pointerId: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      // The position comes off the live rect, the size off the layout. Lifting a tile mid-entrance\n      // would otherwise scale the ghost by whatever the entrance had reached.\n      source: { left: tile.left - rect.left, top: tile.top - rect.top, size: tileSize },\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const onTilePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    const start = pending.current;\n    if (!start || start.pointerId !== event.pointerId) return;\n    const point = rootPoint(event.clientX, event.clientY);\n    if (!drag) {\n      const moved = Math.hypot(event.clientX - start.startX, event.clientY - start.startY);\n      if (moved < m.drag.threshold) return;\n      suppressClick.current = true;\n      setDrag({\n        sticker: start.sticker,\n        pointerId: event.pointerId,\n        x: point.x,\n        y: point.y,\n        size: start.source.size * m.drag.liftScale,\n        rotation: stickerDragRotation(start.sticker.id),\n        source: start.source,\n      });\n      return;\n    }\n    setDrag(current => (current ? { ...current, x: point.x, y: point.y } : current));\n  };\n\n  const endDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    const start = pending.current;\n    if (start && start.pointerId === event.pointerId) pending.current = null;\n    if (!drag || drag.pointerId !== event.pointerId) return;\n    const point = rootPoint(event.clientX, event.clientY);\n    const insideSheet =\n      point.x >= left && point.x <= left + width && point.y >= top && point.y <= top + height;\n    if (!insideSheet) {\n      onPlace?.(drag.sticker, { x: point.x, y: point.y, rotation: drag.rotation, size: drag.size });\n      setDrag(null);\n      return;\n    }\n    // Let go over the sheet: the sticker goes home. A Web Animations run, so this is reachable through\n    // `document.getAnimations()` like everything else that moves here.\n    const node = ghost.current;\n    const reduced = typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    if (!node || reduced) {\n      setDrag(null);\n      return;\n    }\n    const home = drag.source;\n    const animation = node.animate(\n      [\n        { transform: `translate(${drag.x - drag.size / 2}px, ${drag.y - drag.size / 2}px) rotate(${drag.rotation}deg) scale(1)` },\n        { transform: `translate(${home.left}px, ${home.top}px) rotate(0deg) scale(${home.size / drag.size})` },\n      ],\n      { duration: m.timing.settle, easing: m.dragEasing, fill: \"both\" },\n    );\n    animation.addEventListener(\"finish\", () => setDrag(null));\n  };\n\n  const alive = open || closing;\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"sticker-picker\"\n      data-picker={id}\n      data-state={open ? \"open\" : closing ? \"closing\" : \"closed\"}\n      className={cn(\"absolute inset-0 z-20 select-none\", vars, className)}\n      style={{ fontFamily: fontStack, pointerEvents: \"none\", ...style }}\n      {...props}\n    >\n      {/* A scrim, not a control: invisible, full screen, and both Escape and the caller's own close\n          control do the same job, so it stays out of the tab order and the accessibility tree. */}\n      {alive && onDismiss ? (\n        <div aria-hidden=\"true\" data-slot=\"dismiss\" onClick={onDismiss} className=\"pointer-events-auto absolute inset-0 cursor-default\" />\n      ) : null}\n\n      <div\n        ref={sheet}\n        data-slot=\"sheet\"\n        role=\"group\"\n        aria-label={label}\n        // A closed sheet is not just invisible: `inert` takes its field, grid and tabs out of the tab\n        // order and out of the accessibility tree, which `opacity: 0` on its own would not.\n        inert={!open}\n        className=\"pointer-events-auto absolute overflow-hidden\"\n        style={{\n          left,\n          top,\n          width,\n          height,\n          borderRadius: m.sheet.radius,\n          ...continuous,\n          background: \"rgb(var(--ios-sp-glass) / var(--ios-sp-alpha))\",\n          boxShadow: \"0 5px 30px 6px rgb(0 0 0 / var(--ios-sp-shadow-alpha)), var(--ios-sp-rim)\",\n          backdropFilter: \"blur(45px) saturate(1.9)\",\n          WebkitBackdropFilter: \"blur(45px) saturate(1.9)\",\n        }}\n      >\n        <div ref={content} data-slot=\"sheet-content\" className=\"absolute inset-0\">\n          {grabber ? (\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"grabber\"\n              className=\"pointer-events-none absolute left-1/2 [background:rgba(0,0,0,0.3)] dark:[background:rgba(255,255,255,0.3)]\"\n              style={{\n                top: m.grabber.top,\n                width: m.grabber.width,\n                height: m.grabber.height,\n                marginLeft: -m.grabber.width / 2,\n                borderRadius: m.grabber.radius,\n              }}\n            />\n          ) : null}\n\n          {search ? (\n            <div data-slot=\"search-row\" className=\"absolute flex items-center\" style={{ left: m.grid.inset, right: m.grid.inset, top: m.search.top, height: m.search.rowHeight }}>\n              <div\n                className=\"relative w-full\"\n                style={{ height: m.search.fieldHeight, borderRadius: m.search.radius, background: \"var(--ios-sp-field)\" }}\n              >\n                <span aria-hidden=\"true\" className=\"pointer-events-none absolute flex\" style={{ left: 10, top: (m.search.fieldHeight - 18.6667 * m.search.glyphScale) / 2 }}>\n                  <SearchGlyph scale={m.search.glyphScale} />\n                </span>\n                <input\n                  type=\"search\"\n                  data-slot=\"search-field\"\n                  aria-label={`Search ${label}`}\n                  placeholder={searchPlaceholder}\n                  value={query}\n                  onChange={event => setQuery(event.target.value)}\n                  className=\"size-full bg-transparent outline-none placeholder:[color:var(--ios-sp-muted)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff] [&::-webkit-search-cancel-button]:appearance-none\"\n                  style={{\n                    paddingLeft: m.search.textInset,\n                    paddingRight: 10,\n                    borderRadius: m.search.radius,\n                    fontSize: m.search.fontSize,\n                    fontWeight: m.search.weight,\n                    letterSpacing: 0,\n                    color: \"var(--ios-sp-label)\",\n                  }}\n                />\n              </div>\n            </div>\n          ) : null}\n\n          <div\n            data-slot=\"grid-scroller\"\n            className=\"absolute overflow-y-auto overflow-x-hidden [overscroll-behavior:contain]\"\n            style={{ left: 0, right: 0, top: searchHeight, bottom: m.strip.height, padding: m.grid.inset }}\n          >\n            {matches.length === 0 ? (\n              <p data-slot=\"empty\" className=\"w-full text-center\" style={{ marginTop: 48, fontSize: 15, color: \"var(--ios-sp-muted)\" }}>\n                {emptyLabel}\n              </p>\n            ) : (\n              <div\n                data-slot=\"grid\"\n                role=\"group\"\n                aria-label={searching ? `${label} results` : activeTab?.label ?? label}\n                onKeyDown={onGridKeyDown}\n                className=\"grid\"\n                style={{ gridTemplateColumns: `repeat(${columns}, ${tileSize}px)`, gap: m.grid.gap }}\n              >\n                {matches.map((sticker, index) => (\n                  <button\n                    key={sticker.id}\n                    type=\"button\"\n                    data-slot=\"sticker\"\n                    data-index={index}\n                    data-sticker={sticker.id}\n                    data-dragging={drag?.sticker.id === sticker.id ? \"true\" : undefined}\n                    aria-label={sticker.label}\n                    tabIndex={index === activeIndex ? 0 : -1}\n                    onFocus={() => setActive(index)}\n                    onPointerDown={event => onTilePointerDown(event, sticker)}\n                    onPointerMove={onTilePointerMove}\n                    onPointerUp={endDrag}\n                    onPointerCancel={endDrag}\n                    onClick={() => {\n                      if (suppressClick.current) {\n                        suppressClick.current = false;\n                        return;\n                      }\n                      onSelect?.(sticker);\n                    }}\n                    className=\"block touch-none p-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\"\n                    style={{ width: tileSize, height: tileSize, opacity: drag?.sticker.id === sticker.id ? 0 : undefined }}\n                  >\n                    <StickerArt sticker={sticker} size={tileSize} />\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n\n          <div\n            ref={strip}\n            data-slot=\"tab-strip\"\n            role=\"tablist\"\n            aria-label={`${label} categories`}\n            aria-orientation=\"horizontal\"\n            onKeyDown={onTabsKeyDown}\n            className=\"absolute bottom-0 left-0 flex w-full items-stretch overflow-x-auto\"\n            style={{\n              height: m.strip.height,\n              paddingLeft: m.strip.gutter,\n              paddingRight: m.strip.gutter,\n              gap: m.strip.spacing,\n              boxShadow: `inset 0 ${m.strip.hairline}px 0 0 var(--ios-sp-separator)`,\n            }}\n          >\n            {tabs.map(entry => {\n              const selected = entry.id === activeTabId;\n              return (\n                <Fragment key={entry.id}>\n                  {entry.separatorBefore ? (\n                    <span aria-hidden=\"true\" data-slot=\"tab-separator\" className=\"block shrink-0 self-center\" style={{ width: m.strip.separatorWidth, height: m.strip.itemHeight, background: \"var(--ios-sp-separator)\" }} />\n                  ) : null}\n                  <button\n                    type=\"button\"\n                    role=\"tab\"\n                    data-slot=\"tab\"\n                    data-tab={entry.id}\n                    aria-selected={selected}\n                    aria-label={entry.label}\n                    tabIndex={entry.id === activeTabId ? 0 : -1}\n                    onClick={() => {\n                      selectTab(entry.id);\n                      setQuery(\"\");\n                    }}\n                    className=\"flex shrink-0 items-center justify-center focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0088ff]\"\n                    style={{\n                      width: m.strip.itemWidth,\n                      height: m.strip.itemHeight,\n                      borderRadius: m.grid.tileRadius,\n                      ...continuous,\n                      // The selection sits inside the item box, so the strip's own 38 pitch is intact.\n                      background: selected ? \"var(--ios-sp-tab-selected)\" : \"transparent\",\n                      backgroundClip: \"padding-box\",\n                      padding: m.strip.selectionInset,\n                      color: selected ? \"var(--ios-sp-label)\" : \"var(--ios-sp-muted)\",\n                    }}\n                  >\n                    <TabGlyph icon={entry.icon} size={m.strip.iconSize} />\n                  </button>\n                </Fragment>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      {/*\n        The lifted sticker. It lives outside the sheet so it can be carried over the transcript, and it\n        is inert to the accessibility tree: the button it came from still carries the name, and letting\n        go over the transcript reaches the same handler a tap does.\n      */}\n      {drag ? (\n        <div\n          ref={ghost}\n          aria-hidden=\"true\"\n          data-slot=\"drag-ghost\"\n          className=\"pointer-events-none absolute left-0 top-0\"\n          style={{\n            width: drag.size,\n            height: drag.size,\n            transform: `translate(${drag.x - drag.size / 2}px, ${drag.y - drag.size / 2}px) rotate(${drag.rotation}deg)`,\n            filter: `drop-shadow(0 ${m.drag.shadowLift}px ${m.drag.shadowBlur}px var(--ios-sp-ghost-shadow))`,\n          }}\n        >\n          <StickerArt sticker={drag.sticker} size={drag.size} />\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/sticker-picker.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-select-mode",
      "title": "iOS selection mode",
      "description": "Selection circles beside messages, the close button, and the bottom toolbar.",
      "files": [
        {
          "path": "registry/imessage/ios-select-mode.tsx",
          "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 message selection mode, measured from `references/ios/captures/select-mode-dark.png`\n * (402×874 @3x).\n *\n * - A Ø21.67 circle sits centred on x 23.83, vertically centred on each bubble body. Unselected is\n *   a 1.67pt ring (#464649 over black); selected is a solid #0091ff disc with a 9.67 white check\n *   (1.5pt stroke).\n * - The bubbles do **not** move: the last one still ends at x 386, exactly where it sits normally.\n * - The nav bar's back button is gone; a Ø44 glass circle with a 17×17 ✕ takes its place at\n *   (364, 84), i.e. mirrored to the trailing edge.\n * - The bottom toolbar is two Ø48 glass circles centred (52, 822) and (350, 822): a 21×24 trash and\n *   a 23×19.67 forward arrow. In dark the glass body reads #191919 (rgba(28,28,28,0.9) over black)\n *   with a #f4f3f4 glyph, and its outermost ring reads #2e2e2e–#363636: the 1px inset rim.\n *\n * ## Motion: what is measured and what is not\n *\n * **Measured**: both end poses. At rest the screen is the capture above, to the point; at `t = 0`\n * every travel distance below is read off that same capture, so the circles leave from the screen's\n * own leading edge and the toolbar leaves from its own bottom edge rather than from nowhere.\n *\n * **NOT measured**: the durations, the easings, and the order things move in. No capture in this\n * repo records select mode in motion. The numbers in `selectModeMotion` are borrowed, not derived:\n *\n * - `enter` 260 / `exit` 200 with `cubic-bezier(0.32, 0.72, 0, 1)` in and `cubic-bezier(0.4, 0, 1, 1)`\n *   out are the measured \"Send with effect\" screen's timings (`ios-effects-picker.tsx`), which\n *   `message-reply.tsx` already reuses for the same reason.\n * - `fill` 220 with `cubic-bezier(0.2, 0.95, 0.3, 1)` is the long-press menu's spring curve from\n *   `message-actions.tsx`, at the same 220 the effects screen moves its preview rows.\n * - The close button grows from 0.86, which is the plus menu sheet's scale in `ios-plus-menu.tsx`.\n *\n * Everything animated is one number, `--ios-sel-t`, going 0 → 1: the circles slide in from the\n * leading edge and fade while the log shifts by `shift`, so the two never cross; the ✕ grows in at\n * the trailing edge of the nav bar; the toolbar rises from below while the composer it replaces goes\n * down. `active={false}` reverses all of it and then calls `onExited`.\n *\n * `progress` (0..1) seeks that timeline instead of playing it, which is what the harness scrubs. A\n * seeked frame carries no transition at all, so the same checkpoint renders identically every run;\n * the live path is a plain CSS transition, so `document.getAnimations()` can reach every part of it.\n * `prefers-reduced-motion: reduce` drops the transitions and the surface simply appears.\n *\n * The parent has to keep this mounted until `onExited`, and it has to derive that \"closing\" state\n * DURING RENDER, not in an effect: an effect leaves one committed frame with the rows already gone\n * and the exit never runs. `ios-messages-app.tsx` does the same for the long-press overlay.\n *\n *     const [seen, setSeen] = useState(selecting);\n *     const [closing, setClosing] = useState(false);\n *     if (seen !== selecting) { setSeen(selecting); setClosing(!selecting); }   // during render\n *     {(selecting || closing) && (\n *       <IosSelectMode active={selecting} onExited={() => setClosing(false)}>…</IosSelectMode>\n *     )}\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * `box-shadow` only accepts `none` on its own, so `none, <shadow>` drops the whole declaration and\n * the glass loses both its shadow and its rim. Every slot holds a real shadow and the unused one is\n * fully transparent. The classes are written out in full: Tailwind only compiles what it can read\n * literally in the source.\n */\nconst vars =\n  \"[--ios-sel-ring:rgba(60,60,67,0.3)] [--ios-sel-check:#ffffff] [--ios-sel-tint:#0088ff] \" +\n  \"[--ios-sel-glass:rgba(255,255,255,0.9)] [--ios-sel-rim:0_0_0_0_rgba(0,0,0,0)] [--ios-sel-shadow:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-sel-glyph:#1a1919] \" +\n  \"dark:[--ios-sel-ring:#464649] dark:[--ios-sel-tint:#0091ff] \" +\n  \"dark:[--ios-sel-glass:rgba(28,28,28,0.9)] dark:[--ios-sel-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-sel-shadow:0_0_0_0_rgba(0,0,0,0)] dark:[--ios-sel-glyph:#f4f3f4]\";\n\n/** Measured selection geometry, in points. */\nexport const selectionMetrics = { circleSize: 21.6667, circleCenterX: 23.8333, ringWidth: 1.6667, checkSize: 9.6667, toolbarButton: 48, toolbarCenterY: 822 } as const;\n\nconst ENTER_CURVE = [0.32, 0.72, 0, 1] as const;\nconst EXIT_CURVE = [0.4, 0, 1, 1] as const;\nconst FILL_CURVE = [0.2, 0.95, 0.3, 1] as const;\nconst curveText = (curve: readonly number[]) => `cubic-bezier(${curve.join(\", \")})`;\n\n/**\n * Durations and travel. The two distances are measured; the four times and three curves are not.\n * See the file comment for where each one was borrowed from.\n */\nexport const selectModeMotion = {\n  enter: 260,\n  exit: 200,\n  /** One selection circle filling, or emptying. */\n  fill: 220,\n  ease: curveText(ENTER_CURVE),\n  exitEase: curveText(EXIT_CURVE),\n  fillEase: curveText(FILL_CURVE),\n  /**\n   * How far a circle travels. Measured: at 0 its trailing edge sits on the screen's leading edge,\n   * so it is the settled centre plus its own radius.\n   */\n  circleSlide: selectionMetrics.circleCenterX + selectionMetrics.circleSize / 2,\n  /**\n   * How far the toolbar travels. Measured: the buttons' 798 top edge (centre 822 less the Ø48's\n   * radius) down to the 874 screen bottom.\n   */\n  toolbarRise: 76,\n  /** The ✕ grows from here, the plus menu sheet's scale. Not measured. */\n  closeScale: 0.86,\n} as const;\n\nfunction clamp01(value: number) {\n  return Math.max(0, Math.min(1, value));\n}\n\n/**\n * A CSS `cubic-bezier` evaluated in JS, so a seeked frame lands on the pose the played transition\n * would have been in at that fraction of its own duration. Newton first, bisection when Newton\n * wanders off the curve (which it does near a zero-slope control point like this one's `0, 1`).\n */\nfunction cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;\n  const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;\n  const atX = (t: number) => ((ax * t + bx) * t + cx) * t;\n  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;\n  return (progress: number) => {\n    const p = clamp01(progress);\n    if (p === 0 || p === 1) return p;\n    let t = p;\n    for (let i = 0; i < 8; i++) {\n      const error = atX(t) - p;\n      if (Math.abs(error) < 1e-7) break;\n      const slope = slopeX(t);\n      if (Math.abs(slope) < 1e-7) break;\n      t -= error / slope;\n    }\n    if (!(t >= 0 && t <= 1) || Math.abs(atX(t) - p) > 1e-5) {\n      let low = 0, high = 1;\n      for (let i = 0; i < 30; i++) {\n        t = (low + high) / 2;\n        if (atX(t) < p) low = t; else high = t;\n      }\n    }\n    return ((ay * t + by) * t + cy) * t;\n  };\n}\n\nconst enterCurve = cubicBezier(...ENTER_CURVE);\nconst exitCurve = cubicBezier(...EXIT_CURVE);\n\n/** Live, so a viewer who turns motion off mid-session gets the settled pose without a reload. */\nfunction usePrefersReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    if (typeof matchMedia !== \"function\") return;\n    const query = matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => setReduced(query.matches);\n    sync();\n    query.addEventListener(\"change\", sync);\n    return () => query.removeEventListener(\"change\", sync);\n  }, []);\n  return reduced;\n}\n\nexport type SelectModeMotionProps = {\n  /** True enters select mode, false plays the exit and then calls `onExited`. */\n  active?: boolean;\n  /**\n   * Seek the transition currently running to this fraction (0..1) instead of playing it, which is\n   * what the harness does. A seeked exit is a picture, not a lifecycle: it never calls `onExited`.\n   */\n  progress?: number;\n  onExited?: () => void;\n};\n\nexport type SelectModeTransition = {\n  /** 0 = no select mode, 1 = settled. Published as `--ios-sel-t`; every animated style reads it. */\n  t: number;\n  /** `<duration> <easing>` for the direction now running, or \"\" when the pose is seeked or reduced. */\n  timing: string;\n};\n\nconst SelectModeContext = createContext<SelectModeTransition | null>(null);\n\n/**\n * The whole entrance and exit as one number. Call it once at the top of a select-mode screen (or let\n * `IosSelectMode` do it) and every piece below picks the result up from context.\n */\nexport function useSelectModeTransition({ active = true, progress, onExited }: SelectModeMotionProps = {}): SelectModeTransition {\n  const reduced = usePrefersReducedMotion();\n  const seeking = progress !== undefined;\n\n  // Derived DURING RENDER, not in an effect: `onExited` must fire for an open surface that closes\n  // and never for one that mounted closed, and the exit has to be committed on the frame `active`\n  // goes false or there is nothing left on screen to animate out.\n  const [seenActive, setSeenActive] = useState(active);\n  const [closing, setClosing] = useState(false);\n  if (seenActive !== active) {\n    setSeenActive(active);\n    setClosing(!active);\n  }\n\n  const exited = useRef(onExited);\n  useEffect(() => { exited.current = onExited; }, [onExited]);\n  useEffect(() => {\n    if (!closing || seeking) return;\n    const timer = setTimeout(() => { setClosing(false); exited.current?.(); }, reduced ? 0 : selectModeMotion.exit);\n    return () => clearTimeout(timer);\n  }, [closing, seeking, reduced]);\n\n  const t = seeking\n    ? (active ? enterCurve(progress) : 1 - exitCurve(progress))\n    : (active ? 1 : 0);\n  const timing = seeking || reduced\n    ? \"\"\n    : `${active ? selectModeMotion.enter : selectModeMotion.exit}ms ${active ? selectModeMotion.ease : selectModeMotion.exitEase}`;\n  return { t, timing };\n}\n\n/** `transition` for the properties given, or nothing at all when the frame is seeked. */\nfunction transitionFor(timing: string, ...properties: string[]) {\n  return timing ? properties.map(property => `${property} ${timing}`).join(\", \") : undefined;\n}\n\n/** Own props win over the context, so one piece can be driven on its own inside a settled screen. */\nfunction useSelectMotion(props: SelectModeMotionProps): SelectModeTransition {\n  const own = useSelectModeTransition(props);\n  const inherited = useContext(SelectModeContext);\n  return props.active !== undefined || props.progress !== undefined || !inherited ? own : inherited;\n}\n\n/** The driver as inline custom properties, so a style rule elsewhere can read the same number. */\nfunction motionVars(transition: SelectModeTransition): CSSProperties {\n  return {\n    \"--ios-sel-t\": transition.t.toFixed(4),\n    \"--ios-sel-transition\": transition.timing ? `opacity ${transition.timing}` : \"none\",\n  } as CSSProperties;\n}\n\n/**\n * Wrap a whole select-mode screen in this and every piece below animates together off one timeline.\n * It generates no box of its own (`display: contents`), so it can go around a screen whose children\n * are absolutely positioned in the frame's coordinates without moving any of them.\n *\n * A nav bar rendered inside it loses its back button to a cross-fade on the same timeline, which is\n * what the capture's settled state shows: no back button, a ✕ at the trailing edge instead.\n */\nexport function IosSelectMode({ active = true, progress, onExited, children }: SelectModeMotionProps & { children: ReactNode }) {\n  const transition = useSelectModeTransition({ active, progress, onExited });\n  return (\n    <SelectModeContext.Provider value={transition}>\n      <div data-slot=\"ios-select-mode\" data-active={active || undefined} data-progress={transition.t.toFixed(3)} style={{ display: \"contents\", ...motionVars(transition) }}>\n        <style>{\n          '[data-slot=\"ios-select-mode\"] [data-slot=\"ios-nav-bar\"] [data-slot=\"back\"]{opacity:calc(1 - var(--ios-sel-t));transition:var(--ios-sel-transition)}' +\n          '[data-slot=\"ios-select-mode\"][data-active] [data-slot=\"ios-nav-bar\"] [data-slot=\"back\"]{pointer-events:none}'\n        }</style>\n        {children}\n      </div>\n    </SelectModeContext.Provider>\n  );\n}\n\nexport type SelectionCircleProps = Omit<ComponentProps<\"button\">, \"children\" | \"onChange\"> & {\n  selected?: boolean;\n  onChange?: (next: boolean) => void;\n  /** Accessible name, e.g. the message text. */\n  label?: string;\n  size?: number;\n  /** Seek the fill (0..1) instead of transitioning it, the way `progress` seeks the entrance. */\n  fillProgress?: number;\n};\n\n/**\n * The circle. Selecting fills it rather than swapping one glyph for another: the tint disc grows\n * from 0.4 out to the ring it sits inside, and the check wipes along its own path behind it. Both\n * are one number, `--ios-sel-fill`, so `fillProgress` seeks the whole thing.\n */\nexport function SelectionCircle({ selected = false, onChange, label, size = selectionMetrics.circleSize, fillProgress, className, style, ...rest }: SelectionCircleProps) {\n  const reduced = usePrefersReducedMotion();\n  const stroke = selectionMetrics.ringWidth * (size / selectionMetrics.circleSize);\n  const fill = fillProgress === undefined ? (selected ? 1 : 0) : clamp01(fillProgress);\n  const timing = fillProgress === undefined && !reduced ? `${selectModeMotion.fill}ms ${selectModeMotion.fillEase}` : \"\";\n  return (\n    <button type=\"button\" data-slot=\"selection-circle\" role=\"checkbox\" aria-checked={selected} aria-label={label ?? (selected ? \"Deselect message\" : \"Select message\")}\n      onClick={() => onChange?.(!selected)}\n      className={cn(\"relative shrink-0 rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\", vars, className)}\n      style={{ width: size, height: size, boxShadow: `inset 0 0 0 ${stroke}px var(--ios-sel-ring)`, \"--ios-sel-fill\": fill.toFixed(4), ...style } as CSSProperties}\n      {...rest}>\n      {/* The disc covers the ring completely at 1, which is why the ring can stay put underneath. */}\n      <span aria-hidden=\"true\" data-slot=\"fill\" className=\"absolute inset-0 rounded-full\"\n        style={{ background: \"var(--ios-sel-tint)\", opacity: \"var(--ios-sel-fill)\", transform: \"scale(calc(0.4 + 0.6 * var(--ios-sel-fill)))\", transition: transitionFor(timing, \"opacity\", \"transform\") }} />\n      <svg aria-hidden=\"true\" data-slot=\"check\" className=\"absolute\" width={size} height={size} viewBox=\"0 0 21.6667 21.6667\" fill=\"none\"\n        stroke=\"var(--ios-sel-check)\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" style={{ left: 0, top: 0 }}>\n        {/* `pathLength` makes the dash lengths a fraction of the stroke, so the wipe needs no\n            measurement of the path and stays right at any `size`. */}\n        <path d=\"M6 11.4 9.35 14.75 15.67 6.92\" pathLength={1}\n          style={{ strokeDasharray: \"1px 1px\", strokeDashoffset: \"calc((1 - var(--ios-sel-fill)) * 1px)\", opacity: \"var(--ios-sel-fill)\", transition: transitionFor(timing, \"stroke-dashoffset\", \"opacity\") }} />\n      </svg>\n    </button>\n  );\n}\n\nexport type MessageSelectionRowProps = Omit<ComponentProps<\"div\">, \"onChange\"> & SelectModeMotionProps & {\n  selected?: boolean;\n  onChange?: (next: boolean) => void;\n  /** Off in the capture: bubbles keep their normal position while the circles overlay the gutter. */\n  shift?: number;\n  label?: string;\n  circleCenterX?: number;\n  /** Seek this row's fill, e.g. to screenshot a selection mid-tick. */\n  fillProgress?: number;\n  children: ReactNode;\n};\n\n/**\n * One row of the selection list: the circle in the left gutter plus the message itself.\n *\n * The circle is centred on the bubble **body**, not on the row. The two only agree when the row is\n * a bare bubble: a reaction balloon adds 28 of headroom above the body and a status line adds a\n * row of type below it, and in `select-mode-dark.png` neither moves the circle. The reacted \"Ok\"\n * bubble makes the difference visible: its body runs y 552.67–592.33 and the capture's circle is\n * centred on 572.33, which is the body's centre, while the row's centre sits 13.83 higher.\n *\n * Entering, the circle slides out of the leading edge and fades while the content moves right by\n * `shift` on the same curve, so the two are never on top of each other on the way in. `shift` is 0\n * in the capture, which makes that half of the motion a no-op unless a caller asks for it.\n */\nexport function MessageSelectionRow({ selected = false, onChange, shift = 0, label, circleCenterX = selectionMetrics.circleCenterX, fillProgress, active, progress, onExited, className, style, children, ...props }: MessageSelectionRowProps) {\n  const transition = useSelectMotion({ active, progress, onExited });\n  const size = selectionMetrics.circleSize;\n  const row = useRef<HTMLDivElement>(null);\n  useLayoutEffect(() => {\n    const root = row.current;\n    if (!root) return;\n    const measure = () => {\n      const body = root.querySelector<HTMLElement>('[data-slot=\"bubble\"], [data-slot=\"emoji\"]');\n      const box = root.getBoundingClientRect();\n      const centre = body ? body.getBoundingClientRect().top + body.getBoundingClientRect().height / 2 - box.top : box.height / 2;\n      root.style.setProperty(\"--ios-sel-centre\", `${centre.toFixed(2)}px`);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    document.fonts?.ready.then(measure).catch(() => {});\n    return () => observer.disconnect();\n  }, [children]);\n  const arrived = transition.t > 0.5;\n  return (\n    <div ref={row} data-slot=\"message-selection-row\" data-selected={selected || undefined}\n      className={cn(\"relative w-full\", vars, className)} style={{ fontFamily: font, ...motionVars(transition), ...style }} {...props}>\n      {/* `flex` keeps the wrapper exactly as tall as the circle: an inline box would add leading and pull the centre up. */}\n      <span data-slot=\"selection-slot\" className=\"absolute flex\" inert={!arrived}\n        style={{\n          left: circleCenterX - size / 2, top: \"var(--ios-sel-centre, 50%)\",\n          transform: `translateY(-50%) translateX(calc((var(--ios-sel-t) - 1) * ${selectModeMotion.circleSlide}px))`,\n          opacity: \"var(--ios-sel-t)\",\n          transition: transitionFor(transition.timing, \"transform\", \"opacity\"),\n        }}>\n        <SelectionCircle selected={selected} onChange={onChange} label={label} fillProgress={fillProgress} />\n      </span>\n      <div data-slot=\"row-content\"\n        style={shift ? { transform: `translateX(calc(var(--ios-sel-t) * ${shift}px))`, transition: transitionFor(transition.timing, \"transform\") } : undefined}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\nfunction GlassButton({ size, label, onClick, disabled = false, tabIndex, style, children }: { size: number; label: string; onClick?: () => void; disabled?: boolean; tabIndex?: number; style?: CSSProperties; children: ReactNode }) {\n  return (\n    // `aria-disabled` rather than `disabled`: a toolbar keeps every button reachable so the arrow\n    // keys can still walk past one that has nothing to act on.\n    <button type=\"button\" aria-label={label} aria-disabled={disabled || undefined} tabIndex={tabIndex} onClick={disabled ? undefined : onClick}\n      className=\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n      style={{ width: size, height: size, background: \"var(--ios-sel-glass)\", boxShadow: \"var(--ios-sel-rim), var(--ios-sel-shadow)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\", color: \"var(--ios-sel-glyph)\", pointerEvents: \"auto\", ...style }}>\n      {children}\n    </button>\n  );\n}\n\nexport type IosSelectionCloseButtonProps = Omit<ComponentProps<\"div\">, \"children\"> & SelectModeMotionProps & { onClose?: () => void };\n\n/**\n * The ✕ that replaces the nav bar's back button, at the trailing edge (364, 84). It grows in on the\n * screen's timeline, and Escape does what tapping it does: select mode is never a place you can be\n * stuck in with only a pointer.\n */\nexport function IosSelectionCloseButton({ onClose, active, progress, onExited, className, style, ...props }: IosSelectionCloseButtonProps) {\n  const transition = useSelectMotion({ active, progress, onExited });\n  const arrived = transition.t > 0.5;\n  useEffect(() => {\n    if (!onClose || !arrived) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      onClose();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [onClose, arrived]);\n  const grow = selectModeMotion.closeScale;\n  return (\n    // The wrapper spans the screen only so the button can be placed in its coordinates; it must not\n    // swallow the taps meant for the selection circles underneath.\n    <div data-slot=\"ios-selection-close\" className={cn(\"pointer-events-none absolute inset-0 select-none\", vars, className)}\n      style={{ fontFamily: font, ...motionVars(transition), ...style }} {...props}>\n      <GlassButton size={44} label=\"Done selecting\" onClick={onClose}\n        style={{\n          left: 342, top: 62,\n          opacity: \"var(--ios-sel-t)\",\n          transform: `scale(calc(${grow} + ${(1 - grow).toFixed(4)} * var(--ios-sel-t)))`,\n          transition: transitionFor(transition.timing, \"opacity\", \"transform\"),\n          pointerEvents: arrived ? \"auto\" : \"none\",\n        }}>\n        {/* The capture puts the ✕ ink on x 355.0–372.0, y 76.0–93.0: a whole point of it is left of\n            the circle's own centre once Blink has snapped the centred 17 box up from 355.5 to 356. */}\n        <svg aria-hidden=\"true\" width=\"17\" height=\"17\" viewBox=\"0 0 17 17\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" style={{ transform: \"translateX(-1px)\" }}>\n          <path d=\"M1.1 1.1 15.9 15.9M15.9 1.1 1.1 15.9\" />\n        </svg>\n      </GlassButton>\n    </div>\n  );\n}\n\nexport type IosSelectionToolbarProps = Omit<ComponentProps<\"div\">, \"children\"> & SelectModeMotionProps & {\n  count?: number;\n  onDelete?: () => void;\n  onForward?: () => void;\n  /**\n   * The composer row this toolbar replaces. Passed in rather than left to the caller so the two\n   * cross on one timeline: the toolbar rises out of the bottom edge as the composer drops through\n   * it. `ios-plus-menu.tsx` takes the same slot for the same reason.\n   *\n   * Hand it a composer in normal flow, not one that positions itself: the slot drops by its own\n   * height, and a child that has taken itself out of flow leaves the slot no height to drop by.\n   */\n  composer?: ReactNode;\n};\n\n/**\n * The bottom toolbar: trash at (52, 822), forward at (350, 822), both Ø48 glass circles. It rises\n * from below the screen; give it the `composer` it replaces and that goes down as it comes up.\n */\nexport function IosSelectionToolbar({ count = 0, onDelete, onForward, composer, active, progress, onExited, className, style, ...props }: IosSelectionToolbarProps) {\n  const transition = useSelectMotion({ active, progress, onExited });\n  const arrived = transition.t > 0.5;\n  const disabled = count === 0;\n  // A toolbar is one tab stop; the arrow keys move between its buttons.\n  const [focusIndex, setFocusIndex] = useState(0);\n  const bar = useRef<HTMLDivElement>(null);\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (![\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) return;\n    const buttons = Array.from(bar.current?.querySelectorAll<HTMLButtonElement>(\"button\") ?? []);\n    if (!buttons.length) return;\n    event.preventDefault();\n    const from = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    const current = from < 0 ? focusIndex : from;\n    const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? buttons.length - 1 : event.key === \"ArrowRight\" ? (current + 1) % buttons.length : (current - 1 + buttons.length) % buttons.length;\n    setFocusIndex(next);\n    buttons[next]?.focus();\n  }\n  const button = (index: number): CSSProperties => ({\n    top: -24, opacity: disabled ? 0.4 : 1,\n    pointerEvents: arrived ? \"auto\" : \"none\",\n    left: index === 0 ? 28 : 326,\n  });\n  return (\n    <>\n      {composer !== undefined && (\n        // Not `display: none` at the far end: it has to be on screen to be seen leaving, so it is\n        // pushed a whole height down and made inert instead.\n        <div data-slot=\"composer-slot\" className=\"absolute bottom-0 left-0 w-full\" inert={arrived}\n          style={{\n            ...motionVars(transition),\n            transform: \"translateY(calc(var(--ios-sel-t) * 100%))\",\n            opacity: \"max(0, 1 - var(--ios-sel-t) * 2.5)\",\n            transition: transitionFor(transition.timing, \"transform\", \"opacity\"),\n          }}>\n          {composer}\n        </div>\n      )}\n      <div ref={bar} data-slot=\"ios-selection-toolbar\" role=\"toolbar\" aria-label=\"Selected messages\" onKeyDown={onKeyDown}\n        className={cn(\"pointer-events-none absolute bottom-0 left-0 h-[52px] w-full select-none\", vars, className)}\n        style={{\n          fontFamily: font, ...motionVars(transition),\n          transform: `translateY(calc((1 - var(--ios-sel-t)) * ${selectModeMotion.toolbarRise}px))`,\n          // Up at full strength for most of the travel, but never painted outside a frame that\n          // does not clip: the fade is spent in the first 40%.\n          opacity: \"min(1, var(--ios-sel-t) * 2.5)\",\n          transition: transitionFor(transition.timing, \"transform\", \"opacity\"),\n          ...style,\n        }} {...props}>\n        <GlassButton size={48} label={`Delete ${count} message${count === 1 ? \"\" : \"s\"}`} disabled={disabled} onClick={onDelete}\n          tabIndex={focusIndex === 0 ? 0 : -1} style={button(0)}>\n          <svg aria-hidden=\"true\" width=\"21\" height=\"24\" viewBox=\"0 0 21 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" style={{ transform: \"translate(-0.6667px, -0.6667px)\" }}>\n            <path d=\"M0.8 4.8h19.4\" />\n            <path d=\"M7.2 4.8V2.6a1.8 1.8 0 0 1 1.8-1.8h3a1.8 1.8 0 0 1 1.8 1.8v2.2\" />\n            <path d=\"M2.9 4.8 4 21a2.2 2.2 0 0 0 2.2 2.2h8.6A2.2 2.2 0 0 0 17 21l1.1-16.2\" />\n            <path d=\"M7.7 9v9.4M10.5 9v9.4M13.3 9v9.4\" />\n          </svg>\n        </GlassButton>\n        <GlassButton size={48} label={`Forward ${count} message${count === 1 ? \"\" : \"s\"}`} disabled={disabled} onClick={onForward}\n          tabIndex={focusIndex === 1 ? 0 : -1} style={button(1)}>\n          <svg aria-hidden=\"true\" width=\"23\" height=\"19.6667\" viewBox=\"0 0 23 19.6667\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinejoin=\"round\" strokeLinecap=\"round\" style={{ transform: \"translateY(0.6667px)\" }}>\n            <path d=\"M13.1 0.9v4.9C5.9 6.2 1.5 10.4 0.9 18.8c2.4-4.5 6.3-6.6 12.2-6.6v4.9l9-8.1z\" />\n          </svg>\n        </GlassButton>\n      </div>\n    </>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-select-mode.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-search",
      "title": "iOS search",
      "description": "The search state of the conversation list: the field rising under the nav, a Cancel button, and results grouped into Conversations, Messages, Photos and Links.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json",
        "https://imessage.swerdlow.dev/r/ios-composer.json"
      ],
      "files": [
        {
          "path": "registry/imessage/ios-search.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar } from \"@/components/imessage/avatar\";\nimport { IosMicIcon } from \"@/components/imessage/ios-composer\";\n\n/**\n * iOS 26 Messages search: the state the conversation list enters when the search field is pulled\n * down or tapped, and the results screen that replaces the list once there is a query.\n *\n * ## Where the numbers come from\n *\n * **Measured from a capture.** The search field itself is the one in\n * `references/ios/captures/list-light.png` / `list-dark.png`, carried over from\n * `ios-conversation-list.tsx` unchanged: a 48pt glass capsule inset 28 from both screen edges, radius\n * 24 (a plain circle, n = 2.045), resting at y 798-846; magnifier ring Ø13.49 centred (54.75, 820.22)\n * with a 1.725 stroke and a 2.467 handle; \"Search\" 17pt weight 500 at x 46.8; the mic 12.6667 x\n * 18.3333 inset 23.3333 from the pill's trailing edge. The 12pt gap to the button beside it is the\n * measured gap to the compose circle. Every row metric below that is shared with the conversation\n * list is also measured from those two frames: the 86.6667 row, the Ø45 avatar at x 26, the 17pt\n * semibold name at x 83, the 15pt secondary time whose ink ends at x 364, the chevron at x 376, the\n * 15pt/20 preview, and the separator from x 83 to 386. Colours are the list's measured tokens, and\n * the #0088ff / #0091ff tint is the measured iOS link colour (SPEC.md, \"iOS colors\").\n *\n * **Read out of the framework.** Nothing in `references/` holds a search *result*, so the results\n * screen is built from ChatKit 26.5 instead of a screenshot. Values were read at runtime off\n * `[[CKUIBehaviorPhone alloc] init]` (a Mac Catalyst probe that dlopens\n * `/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework/ChatKit`); every one of them\n * resolves to `-[CKUIBehavior …]`, which is the iPhone value because `CKUIBehaviorPhone` overrides\n * none of them. Each is named on the constant it feeds in `iosSearchMetrics`. The section titles, the\n * \"See All\" and \"Cancel\" labels and the \"No Results\" copy are the framework's own English strings out\n * of `ChatKit.framework/Versions/A/Resources/ChatKit.loctable`.\n *\n * Two structural facts also come from disassembly rather than guesswork:\n *\n * - `+[CKConversationSearchResultCell conversationListCellClass]` returns the ordinary conversation\n *   list cell at the default content size (it only swaps to `CKConversationLargeTextSearchCell` when\n *   `isAccessibilityPreferredContentSizeCategory` is YES), and\n *   `-[CKUIBehavior searchMessageCellHeightForDisplayScale:]` returns **86.66667** at scale 3, the\n *   conversation list's own row pitch. So a result row is a conversation row, at the measured pitch.\n * - `-[CKMessageSearchResultCell _annotatedResultStringForResult:searchText:]` builds the snippet by\n *   passing `searchMessagesBalloonFont` as **both** the primary and the annotated font and only\n *   swapping the colour: `secondaryLabelColor` to `labelColor` for an incoming message, and\n *   `searchMessagesFromMeUnannotatedLabelColor` (white at 0.6) to `+[UIColor whiteColor]` for one you\n *   sent. The match is therefore recoloured, never bolded. Before that it trims the snippet with\n *   `ck_trimmedStringWithPreferredLength:anchoredAroundSubstring:` at `searchMessagesMaxSummaryLength`\n *   (200), anchored on the match, which `trimAroundMatch` below reproduces.\n *\n * **Judgement, not measured.** Called out again on each constant: where the active field sits under\n * the status bar; the crossfade offsets and the 260/200 ms durations (reused from the measured\n * `effectsPickerMetrics.timing` of the \"Send with effect\" screen, which is a different screen, so they\n * carry no authority here); the Ø17 clear button; laying the Photos and Links sections out as\n * horizontal strips and the tile size that follows from `searchDefaultMaxResults`; and applying the\n * Documents cell's paddings to a link card. Do not quote any of those as measured.\n *\n * ## Motion\n *\n * One Web Animations timeline over four elements, so `document.getAnimations()` reaches it and\n * `progress` pauses and seeks every part to the same frame on every run. Closing runs the same\n * keyframes with `direction: \"reverse\"` and is read off the `open` prop **during render**, so the exit\n * always gets committed frames before `onExited` lets the caller unmount. `prefers-reduced-motion`\n * builds no animation at all: the screen is simply there, and a close still reports on the next frame.\n */\n\n/** Section kinds, named after ChatKit's own controllers (`CKConversationSearchController` and friends). */\nexport type IosSearchSectionKind = \"conversations\" | \"messages\" | \"photos\" | \"links\";\n\nexport type IosSearchConversationResult = {\n  id: string;\n  name: string;\n  initials?: string;\n  /** The matching line. The part of it that matches the query is recoloured, not bolded. */\n  preview: string;\n  time: string;\n};\n\nexport type IosSearchMessageResult = {\n  id: string;\n  /** Who sent it. */\n  name: string;\n  initials?: string;\n  /** The chat it is in, shown ahead of the sender. */\n  conversation?: string;\n  text: string;\n  time: string;\n  /** Your own message: the balloon takes the outgoing fill and the match turns pure white. */\n  fromMe?: boolean;\n};\n\nexport type IosSearchPhotoResult = { id: string; src?: string; name: string; time: string };\n\nexport type IosSearchLinkResult = { id: string; title: string; domain: string; time: string; src?: string };\n\nexport type IosSearchResult =\n  | IosSearchConversationResult\n  | IosSearchMessageResult\n  | IosSearchPhotoResult\n  | IosSearchLinkResult;\n\nexport type IosSearchSection =\n  | { kind: \"conversations\"; title?: string; results: IosSearchConversationResult[] }\n  | { kind: \"messages\"; title?: string; results: IosSearchMessageResult[] }\n  | { kind: \"photos\"; title?: string; results: IosSearchPhotoResult[] }\n  | { kind: \"links\"; title?: string; results: IosSearchLinkResult[] };\n\n/**\n * Every number the screen draws with, and where each one came from. \"ChatKit\" means it was read off\n * `-[CKUIBehavior …]` through the Phone behaviour; \"measured\" means a capture in `references/`.\n */\nexport const iosSearchMetrics = {\n  /** The screen these were measured on. Points equal CSS px. */\n  screen: { width: 402, height: 874 },\n  field: {\n    /** Measured, `list-light.png`: the pill is 48 tall, inset 28 both sides, radius 24. */\n    height: 48,\n    inset: 28,\n    radius: 24,\n    /** Measured: the resting pill spans x 28-314 (286 wide) and y 798-846. */\n    restingTop: 798,\n    restingWidth: 286,\n    /** Measured: the 12pt gap between the pill and the compose circle beside it. */\n    gap: 12,\n    /**\n     * Judgement. No capture holds the active search state, and ChatKit does not place it either:\n     * `searchNavbarCanvasInsets` and `spaceBetweenSearchBarAndComposeButton` are declared on\n     * `CKUIBehaviorMac` only, so on iPhone the bar is UIKit's and its offset is unmeasured. 6 centres\n     * the 48pt pill in a 60pt bar below the status bar.\n     */\n    topFromInset: 6,\n    /** Measured: magnifier, placeholder and mic offsets inside the pill. */\n    glyphLeft: 19,\n    glyphTop: 14,\n    textLeft: 46.8,\n    textTop: 15.5,\n    micRight: 23.3333,\n    micTop: 14.6667,\n    micWidth: 12.6667,\n    micHeight: 18.3333,\n    /** Judgement: UIKit's clear button is a Ø17 disc; it is centred on the mic's ink. */\n    clearSize: 17,\n  },\n  /** ChatKit `-[CKUIBehavior additionalSearchResultTopPadding]`: the gap from the bar to the results. */\n  resultsTopPadding: 8,\n  header: {\n    /** ChatKit `searchHeaderHeight`. */\n    height: 44,\n    /** ChatKit `searchHeaderFont`: SF Semibold 20 (line 23.5547, cap 14.0918). */\n    fontSize: 20,\n    weight: 600,\n    /** ChatKit `searchHeaderButtonFont`: SF Regular 17. */\n    buttonFontSize: 17,\n    /** ChatKit `searchSectionMarginInsets` = {0, 16, 0, 16}. */\n    margin: 16,\n    /** ChatKit `searchSectionHeadersPinToBounds` is YES, so headers stick. */\n    pinned: true,\n  },\n  /**\n   * ChatKit `searchMessageCellHeightForDisplayScale:` returns 86.66667 at scale 3 (86.5 at scale 2),\n   * which is the conversation list's measured row pitch, and\n   * `+[CKConversationSearchResultCell conversationListCellClass]` hands the conversation section the\n   * ordinary list cell. So both row kinds are one measured row.\n   */\n  row: {\n    height: 86.6667,\n    /** Measured, `list-light.png`. */\n    avatar: 45,\n    avatarLeft: 26,\n    avatarTop: 20,\n    textLeft: 83,\n    nameTop: 14,\n    nameSize: 17,\n    nameWeight: 600,\n    timeSize: 15,\n    timeRight: 37.1167,\n    timeTop: 15,\n    chevronLeft: 376,\n    chevronTop: 15,\n    previewTop: 33,\n    previewSize: 15,\n    previewLine: 20,\n    separatorLeft: 83,\n    separatorRight: 16,\n  },\n  message: {\n    /** ChatKit `searchMessagesAvatarSize` = {28, 28}. */\n    avatar: 28,\n    /** ChatKit `searchMessagesTopSpacing` / `searchMessagesBottomSpacing`. */\n    topSpacing: 12,\n    bottomSpacing: 18,\n    /** ChatKit `searchMessagesConversationToSenderSpacing`. */\n    conversationToSender: 4,\n    /** ChatKit `searchMessagesSenderToBalloonSpacing`. */\n    senderToBalloon: 8,\n    /** ChatKit `searchMessagesBalloonToChevronSpacing`. */\n    balloonToChevron: 12,\n    /** ChatKit `searchMessagesHorizontalBalloonMargin`; applied here as the balloon's width ceiling. */\n    horizontalBalloonMargin: 72,\n    /** ChatKit `searchMessagesBalloonFont` (SF Regular 17) and its 20.0215 line box. */\n    balloonFontSize: 17,\n    balloonLine: 20.0215,\n    /** ChatKit `searchMessagesSenderFont` / `searchMessagesDateFont` (SF Regular 12), line 14.1328. */\n    labelFontSize: 12,\n    labelLine: 14.1328,\n    /** ChatKit `searchMessagesDMConversationFont` / `…GroupConversationFont` are SF **Medium** 12. */\n    conversationWeight: 500,\n    /** ChatKit `searchMessagesMaxSummaryLength`. */\n    maxSummaryLength: 200,\n    /** Measured bubble padding (tokens.ts `bubbleMetrics.ios.paddingX`); the vertical padding is what the row height leaves. */\n    balloonPaddingX: 13.85,\n    /** Measured bubble radius, clamped to a capsule by the balloon's own height. */\n    balloonRadius: 19,\n  },\n  photos: {\n    /** ChatKit `searchPhotosInterItemSpacing`. */\n    gap: 10,\n    /**\n     * ChatKit `searchPhotosCellCornerRadius` is 0 on the Phone behaviour. `CKUIBehaviorMac` overrides\n     * it to 8, which is what makes 0 read as a real value rather than an unset default.\n     */\n    radius: 0,\n    /**\n     * Judgement, from two framework numbers: `searchDefaultMaxResults` is 4 and the section is inset\n     * 16 either side, so four tiles and three 10pt gaps across 402 give (370 - 30) / 4 = 85.\n     */\n    tile: 85,\n  },\n  links: {\n    /** ChatKit `searchLinksInterItemSpacing` and `searchLinksCellCornerRadius`. */\n    gap: 10,\n    radius: 10,\n    /** ChatKit `searchLinksFractionalWidthScale` 1.2 and `searchLinksFractionalHeightScale` 0.85, applied to the photo tile. */\n    widthScale: 1.2,\n    heightScale: 0.85,\n    /** ChatKit `searchResultLabelBoldFont` / `searchResultLabelFont`: SF Semibold and Regular 12. */\n    labelFontSize: 12,\n    labelLine: 14.1328,\n    /** Judgement: the Documents cell's own paddings (`searchAttachmentsTitleTopPadding` 12, `searchAttachmentsCellDatePadding` 4) applied to a link card. */\n    titleTop: 12,\n    subtitleTop: 4,\n  },\n  /** ChatKit `searchResultsTitleHeaderBottomPadding`, used here as the gap under a strip section. */\n  sectionGap: 12,\n  /** ChatKit `searchDefaultMaxResults`: how many rows a section shows before \"See All\". */\n  maxResults: 4,\n  empty: {\n    /** ChatKit `searchIndexingTitleFont` (SF Regular 22) and `searchIndexingSubtitleFont` (SF Regular 15). */\n    titleFontSize: 22,\n    subtitleFontSize: 15,\n  },\n  /**\n   * Judgement. Reused from the measured `effectsPickerMetrics.timing` in `ios-effects-picker.tsx`,\n   * which was measured for a different screen, so these carry no authority for this one.\n   */\n  timing: { enter: 260, exit: 200 },\n} as const;\n\n/** ChatKit's own English section titles, out of `ChatKit.loctable`. */\nexport const iosSearchSectionTitles: Record<IosSearchSectionKind, string> = {\n  // SEARCH_CONVERSATIONS_TITLE. The framework calls this section Conversations, not Contacts.\n  conversations: \"Conversations\",\n  messages: \"Messages\", // SEARCH_MESSAGES_TITLE\n  photos: \"Photos\", // SEARCH_PHOTOS_TITLE\n  links: \"Links\", // SEARCH_LINKS_TITLE\n};\n\n/** ChatKit strings: SEARCH, CANCEL, SEARCH_SHOW_MORE, SEARCH_RESULTS_INDEXING_TITLE. */\nexport const iosSearchStrings = {\n  placeholder: \"Search\",\n  cancel: \"Cancel\",\n  seeAll: \"See All\",\n  noResults: \"No Results\",\n} as const;\n\nexport type IosSearchProps = Omit<ComponentProps<\"div\">, \"onSelect\" | \"onChange\"> & {\n  /** The query. Uncontrolled when omitted. */\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  sections?: IosSearchSection[];\n  onSelect?: (result: IosSearchResult, kind: IosSearchSectionKind) => void;\n  onSeeAll?: (kind: IosSearchSectionKind) => void;\n  /** Cancel, and Escape, leave search. */\n  onCancel?: () => void;\n  /** False plays the exit and then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  /** Seek the transition to this fraction (0..1) instead of playing it, which is what the harness does. */\n  progress?: number;\n  /** Space above the field for the status bar. */\n  topInset?: number;\n  /** Where the field rests on the list before it rises. Measured at y 798. */\n  restingFieldTop?: number;\n  /** Rows per section before \"See All\" appears. ChatKit's own default is 4. */\n  maxResults?: number;\n  placeholder?: string;\n  cancelLabel?: string;\n  /** Shown before anything is typed. Native fills this with suggestions, which this kit does not model. */\n  emptyState?: ReactNode;\n  /** Second line under \"No Results\". Native only fills it while the index is still building. */\n  noResultsDetail?: string;\n};\n\nconst font = '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"SF Pro\", \"Helvetica Neue\", Helvetica, Arial, sans-serif';\n\n/**\n * Light values are the conversation list's measured tokens; dark values are its measured dark set.\n * The balloon fills are the kit's measured palette (`tokens.ts`): iOS gray #e9e9eb / #262629, and the\n * blue, which `tokens.ts` still carries from macOS because no iOS capture holds a blue bubble.\n */\nconst vars =\n  \"[--ios-search-bg:#ffffff] [--ios-search-label:#000000] [--ios-search-secondary:#8a8a8e] [--ios-search-chevron:#c5c5c7] [--ios-search-separator:#e8e8e8] \" +\n  \"[--ios-search-glass:rgba(255,255,255,0.9)] [--ios-search-rim:none] [--ios-search-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-search-field:#8a8a8e] \" +\n  \"[--ios-search-tint:#0088ff] [--ios-search-dim:rgba(0,0,0,0.2)] [--ios-search-clear:#c5c5c7] \" +\n  \"[--ios-search-incoming:#e9e9eb] [--ios-search-incoming-text:#000000] [--ios-search-blue-top:#77c7f5] [--ios-search-blue-bottom:#3682f7] [--ios-search-tile:#e9e9eb] \" +\n  \"dark:[--ios-search-bg:#000000] dark:[--ios-search-label:#ffffff] dark:[--ios-search-secondary:#8d8d93] dark:[--ios-search-chevron:#464649] dark:[--ios-search-separator:#2a2a2c] \" +\n  \"dark:[--ios-search-glass:rgba(28,28,28,0.9)] dark:[--ios-search-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-search-shadow:none] dark:[--ios-search-field:#97979d] \" +\n  \"dark:[--ios-search-tint:#0091ff] dark:[--ios-search-dim:rgba(0,0,0,0.5)] dark:[--ios-search-clear:#464649] \" +\n  \"dark:[--ios-search-incoming:#262629] dark:[--ios-search-incoming-text:#ffffff] dark:[--ios-search-blue-top:#589af7] dark:[--ios-search-blue-bottom:#3d8ef7] dark:[--ios-search-tile:#1c1c1e]\";\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\n\nfunction reducedMotion() {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n}\n\nfunction initialsOf(name: string): string {\n  return name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\n/** Where the query lands in a piece of text. Falls back to each word when the whole phrase misses. */\nfunction matchRanges(text: string, query: string): Array<[number, number]> {\n  const needle = query.trim().toLowerCase();\n  if (!needle) return [];\n  const haystack = text.toLowerCase();\n  const find = (term: string) => {\n    const found: Array<[number, number]> = [];\n    for (let at = haystack.indexOf(term); at !== -1; at = haystack.indexOf(term, at + term.length)) found.push([at, at + term.length]);\n    return found;\n  };\n  const whole = find(needle);\n  if (whole.length > 0) return whole;\n  const words = needle.split(/\\s+/).filter(Boolean);\n  return words.flatMap(find).sort((a, b) => a[0] - b[0]);\n}\n\n/**\n * ChatKit's `ck_trimmedStringWithPreferredLength:anchoredAroundSubstring:`: a snippet longer than\n * `max` is cut down to a window centred on the match, with an ellipsis on whichever side was cut.\n */\nexport function trimAroundMatch(text: string, query: string, max = iosSearchMetrics.message.maxSummaryLength): string {\n  if (text.length <= max) return text;\n  const first = matchRanges(text, query)[0];\n  const anchor = first ? first[0] : 0;\n  const start = Math.max(0, Math.min(text.length - max, anchor - Math.floor((max - (first ? first[1] - first[0] : 0)) / 2)));\n  const end = Math.min(text.length, start + max);\n  return `${start > 0 ? \"…\" : \"\"}${text.slice(start, end).trim()}${end < text.length ? \"…\" : \"\"}`;\n}\n\n/**\n * The annotated snippet. Same font throughout, only the colour changes, which is what\n * `-[CKMessageSearchResultCell _annotatedResultStringForResult:searchText:]` does.\n */\nfunction Highlight({ text, query, match }: { text: string; query: string; match: string }) {\n  const ranges = matchRanges(text, query);\n  if (ranges.length === 0) return <>{text}</>;\n  const parts: ReactNode[] = [];\n  let at = 0;\n  ranges.forEach(([start, end], index) => {\n    if (start < at) return;\n    if (start > at) parts.push(text.slice(at, start));\n    parts.push(\n      <span key={`${start}-${index}`} data-slot=\"match\" style={{ color: match }}>\n        {text.slice(start, end)}\n      </span>,\n    );\n    at = end;\n  });\n  if (at < text.length) parts.push(text.slice(at));\n  return <>{parts}</>;\n}\n\n/** The measured list chevron, reused byte for byte. */\nfunction Chevron({ style }: { style?: CSSProperties }) {\n  return (\n    <svg aria-hidden=\"true\" data-slot=\"chevron\" className=\"absolute\" style={style} width=\"11\" height=\"16\" viewBox=\"-2 -2 11 16\" fill=\"none\" stroke=\"var(--ios-search-chevron)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M1 1 6 6 1 11\" />\n    </svg>\n  );\n}\n\n/** The measured list separator, from x 83 to 386 at the row's bottom edge. */\nfunction RowSeparator() {\n  const m = iosSearchMetrics.row;\n  return <span aria-hidden=\"true\" data-slot=\"separator\" className=\"absolute\" style={{ left: m.separatorLeft, right: m.separatorRight, bottom: 0, height: 1, transform: \"translateY(-0.3333px)\", background: \"var(--ios-search-separator)\" }} />;\n}\n\nfunction ConversationRow({ result, query, onSelect }: { result: IosSearchConversationResult; query: string; onSelect?: () => void }) {\n  const m = iosSearchMetrics.row;\n  return (\n    <button type=\"button\" onClick={onSelect} aria-label={`${result.name}, ${result.time}, ${result.preview}`}\n      className=\"absolute inset-0 w-full text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500\">\n      <Avatar aria-hidden=\"true\" size={m.avatar} initials={result.initials ?? initialsOf(result.name)} className=\"absolute\" style={{ left: m.avatarLeft, top: m.avatarTop }} />\n      <span aria-hidden=\"true\" data-slot=\"name\" className=\"absolute truncate\" style={{ left: m.textLeft, right: 96, top: m.nameTop, transform: \"translateY(0.3333px)\", fontSize: m.nameSize, lineHeight: 1, fontWeight: m.nameWeight, letterSpacing: 0, color: \"var(--ios-search-label)\" }}>\n        {result.name}\n      </span>\n      <span aria-hidden=\"true\" data-slot=\"time\" className=\"absolute whitespace-nowrap\" style={{ right: m.timeRight, top: m.timeTop, transform: \"translateY(0.3333px)\", fontSize: m.timeSize, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-search-secondary)\" }}>\n        {result.time}\n      </span>\n      <Chevron style={{ left: m.chevronLeft, top: m.chevronTop, transform: \"translateX(0.3333px)\" }} />\n      <span aria-hidden=\"true\" data-slot=\"preview\" className=\"absolute overflow-hidden\" style={{ left: m.textLeft, right: 34, top: m.previewTop, transform: \"translateY(-0.3333px)\", fontSize: m.previewSize, lineHeight: `${m.previewLine}px`, letterSpacing: 0, color: \"var(--ios-search-secondary)\", display: \"-webkit-box\", WebkitLineClamp: 2, WebkitBoxOrient: \"vertical\" } as CSSProperties}>\n        <Highlight text={result.preview} query={query} match=\"var(--ios-search-label)\" />\n      </span>\n      <RowSeparator />\n    </button>\n  );\n}\n\nfunction MessageRow({ result, query, onSelect }: { result: IosSearchMessageResult; query: string; onSelect?: () => void }) {\n  const row = iosSearchMetrics.row;\n  const m = iosSearchMetrics.message;\n  const snippet = trimAroundMatch(result.text, query);\n  // Every spacing here is a framework value; the balloon's own vertical padding is what the 86.6667\n  // row has left once they are taken out, so it is derived rather than invented.\n  const balloonTop = m.topSpacing + m.labelLine + m.senderToBalloon;\n  const balloonHeight = row.height - balloonTop - m.bottomSpacing;\n  const label = [result.conversation, result.name].filter(Boolean).join(\", \");\n  return (\n    <button type=\"button\" onClick={onSelect} aria-label={`${label}, ${result.time}, ${snippet}`}\n      className=\"absolute inset-0 w-full text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500\">\n      {/* Ø28 centred in the measured 26-71 avatar gutter so the text column still starts at x 83. */}\n      <Avatar aria-hidden=\"true\" size={m.avatar} initials={result.initials ?? initialsOf(result.name)} className=\"absolute\" style={{ left: row.avatarLeft + (row.avatar - m.avatar) / 2, top: m.topSpacing }} />\n      <span aria-hidden=\"true\" data-slot=\"message-heading\" className=\"absolute flex items-baseline overflow-hidden whitespace-nowrap\" style={{ left: row.textLeft, right: row.timeRight + 40, top: m.topSpacing, height: m.labelLine, gap: m.conversationToSender, fontSize: m.labelFontSize, lineHeight: `${m.labelLine}px`, letterSpacing: 0 }}>\n        {result.conversation && <span data-slot=\"conversation\" className=\"shrink-0 truncate\" style={{ fontWeight: m.conversationWeight, color: \"var(--ios-search-label)\" }}>{result.conversation}</span>}\n        <span data-slot=\"sender\" className=\"truncate\" style={{ color: \"var(--ios-search-secondary)\" }}>{result.name}</span>\n      </span>\n      <span aria-hidden=\"true\" data-slot=\"time\" className=\"absolute whitespace-nowrap\" style={{ right: row.timeRight, top: m.topSpacing, fontSize: m.labelFontSize, lineHeight: `${m.labelLine}px`, letterSpacing: 0, color: \"var(--ios-search-secondary)\" }}>\n        {result.time}\n      </span>\n      {/*\n        The track carries the geometry so the balloon inside it can hug its own text and still stop\n        short: an absolutely positioned box cannot take `left`, `right` and a shrink-to-fit width at\n        once. The chevron sits at the row's trailing edge and the framework's 12pt balloon-to-chevron\n        gap becomes the track's right inset, with `searchMessagesHorizontalBalloonMargin` as the\n        ceiling on top of that.\n      */}\n      <span aria-hidden=\"true\" data-slot=\"balloon-track\" className=\"absolute flex items-center\"\n        style={{ left: row.textLeft, right: iosSearchMetrics.screen.width - row.chevronLeft + m.balloonToChevron, top: balloonTop, height: balloonHeight }}>\n        <span data-slot=\"balloon\" className=\"flex h-full items-center overflow-hidden\"\n          style={{\n            maxWidth: `min(100%, ${iosSearchMetrics.screen.width - m.horizontalBalloonMargin}px)`,\n            paddingLeft: m.balloonPaddingX,\n            paddingRight: m.balloonPaddingX,\n            borderRadius: Math.min(m.balloonRadius, balloonHeight / 2),\n            background: result.fromMe ? \"linear-gradient(var(--ios-search-blue-top), var(--ios-search-blue-bottom))\" : \"var(--ios-search-incoming)\",\n            fontSize: m.balloonFontSize,\n            lineHeight: `${m.balloonLine}px`,\n            letterSpacing: 0,\n            color: result.fromMe ? \"rgba(255,255,255,0.6)\" : \"var(--ios-search-secondary)\",\n          }}>\n          <span className=\"truncate\">\n            <Highlight text={snippet} query={query} match={result.fromMe ? \"#ffffff\" : \"var(--ios-search-label)\"} />\n          </span>\n        </span>\n      </span>\n      <Chevron style={{ left: row.chevronLeft, top: balloonTop + balloonHeight / 2 - 8, transform: \"translateX(0.3333px)\" }} />\n      <RowSeparator />\n    </button>\n  );\n}\n\nfunction PhotoTile({ result, onSelect }: { result: IosSearchPhotoResult; onSelect?: () => void }) {\n  const m = iosSearchMetrics.photos;\n  return (\n    <button type=\"button\" onClick={onSelect} aria-label={`Photo from ${result.name}, ${result.time}`}\n      className=\"relative shrink-0 overflow-hidden focus-visible:outline-2 focus-visible:outline-blue-500\"\n      style={{ width: m.tile, height: m.tile, borderRadius: m.radius, background: \"var(--ios-search-tile)\" }}>\n      {result.src && (\n        // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral\n        <img src={result.src} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n      )}\n    </button>\n  );\n}\n\nfunction LinkCard({ result, query, onSelect }: { result: IosSearchLinkResult; query: string; onSelect?: () => void }) {\n  const m = iosSearchMetrics.links;\n  const width = iosSearchMetrics.photos.tile * m.widthScale;\n  const thumbHeight = iosSearchMetrics.photos.tile * m.heightScale;\n  return (\n    <button type=\"button\" onClick={onSelect} aria-label={`${result.title}, ${result.domain}, ${result.time}`}\n      className=\"relative shrink-0 text-left focus-visible:outline-2 focus-visible:outline-blue-500\" style={{ width }}>\n      <span aria-hidden=\"true\" className=\"block overflow-hidden\" style={{ height: thumbHeight, borderRadius: m.radius, background: \"var(--ios-search-tile)\" }}>\n        {result.src && (\n          // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework-neutral\n          <img src={result.src} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n        )}\n      </span>\n      <span aria-hidden=\"true\" className=\"block truncate\" style={{ marginTop: m.titleTop, fontSize: m.labelFontSize, lineHeight: `${m.labelLine}px`, fontWeight: 600, letterSpacing: 0, color: \"var(--ios-search-label)\" }}>\n        <Highlight text={result.title} query={query} match=\"var(--ios-search-tint)\" />\n      </span>\n      <span aria-hidden=\"true\" className=\"block truncate\" style={{ marginTop: m.subtitleTop, fontSize: m.labelFontSize, lineHeight: `${m.labelLine}px`, letterSpacing: 0, color: \"var(--ios-search-secondary)\" }}>\n        {result.domain} · {result.time}\n      </span>\n    </button>\n  );\n}\n\nfunction SectionHeader({ id, title, kind, showSeeAll, onSeeAll }: { id: string; title: string; kind: IosSearchSectionKind; showSeeAll: boolean; onSeeAll?: (kind: IosSearchSectionKind) => void }) {\n  const m = iosSearchMetrics.header;\n  return (\n    <div data-slot=\"section-header\" className=\"sticky top-0 z-10\" style={{ height: m.height, background: \"var(--ios-search-bg)\" }}>\n      <h2 id={id} className=\"absolute m-0 truncate\" style={{ left: m.margin, right: m.margin + 80, top: (m.height - m.fontSize) / 2, fontSize: m.fontSize, lineHeight: 1, fontWeight: m.weight, letterSpacing: 0, color: \"var(--ios-search-label)\" }}>\n        {title}\n      </h2>\n      {showSeeAll && onSeeAll && (\n        <button type=\"button\" data-slot=\"see-all\" onClick={() => onSeeAll(kind)} aria-label={`${iosSearchStrings.seeAll} ${title}`}\n          className=\"absolute rounded focus-visible:outline-2 focus-visible:outline-blue-500\"\n          style={{ right: m.margin, top: (m.height - m.buttonFontSize) / 2, fontSize: m.buttonFontSize, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-search-tint)\" }}>\n          {iosSearchStrings.seeAll}\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport function IosSearch({\n  query: queryProp,\n  defaultQuery = \"\",\n  onQueryChange,\n  sections = [],\n  onSelect,\n  onSeeAll,\n  onCancel,\n  open = true,\n  onExited,\n  progress,\n  topInset = 54,\n  restingFieldTop = iosSearchMetrics.field.restingTop,\n  maxResults = iosSearchMetrics.maxResults,\n  placeholder = iosSearchStrings.placeholder,\n  cancelLabel = iosSearchStrings.cancel,\n  emptyState = null,\n  noResultsDetail,\n  className,\n  style,\n  ...props\n}: IosSearchProps) {\n  const m = iosSearchMetrics;\n  const id = useId();\n  const [internalQuery, setInternalQuery] = useState(defaultQuery);\n  const query = queryProp ?? internalQuery;\n  const setQuery = (next: string) => {\n    if (queryProp === undefined) setInternalQuery(next);\n    onQueryChange?.(next);\n  };\n\n  const scrim = useRef<HTMLDivElement>(null);\n  const surface = useRef<HTMLDivElement>(null);\n  const fieldRow = useRef<HTMLDivElement>(null);\n  const pill = useRef<HTMLDivElement>(null);\n  const cancel = useRef<HTMLButtonElement>(null);\n  const input = useRef<HTMLInputElement>(null);\n  const exited = useRef(false);\n  // Kept in a ref so a caller passing a fresh closure does not restart the timeline: an inline arrow\n  // has a new identity on every render, and a dependency on it would tear a finished exit down and\n  // start it again from the top.\n  const exitedCallback = useRef(onExited);\n  useEffect(() => {\n    exitedCallback.current = onExited;\n  });\n\n  const [cancelWidth, setCancelWidth] = useState(0);\n  useEffect(() => {\n    const node = cancel.current;\n    if (!node) return;\n    const sync = () => setCancelWidth(node.offsetWidth);\n    sync();\n    const observer = new ResizeObserver(sync);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [cancelLabel]);\n\n  const fieldTop = topInset + m.field.topFromInset;\n  const rowWidth = m.screen.width - m.field.inset * 2;\n  const pillWidth = cancelWidth > 0 ? rowWidth - cancelWidth - m.field.gap : rowWidth;\n  const rise = restingFieldTop - fieldTop;\n  const resultsTop = fieldTop + m.field.height + m.resultsTopPadding;\n\n  /** Escape leaves search, wherever focus happens to be. */\n  useEffect(() => {\n    if (!open || !onCancel) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      onCancel();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, onCancel]);\n\n  /** Opening for real takes the caret; a scrubbed frame must not, or the harness moves focus. */\n  useEffect(() => {\n    if (!open || progress !== undefined) return;\n    input.current?.focus({ preventScroll: true });\n  }, [open, progress]);\n\n  /**\n   * One timeline over the scrim, the surface, the field row and the pill. Closing runs the same\n   * keyframes in reverse, so a seek lands on the same frame in either direction, and the direction is\n   * read off `open` during render rather than in an effect that would skip the exit's frames.\n   */\n  useEffect(() => {\n    if (open) exited.current = false;\n    const closing = !open;\n    const duration = closing ? m.timing.exit : m.timing.enter;\n    const finish = () => {\n      if (exited.current) return;\n      exited.current = true;\n      exitedCallback.current?.();\n    };\n    // Scrubbing is inspection, not a dismissal: a seeked exit poses the screen and reports nothing.\n    const reports = closing && progress === undefined;\n    if (reducedMotion()) {\n      if (!reports) return;\n      const frame = requestAnimationFrame(finish);\n      return () => cancelAnimationFrame(frame);\n    }\n    const tracks: Array<[HTMLElement | null, Keyframe[]]> = [\n      // Judgement: the dim leads and the opaque surface follows, so the list is visibly dimmed\n      // before it is replaced. Neither offset is measured.\n      [scrim.current, [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 0.5 }, { opacity: 1, offset: 1 }]],\n      [surface.current, [{ opacity: 0, offset: 0 }, { opacity: 0, offset: 0.15 }, { opacity: 1, offset: 1 }]],\n      [fieldRow.current, [{ transform: `translateY(${rise}px)` }, { transform: \"translateY(0px)\" }]],\n      [pill.current, [{ width: `${m.field.restingWidth}px` }, { width: `${pillWidth}px` }]],\n      [cancel.current, [{ opacity: 0, transform: `translateX(${m.field.gap}px)` }, { opacity: 1, transform: \"translateX(0px)\" }]],\n    ];\n    const animations = tracks\n      .filter((track): track is [HTMLElement, Keyframe[]] => track[0] !== null)\n      .map(([node, frames]) => node.animate(frames, { duration, easing: \"cubic-bezier(0.32, 0.72, 0, 1)\", fill: \"both\", direction: closing ? \"reverse\" : \"normal\" }));\n    if (progress !== undefined) {\n      for (const animation of animations) {\n        animation.pause();\n        animation.currentTime = clamp01(progress) * duration;\n      }\n      return () => { for (const animation of animations) animation.cancel(); };\n    }\n    if (!closing) return () => { for (const animation of animations) animation.cancel(); };\n    const first = animations[0];\n    first?.addEventListener(\"finish\", finish);\n    return () => first?.removeEventListener(\"finish\", finish);\n  }, [open, progress, rise, pillWidth, m.field.gap, m.field.restingWidth, m.timing.enter, m.timing.exit]);\n\n  const typed = query.trim().length > 0;\n  const shown = sections.map(section => ({ section, visible: section.results.slice(0, maxResults) })).filter(entry => entry.visible.length > 0);\n  const total = sections.reduce((count, section) => count + section.results.length, 0);\n\n  return (\n    <div data-slot=\"ios-search\" data-state={open ? \"open\" : \"closed\"} className={cn(\"absolute inset-0 isolate select-none overflow-hidden\", vars, className)}\n      style={{ fontFamily: font, ...style }} {...props}>\n      <div ref={scrim} aria-hidden=\"true\" data-slot=\"scrim\" className=\"absolute inset-0\" style={{ background: \"var(--ios-search-dim)\" }} />\n\n      {/* The opaque page and everything on it fade together, so the list never shows through a gap\n          between two rows on the way in. */}\n      <div ref={surface} data-slot=\"surface\" className=\"absolute inset-0\" style={{ background: \"var(--ios-search-bg)\" }}>\n      <div data-slot=\"results\" className=\"absolute overflow-y-auto\" style={{ left: 0, right: 0, top: resultsTop, bottom: 0 }}>\n        {!typed && emptyState}\n        {typed && total === 0 && (\n          <div data-slot=\"no-results\" role=\"status\" className=\"flex h-full flex-col items-center justify-center px-8 text-center\">\n            <p className=\"m-0\" style={{ fontSize: m.empty.titleFontSize, lineHeight: 1.2, letterSpacing: 0, color: \"var(--ios-search-label)\" }}>{iosSearchStrings.noResults}</p>\n            {noResultsDetail && <p className=\"m-0\" style={{ marginTop: 8, fontSize: m.empty.subtitleFontSize, lineHeight: 1.3, letterSpacing: 0, color: \"var(--ios-search-secondary)\" }}>{noResultsDetail}</p>}\n          </div>\n        )}\n        {typed && shown.map(({ section, visible }) => {\n          const title = section.title ?? iosSearchSectionTitles[section.kind];\n          const headingId = `${id}-${section.kind}`;\n          const seeAll = section.results.length > visible.length;\n          return (\n            <section key={section.kind} data-slot=\"section\" data-kind={section.kind} aria-labelledby={headingId} className=\"relative\">\n              <SectionHeader id={headingId} title={title} kind={section.kind} showSeeAll={seeAll} onSeeAll={onSeeAll} />\n              {section.kind === \"conversations\" && (\n                <ul role=\"list\" aria-labelledby={headingId} className=\"relative m-0 list-none p-0\" style={{ height: visible.length * m.row.height }}>\n                  {visible.map((result, index) => (\n                    <li key={result.id} role=\"listitem\" className=\"absolute left-0 right-0 top-0\" style={{ height: m.row.height, transform: `translateY(${index * m.row.height}px)` }}>\n                      <ConversationRow result={result as IosSearchConversationResult} query={query} onSelect={() => onSelect?.(result, section.kind)} />\n                    </li>\n                  ))}\n                </ul>\n              )}\n              {section.kind === \"messages\" && (\n                <ul role=\"list\" aria-labelledby={headingId} className=\"relative m-0 list-none p-0\" style={{ height: visible.length * m.row.height }}>\n                  {visible.map((result, index) => (\n                    <li key={result.id} role=\"listitem\" className=\"absolute left-0 right-0 top-0\" style={{ height: m.row.height, transform: `translateY(${index * m.row.height}px)` }}>\n                      <MessageRow result={result as IosSearchMessageResult} query={query} onSelect={() => onSelect?.(result, section.kind)} />\n                    </li>\n                  ))}\n                </ul>\n              )}\n              {section.kind === \"photos\" && (\n                <ul role=\"list\" aria-labelledby={headingId} className=\"m-0 flex list-none overflow-x-auto p-0\" style={{ gap: m.photos.gap, paddingLeft: m.header.margin, paddingRight: m.header.margin, paddingBottom: m.sectionGap }}>\n                  {visible.map(result => (\n                    <li key={result.id} role=\"listitem\" className=\"shrink-0\">\n                      <PhotoTile result={result as IosSearchPhotoResult} onSelect={() => onSelect?.(result, section.kind)} />\n                    </li>\n                  ))}\n                </ul>\n              )}\n              {section.kind === \"links\" && (\n                <ul role=\"list\" aria-labelledby={headingId} className=\"m-0 flex list-none overflow-x-auto p-0\" style={{ gap: m.links.gap, paddingLeft: m.header.margin, paddingRight: m.header.margin, paddingBottom: m.sectionGap }}>\n                  {visible.map(result => (\n                    <li key={result.id} role=\"listitem\" className=\"shrink-0\">\n                      <LinkCard result={result as IosSearchLinkResult} query={query} onSelect={() => onSelect?.(result, section.kind)} />\n                    </li>\n                  ))}\n                </ul>\n              )}\n            </section>\n          );\n        })}\n      </div>\n      </div>\n\n      <div ref={fieldRow} data-slot=\"field-row\" role=\"search\" className=\"absolute z-20\" style={{ left: m.field.inset, right: m.field.inset, top: fieldTop, height: m.field.height }}>\n        <div ref={pill} data-slot=\"field\" className=\"absolute left-0 top-0 h-full rounded-full\" style={{ width: pillWidth }}>\n          <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]\" style={{ boxShadow: \"var(--ios-search-shadow)\" }} />\n          <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit]\" style={{ background: \"var(--ios-search-glass)\", boxShadow: \"var(--ios-search-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n          <svg aria-hidden=\"true\" className=\"absolute\" style={{ left: m.field.glyphLeft, top: m.field.glyphTop }} width=\"18.3333\" height=\"18.6667\" viewBox=\"-1 -1 18.3333 18.6667\" fill=\"none\" stroke=\"var(--ios-search-field)\" strokeLinecap=\"round\">\n            <circle cx=\"6.745\" cy=\"7.219\" r=\"5.883\" strokeWidth=\"1.725\" />\n            <path d=\"M11.4 12.01 15.17 15.78\" strokeWidth=\"2.467\" />\n          </svg>\n          {!typed && (\n            <span aria-hidden=\"true\" data-slot=\"placeholder\" className=\"absolute\" style={{ left: m.field.textLeft, top: m.field.textTop, transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 500, letterSpacing: 0, color: \"var(--ios-search-field)\" }}>\n              {placeholder}\n            </span>\n          )}\n          <input\n            ref={input}\n            type=\"text\"\n            role=\"searchbox\"\n            autoComplete=\"off\"\n            autoCorrect=\"off\"\n            spellCheck={false}\n            aria-label={placeholder}\n            value={query}\n            onChange={event => setQuery(event.target.value)}\n            className=\"absolute border-0 bg-transparent p-0 outline-none select-text\"\n            style={{\n              left: m.field.textLeft,\n              right: m.field.micRight + m.field.micWidth + 8,\n              // The measured placeholder's line box starts at 15.8333; a 20pt line box centres on it\n              // from 14.3333. Weight 500 is the placeholder's measured weight, reused for typed text,\n              // which no capture holds.\n              top: 14.3333,\n              height: 20,\n              fontFamily: font,\n              fontSize: 17,\n              lineHeight: \"20px\",\n              fontWeight: 500,\n              letterSpacing: 0,\n              color: \"var(--ios-search-label)\",\n              caretColor: \"var(--ios-search-tint)\",\n            }}\n          />\n          {typed ? (\n            <button type=\"button\" data-slot=\"clear\" aria-label=\"Clear search\" onClick={() => { setQuery(\"\"); input.current?.focus(); }}\n              className=\"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\"\n              style={{ right: m.field.micRight + (m.field.micWidth - m.field.clearSize) / 2, top: (m.field.height - m.field.clearSize) / 2, width: m.field.clearSize, height: m.field.clearSize, background: \"var(--ios-search-clear)\" }}>\n              <svg aria-hidden=\"true\" width=\"9\" height=\"9\" viewBox=\"0 0 9 9\" fill=\"none\" stroke=\"var(--ios-search-glass)\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                <path d=\"M1 1 8 8M8 1 1 8\" />\n              </svg>\n            </button>\n          ) : (\n            <span aria-hidden=\"true\" className=\"absolute\" style={{ right: m.field.micRight, top: m.field.micTop, color: \"var(--ios-search-field)\", display: \"flex\" }}>\n              <IosMicIcon width={m.field.micWidth} height={m.field.micHeight} />\n            </span>\n          )}\n        </div>\n        <button ref={cancel} type=\"button\" data-slot=\"cancel\" onClick={onCancel}\n          className=\"absolute right-0 top-0 h-full whitespace-nowrap rounded focus-visible:outline-2 focus-visible:outline-blue-500\"\n          style={{ fontSize: 17, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-search-tint)\" }}>\n          {cancelLabel}\n        </button>\n      </div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-search.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-swipe-times",
      "title": "Swipe for times",
      "description": "The drag that shifts the conversation left to reveal per-message times.",
      "files": [
        {
          "path": "registry/imessage/ios-swipe-times.tsx",
          "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * \"Swipe left to reveal times\", measured from `references/ios/captures/swipe-timestamps-light.png`\n * (402×874 @3x).\n *\n * - Every bubble shifts left by exactly 58 (the last one's body right edge goes 386 → 328).\n * - The per-message time is 11pt secondary (#8a8a8e light / #8d8d93 dark), right-aligned with its\n *   ink ending at x 385, and its ink is centred on the bubble body's centre (measured on all eight\n *   bubbles: 235.17 / 295.5 / 339.83 / 384.17 / 464 / 572.5 / 626.5 / 686.67).\n * - iOS formats the time with a narrow no-break space before AM/PM.\n *\n * The drag rubber-bands past full reveal and past zero, and releasing runs a ~300 ms spring\n * (ζ 0.9, ω 20 rad/s). `prefers-reduced-motion` snaps instead. Keyboard users get ArrowLeft /\n * ArrowRight (and Enter/Space to toggle) on the element the hook's `handlers` are spread onto.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\n/** Measured reveal geometry, in points. `timeInset` is from the screen's trailing edge. */\n/** `baselineNudge` corrects Chrome's baseline, which sits two thirds of a point below native's. */\nexport const swipeTimesMetrics = { distance: 58, timeInset: 16, fontSize: 11, lineHeight: 13, letterSpacing: 0.1, baselineNudge: -0.6667 } as const;\n\nexport type SwipeToRevealOptions = {\n  /** How far the bubbles travel at full reveal. */\n  distance?: number;\n  /** Controlled 0..1. When set, dragging is disabled and the value is used verbatim. */\n  progress?: number;\n  /** Fraction of `distance` past which a release settles open. */\n  threshold?: number;\n  onChange?: (open: boolean) => void;\n};\n\nexport type SwipeToRevealTimes = {\n  /** 0 = hidden, 1 = fully revealed. May exceed 1 slightly while rubber-banding. */\n  progress: number;\n  /** Signed px shift for the bubble column (negative moves left). */\n  offset: number;\n  open: boolean;\n  setOpen: (next: boolean) => void;\n  handlers: {\n    onPointerDown: (event: PointerEvent<HTMLElement>) => void;\n    onPointerMove: (event: PointerEvent<HTMLElement>) => void;\n    onPointerUp: (event: PointerEvent<HTMLElement>) => void;\n    onPointerCancel: (event: PointerEvent<HTMLElement>) => void;\n    onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;\n    tabIndex: number;\n    role: string;\n    \"aria-label\": string;\n    style: { touchAction: \"pan-y\" };\n  };\n};\n\n/** Rubber band: full travel inside [0, 1], a quarter of it outside. */\nfunction band(raw: number) {\n  if (raw < 0) return raw * 0.25;\n  if (raw > 1) return 1 + (raw - 1) * 0.25;\n  return raw;\n}\n\nexport function useSwipeToRevealTimes({ distance = swipeTimesMetrics.distance, progress: controlled, threshold = 0.4, onChange }: SwipeToRevealOptions = {}): SwipeToRevealTimes {\n  const [value, setValue] = useState(0);\n  const current = useRef(0);\n  const drag = useRef<{ id: number; startX: number; base: number; lastX: number; time: number; velocity: number } | null>(null);\n  const spring = useRef({ raf: 0, target: 0, velocity: 0, last: 0 });\n\n  const write = useCallback((next: number) => { current.current = next; setValue(next); }, []);\n  const stop = useCallback(() => { cancelAnimationFrame(spring.current.raf); spring.current.raf = 0; }, []);\n  useEffect(() => stop, [stop]);\n\n  const settle = useCallback((target: number, velocity: number) => {\n    stop();\n    if (typeof matchMedia === \"function\" && matchMedia(\"(prefers-reduced-motion: reduce)\").matches) { write(target); return; }\n    const s = spring.current;\n    s.target = target; s.velocity = velocity; s.last = performance.now();\n    const step = (now: number) => {\n      const dt = Math.min(0.032, (now - s.last) / 1000);\n      s.last = now;\n      // ζ 0.9, ω 20 rad/s: about 300 ms to rest with no visible overshoot.\n      s.velocity += (400 * (s.target - current.current) - 36 * s.velocity) * dt;\n      const next = current.current + s.velocity * dt;\n      if (Math.abs(s.target - next) < 0.0008 && Math.abs(s.velocity) < 0.01) { write(s.target); s.raf = 0; return; }\n      write(next);\n      s.raf = requestAnimationFrame(step);\n    };\n    s.raf = requestAnimationFrame(step);\n  }, [stop, write]);\n\n  const setOpen = useCallback((next: boolean) => { settle(next ? 1 : 0, 0); onChange?.(next); }, [settle, onChange]);\n\n  const locked = controlled !== undefined;\n  const progress = locked ? controlled : value;\n\n  const handlers: SwipeToRevealTimes[\"handlers\"] = {\n    tabIndex: 0,\n    role: \"group\",\n    \"aria-label\": \"Conversation. Swipe left, or press the left arrow key, to show message times.\",\n    style: { touchAction: \"pan-y\" },\n    onPointerDown(event) {\n      if (locked || event.button !== 0) return;\n      stop();\n      drag.current = { id: event.pointerId, startX: event.clientX, base: current.current, lastX: event.clientX, time: performance.now(), velocity: 0 };\n      event.currentTarget.setPointerCapture?.(event.pointerId);\n    },\n    onPointerMove(event) {\n      const d = drag.current;\n      if (!d || d.id !== event.pointerId) return;\n      const now = performance.now();\n      const dt = Math.max(1, now - d.time);\n      d.velocity = ((d.lastX - event.clientX) / distance / dt) * 1000;\n      d.lastX = event.clientX; d.time = now;\n      write(band(d.base + (d.startX - event.clientX) / distance));\n    },\n    onPointerUp(event) {\n      const d = drag.current;\n      if (!d || d.id !== event.pointerId) return;\n      drag.current = null;\n      event.currentTarget.releasePointerCapture?.(event.pointerId);\n      const next = current.current + d.velocity * 0.12 > threshold;\n      settle(next ? 1 : 0, d.velocity);\n      onChange?.(next);\n    },\n    onPointerCancel(event) {\n      const d = drag.current;\n      if (!d || d.id !== event.pointerId) return;\n      drag.current = null;\n      settle(0, 0);\n      onChange?.(false);\n    },\n    onKeyDown(event) {\n      if (locked) return;\n      if (event.key === \"ArrowLeft\") { event.preventDefault(); setOpen(true); }\n      else if (event.key === \"ArrowRight\" || event.key === \"Escape\") { event.preventDefault(); setOpen(false); }\n      else if (event.key === \"Enter\" || event.key === \" \") { event.preventDefault(); setOpen(progress < 0.5); }\n    },\n  };\n\n  return { progress, offset: -progress * distance, open: progress > 0.5, setOpen, handlers };\n}\n\nexport type SwipeTimesProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Already formatted, e.g. \"1:25 AM\" with a narrow no-break space. */\n  time: ReactNode;\n  /** 0..1 from `useSwipeToRevealTimes`. */\n  progress?: number;\n  distance?: number;\n  /** Ink inset from the container's trailing edge. */\n  timeInset?: number;\n  children: ReactNode;\n};\n\n/**\n * One message row of the reveal: shifts its message left and slides the time in from the trailing\n * edge, vertically centred on the bubble body. Clip the scrolling container: at rest the time column\n * sits one full `distance` off-screen.\n */\nexport function SwipeTimes({ time, progress = 0, distance = swipeTimesMetrics.distance, timeInset = swipeTimesMetrics.timeInset, className, style, children, ...props }: SwipeTimesProps) {\n  const t = Math.max(0, progress);\n  const row = useRef<HTMLDivElement>(null);\n  // Native centres the time on the bubble *body*, not on the row: a reaction balloon or a status\n  // line grows the row without moving the body, and the time stays put.\n  useLayoutEffect(() => {\n    const root = row.current;\n    if (!root) return;\n    const measure = () => {\n      const body = root.querySelector<HTMLElement>('[data-slot=\"bubble\"], [data-slot=\"emoji\"]');\n      const box = root.getBoundingClientRect();\n      const centre = body ? body.getBoundingClientRect().top + body.getBoundingClientRect().height / 2 - box.top : box.height / 2;\n      root.style.setProperty(\"--swipe-centre\", `${centre.toFixed(2)}px`);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    document.fonts?.ready.then(measure).catch(() => {});\n    return () => observer.disconnect();\n  }, [children, time]);\n  return (\n    <div ref={row} data-slot=\"swipe-times\" data-progress={t.toFixed(3)}\n      className={cn(\"relative w-full [--ios-sw-secondary:#8a8a8e] dark:[--ios-sw-secondary:#8d8d93]\", className)}\n      style={{ fontFamily: font, ...style }} {...props}>\n      <div data-slot=\"swipe-content\" style={{ transform: `translateX(${(-t * distance).toFixed(2)}px)`, willChange: \"transform\" }}>{children}</div>\n      <span data-slot=\"time\" aria-hidden={t < 0.5 || undefined} className=\"pointer-events-none absolute whitespace-nowrap text-right\"\n        style={{\n          // The column travels with the bubbles: it starts one full `distance` off the trailing edge.\n          right: timeInset, top: \"var(--swipe-centre, 50%)\",\n          transform: `translate(${((1 - Math.min(1, t)) * distance).toFixed(2)}px, calc(-50% + ${swipeTimesMetrics.baselineNudge}px))`,\n          fontSize: swipeTimesMetrics.fontSize, lineHeight: `${swipeTimesMetrics.lineHeight}px`, letterSpacing: swipeTimesMetrics.letterSpacing,\n          color: \"var(--ios-sw-secondary)\", opacity: Math.max(0, Math.min(1, t * 4)),\n        }}>\n        {time}\n      </span>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-swipe-times.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "ios-notices",
      "title": "Conversation notices",
      "description": "The unknown-sender notice, the failed-send badge, and the Not Delivered label.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json"
      ],
      "files": [
        {
          "path": "registry/imessage/ios-notices.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\n\n/**\n * In-conversation notices.\n *\n * **Unknown sender** — measured from `references/ios/captures/incoming-light.png` / `-dark.png`\n * (402×874 @3x): centred secondary text whose ink runs 721.33–731.67 on line 1 and 734.67–745 on\n * line 2 (ascender 8.33, x-height 6.0 → 11pt, 13.33 pitch), colour #8a8a8e / #8d8d93. Line 1's ink\n * spans x 22.67–379 and line 2 (\"be spam.\") x 177–224, so the paragraph is centred on x 201 and\n * wraps inside the 370pt content width. The last bubble body ends at 702.67, 16 above the text box.\n * The \"Report Spam\" pill is 91 × 22.33 centred on (200.83, 766.83) — a capsule filled with the\n * incoming gray (#e9e9eb / #262629) carrying a 69pt-wide #0088ff / #0091ff label.\n *\n * **Not delivered** — measured from `references/macos/captures/attachment-not-delivered-dark-2x.png`\n * (960×640 @2x): a Ø17 red ring badge (#eb534e) centred 9.5 past the bubble's trailing edge, on the\n * body's vertical centre, plus \"Not Delivered\" in the same 10pt semibold slot \"Delivered\" uses,\n * right-aligned 16.5 inside the badge's trailing edge. iOS scales that to the 11pt status world.\n */\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\n\nconst noticeVars =\n  \"[--ios-nt-secondary:#8a8a8e] [--ios-nt-pill:#e9e9eb] [--ios-nt-link:#0088ff] \" +\n  \"dark:[--ios-nt-secondary:#8d8d93] dark:[--ios-nt-pill:#262629] dark:[--ios-nt-link:#0091ff]\";\n\n/** Measured notice geometry, in points. */\nexport const unknownSenderMetrics = {\n  fontSize: 11, lineHeight: 13.3333, contentWidth: 370, topGap: 16,\n  pillWidth: 91, pillHeight: 22.3333, pillGap: 10.3333, pillFontSize: 11, letterSpacing: 0.1,\n  /**\n   * Centring a 91 wide pill in the 370 wide content column puts its left edge on 155.5, half a\n   * point off the device grid at 3x. Native rounds that painted edge down to the pixel below and\n   * fills x 466–738 of the capture (155.33–246.33); Blink rounds the whole background box up to\n   * 156 instead. Snapping happens before the transform, so two thirds of a point here lands the\n   * composited capsule back on the capture's edges. It is a rasterising correction for the\n   * measured 370 column, not a layout offset.\n   */\n  pillNudgeX: -0.6667,\n} as const;\n\nexport type UnknownSenderNoticeProps = Omit<ComponentProps<\"div\">, \"children\" | \"title\"> & {\n  /** The grey paragraph. Defaults to the exact string iOS shows. */\n  message?: ReactNode;\n  /** Omit to hide the button. */\n  action?: string;\n  onAction?: () => void;\n  /** Space above the paragraph, from the previous bubble's body bottom. */\n  topGap?: number;\n};\n\nexport function UnknownSenderNotice({\n  message = \"If you did not expect this message from an unknown sender, it may be spam.\",\n  action = \"Report Spam\", onAction, topGap = unknownSenderMetrics.topGap, className, style, ...props\n}: UnknownSenderNoticeProps) {\n  const m = unknownSenderMetrics;\n  return (\n    <div data-slot=\"unknown-sender-notice\" role=\"note\"\n      className={cn(\"flex w-full select-none flex-col items-center\", noticeVars, className)}\n      style={{ fontFamily: font, paddingTop: topGap, ...style }} {...props}>\n      <p data-slot=\"notice-text\" className=\"m-0 text-center\"\n        style={{ maxWidth: m.contentWidth, fontSize: m.fontSize, lineHeight: `${m.lineHeight}px`, letterSpacing: m.letterSpacing, color: \"var(--ios-nt-secondary)\" }}>\n        {message}\n      </p>\n      {action && (\n        <button type=\"button\" data-slot=\"notice-action\" onClick={onAction}\n          className=\"relative flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n          style={{\n            marginTop: m.pillGap, width: m.pillWidth, height: m.pillHeight, borderRadius: m.pillHeight / 2,\n            color: \"var(--ios-nt-link)\",\n            fontSize: m.pillFontSize, lineHeight: `${m.pillHeight}px`, fontWeight: 600, letterSpacing: 0,\n          }}>\n          {/* The fill carries `pillNudgeX`; the label must not, since its own ink already lands on the capture. */}\n          <span aria-hidden=\"true\" data-slot=\"notice-action-fill\" className=\"absolute inset-0\"\n            style={{ borderRadius: m.pillHeight / 2, background: \"var(--ios-nt-pill)\", transform: `translateX(${m.pillNudgeX}px)` }} />\n          {/* Chrome's baseline sits two thirds of a point below native's in this line box. */}\n          <span className=\"relative\" style={{ transform: \"translateY(-0.6667px)\" }}>{action}</span>\n        </button>\n      )}\n    </div>\n  );\n}\n\n/** Status typography per platform, matching `bubbleMetrics` so the label lands where \"Delivered\" would. */\nconst statusStyle: Record<Platform, { fontSize: number; lineHeight: number; letterSpacing: number; gap: number; inset: number; badge: number; badgeGap: number }> = {\n  ios: { fontSize: 11, lineHeight: 13, letterSpacing: -0.25, gap: 4.65, inset: 19.3, badge: 18, badgeGap: 8 },\n  macos: { fontSize: 10, lineHeight: 12, letterSpacing: -0.45, gap: 4, inset: 16.5, badge: 17, badgeGap: 9.5 },\n};\n\nexport type FailedSendBadgeProps = Omit<ComponentProps<\"button\">, \"children\"> & {\n  size?: number;\n  platform?: Platform;\n  label?: string;\n};\n\n/** The red (!) ring that sits beside a message that failed to send. */\nexport function FailedSendBadge({ size, platform: platformProp, label = \"Message not delivered. Try again.\", className, style, ...props }: FailedSendBadgeProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const d = size ?? statusStyle[platform].badge;\n  return (\n    <button type=\"button\" data-slot=\"failed-send-badge\" aria-label={label} title={label}\n      className={cn(\"inline-flex shrink-0 items-center justify-center rounded-full align-middle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#eb534e]\", className)}\n      style={{ width: d, height: d, ...style }} {...props}>\n      <svg aria-hidden=\"true\" width={d} height={d} viewBox=\"0 0 17 17\" fill=\"none\" stroke=\"#eb534e\" strokeWidth=\"1.15\" strokeLinecap=\"round\">\n        <circle cx=\"8.5\" cy=\"8.5\" r=\"7.9\" />\n        <path d=\"M8.5 4.3v5.1\" />\n        <path d=\"M8.5 12.35v0.05\" strokeWidth=\"1.5\" />\n      </svg>\n    </button>\n  );\n}\n\nexport type NotDeliveredProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  label?: string;\n  platform?: Platform;\n  /** Trailing inset of the label's ink. Defaults to the platform's \"Delivered\" inset. */\n  inset?: number;\n};\n\n/**\n * The red status line that replaces \"Delivered\". Drop it in the same slot: it uses the platform's\n * status typography so the ink lands on the measured baseline.\n */\nexport function NotDelivered({ label = \"Not Delivered\", platform: platformProp, inset, className, style, ...props }: NotDeliveredProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const s = statusStyle[platform];\n  return (\n    <div data-slot=\"not-delivered\" className={cn(\"select-none text-right\", className)}\n      style={{\n        fontFamily: font, fontSize: s.fontSize, lineHeight: `${s.lineHeight}px`, fontWeight: 600,\n        letterSpacing: s.letterSpacing, marginTop: s.gap, paddingInlineEnd: inset ?? s.inset, color: \"#eb534e\", ...style,\n      }} {...props}>\n      {label}\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/ios-notices.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "system-message",
      "title": "System message",
      "description": "The centred grey lines that are not bubbles: group renames, joins and leaves, the group photo, a missed call, a reaction summary, and the unknown-sender sentence.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/ios-notices.json",
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tapback.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/system-message.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { bubbleMetrics, fontStack } from \"@/components/imessage/tokens\";\nimport { unknownSenderMetrics } from \"@/components/imessage/ios-notices\";\nimport type { TapbackType } from \"@/components/imessage/tapback\";\n\n/**\n * The centred grey lines a transcript carries that are not bubbles: group renames, joins and\n * leaves, the group photo, a missed call, a reaction summary, and the unknown-sender notice.\n *\n * ## What is measured, and what is not\n *\n * **The type, colour, centring and the space above are measured**, from the unknown-sender notice in\n * `references/ios/captures/incoming-light.png` (402x874 @3x). Re-read for this component, scanning\n * every row for non-white ink across the full width:\n *\n * - line 1 (\"If you did not expect this message from an unknown sender, it may\") has ink on device\n *   rows 2163-2195, i.e. pt y 721.0-731.67, with the x-height band running 723.67-729.33 and the\n *   baseline at 729.5. Line 2 (\"be spam.\") runs 2204-2234 = pt 734.67-745.0.\n * - line pitch is therefore 734.67 - 721.33 = **13.3333**, and x-height is 729.5 - 723.67 = **5.83**,\n *   which at SF Pro Text's 0.53 em x-height is 11.0 pt exactly (12 pt would read 6.36). The ascender\n *   run 729.5 - 721.0 = 8.5 agrees at SF's 0.75 em. So the family is **11 pt, not the 12 pt SPEC.md's\n *   prose line says**; `unknownSenderMetrics` in `ios-notices.tsx` measured the same 11.\n * - line 1 spans x 22.33-379.0 and line 2 x 176.67-224.0, so both centre on x 200.5 in a 402 wide\n *   screen: the paragraph is **centred**, and it wraps inside the 370 column that the measured 16 pt\n *   edge inset leaves (402 - 2x16 = 370).\n * - the last bubble body above it ends at pt y 702.67 and the line box starts at 718.67, so the\n *   **space above is 16**.\n * - colour #8a8a8e light / #8d8d93 dark (SPEC.md \"Secondary label (Delivered, date, spam notice)\"),\n *   which is the palette's `--im-secondary`.\n *\n * **The space below is not measured.** Nothing sits under the notice in that capture except its\n * \"Report Spam\" pill, 10.33 below. This file mirrors the measured 16 above as 16 below; treat it as\n * judgement.\n *\n * **The bold run is not measured.** No capture holds a system message with a name in it. Weight 600\n * is reused, not guessed: it is the measured weight of the other ink in this same 11 pt secondary\n * slot, the \"Delivered\" status label (SPEC.md, iOS bubble geometry) and the \"Report Spam\" label.\n *\n * **macOS is sized from the macOS secondary label, not measured for this surface.** It takes\n * `bubbleMetrics.macos.statusFontSize` / `statusLineHeight` / `statusLetterSpacing`, the measured\n * 9 pt on an 11 pt line box that \"Delivered\" and the macOS date header both use, and the measured\n * secondary colour #808080 / #9a9a9a. Its 11.5 gaps are the measured between-cluster gap, reused the\n * way `dateSeparatorMetrics.macos.midGapAbove` reuses it, because a system line is its own cluster.\n * Note that on iOS the notice's 16 is 1.57x that platform's 10.2 cluster gap, so if macOS follows the\n * same ratio the true number is nearer 18. Nothing in `references/` settles it.\n *\n * ## Which strings this supports\n *\n * Only `unknownSender` is a captured string. Every other sentence below follows Apple's documented\n * behaviour for group conversations and tapback summaries, and is **wording, not a measurement**:\n *\n * | event | renders (bold shown in brackets) |\n * |---|---|\n * | `unknownSender` | If you did not expect this message from an unknown sender, it may be spam. |\n * | `conversationNamed` | You named the conversation \"Design Crew\". / [Alex Morgan] named the conversation \"Design Crew\". |\n * | `participantAdded` | You added [Sam Rivera] to the conversation. |\n * | `participantRemoved` | You removed [Sam Rivera] from the conversation. |\n * | `participantLeft` | [Alex Morgan] left the conversation. / You left the conversation. |\n * | `groupPhotoChanged` | You changed the group photo. |\n * | `missedCall` | Missed Call / Missed Video Call / Missed FaceTime |\n * | `reaction` | You loved \"Tuesday morning at 9?\" / [Alex Morgan] laughed at \"…\" |\n *\n * A participant's name renders bold; \"You\" never does, which is the rule Messages follows for the\n * first person. `actor` left out means you. The conversation name and a quoted message stay in the\n * regular weight inside curly quotes, matching how SPEC.md records the macOS sidebar preview\n * (\"You loved “…”\"). \"Missed FaceTime\" is the same wording `facetime-card.tsx` already uses.\n *\n * The unknown-sender sentence lives here as plain text so the family is complete. The version with\n * the measured \"Report Spam\" pill under it is `UnknownSenderNotice` in `ios-notices.tsx`; use that\n * one when the button belongs on screen.\n *\n * Nothing here animates, so there is no reduced-motion or seek behaviour to respect.\n */\n\nexport type SystemMessageMetrics = {\n  fontSize: number;\n  lineHeight: number;\n  letterSpacing: number;\n  weight: number;\n  /** Weight for a participant's name inside the sentence. */\n  emphasisWeight: number;\n  /** Space from the row above to the line box, and from the line box to the row below. */\n  gapAbove: number;\n  gapBelow: number;\n  /** Inset from the pane edge, which is what wraps the sentence. */\n  inset: number;\n};\n\nexport const systemMessageMetrics: Record<Platform, SystemMessageMetrics> = {\n  ios: {\n    // Measured: the unknown-sender notice, via `unknownSenderMetrics`.\n    fontSize: unknownSenderMetrics.fontSize,\n    lineHeight: unknownSenderMetrics.lineHeight,\n    letterSpacing: unknownSenderMetrics.letterSpacing,\n    weight: 400,\n    // Reused: the measured semibold of the 11pt \"Delivered\" label and the \"Report Spam\" pill.\n    emphasisWeight: 600,\n    gapAbove: unknownSenderMetrics.topGap,\n    // Judgement: mirrors the measured 16 above. Nothing sits below the notice in the capture.\n    gapBelow: unknownSenderMetrics.topGap,\n    // Measured: 402 - 2x16 is the 370 column the notice wraps inside.\n    inset: bubbleMetrics.ios.edgeInset,\n  },\n  macos: {\n    // Reused: the measured macOS secondary label, 9pt on an 11pt line box with no tracking.\n    fontSize: bubbleMetrics.macos.statusFontSize,\n    lineHeight: bubbleMetrics.macos.statusLineHeight,\n    letterSpacing: bubbleMetrics.macos.statusLetterSpacing,\n    weight: 400,\n    emphasisWeight: 600,\n    // Reused: the measured between-cluster gap. A system line is its own cluster.\n    gapAbove: bubbleMetrics.macos.gapBetweenGroups,\n    gapBelow: bubbleMetrics.macos.gapBetweenGroups,\n    inset: bubbleMetrics.macos.edgeInset,\n  },\n};\n\n/**\n * Palette first, then the measured value for that platform and theme, so a bare\n * `<SystemMessage>` outside an app shell still paints the right grey.\n */\nconst secondaryVars: Record<Platform, string> = {\n  ios: \"[--im-sys-label:var(--im-secondary,#8a8a8e)] dark:[--im-sys-label:var(--im-secondary,#8d8d93)]\",\n  macos: \"[--im-sys-label:var(--im-secondary,#808080)] dark:[--im-sys-label:var(--im-secondary,#9a9a9a)]\",\n};\n\n/** The one captured string, kept verbatim. */\nexport const unknownSenderText =\n  \"If you did not expect this message from an unknown sender, it may be spam.\";\n\n/** Apple's past-tense tapback wording, keyed by the kit's own `TapbackType`. */\nexport const reactionVerbs: Record<TapbackType, string> = {\n  love: \"loved\",\n  like: \"liked\",\n  dislike: \"disliked\",\n  laugh: \"laughed at\",\n  emphasize: \"emphasized\",\n  question: \"questioned\",\n};\n\nexport type MissedCallKind = \"audio\" | \"video\" | \"facetime\";\n\n/**\n * One thing that happened to the conversation rather than in it. `actor` is the person who did it;\n * leave it out (or pass \"You\") for the first person, which never renders bold.\n */\nexport type SystemMessageEvent =\n  | { type: \"unknownSender\" }\n  | { type: \"conversationNamed\"; actor?: string; name: string }\n  | { type: \"participantAdded\"; actor?: string; participant: string }\n  | { type: \"participantRemoved\"; actor?: string; participant: string }\n  | { type: \"participantLeft\"; actor?: string }\n  | { type: \"groupPhotoChanged\"; actor?: string }\n  | { type: \"missedCall\"; kind?: MissedCallKind }\n  | { type: \"reaction\"; actor?: string; reaction: TapbackType; excerpt: string };\n\n/** A run of the sentence. `emphasis` runs carry a participant's name and render semibold. */\nexport type SystemMessageSegment = { text: string; emphasis?: boolean };\n\nconst quoted = (text: string) => `“${text}”`;\n\n/** \"You\" is the first person and stays in the regular weight; anyone else is a name, so it bolds. */\nfunction person(name: string | undefined): SystemMessageSegment {\n  const text = name ?? \"You\";\n  return text === \"You\" ? { text } : { text, emphasis: true };\n}\n\nconst missedCallText: Record<MissedCallKind, string> = {\n  audio: \"Missed Call\",\n  video: \"Missed Video Call\",\n  // The same wording `facetime-card.tsx` uses for a missed FaceTime.\n  facetime: \"Missed FaceTime\",\n};\n\n/** Turns an event into the runs of its sentence, so a name can render bold inside it. */\nexport function formatSystemMessage(event: SystemMessageEvent): SystemMessageSegment[] {\n  switch (event.type) {\n    case \"unknownSender\":\n      return [{ text: unknownSenderText }];\n    case \"conversationNamed\":\n      return [person(event.actor), { text: ` named the conversation ${quoted(event.name)}.` }];\n    case \"participantAdded\":\n      return [person(event.actor), { text: \" added \" }, person(event.participant), { text: \" to the conversation.\" }];\n    case \"participantRemoved\":\n      return [person(event.actor), { text: \" removed \" }, person(event.participant), { text: \" from the conversation.\" }];\n    case \"participantLeft\":\n      return [person(event.actor), { text: \" left the conversation.\" }];\n    case \"groupPhotoChanged\":\n      return [person(event.actor), { text: \" changed the group photo.\" }];\n    case \"missedCall\":\n      return [{ text: missedCallText[event.kind ?? \"audio\"] }];\n    case \"reaction\":\n      return [person(event.actor), { text: ` ${reactionVerbs[event.reaction]} ${quoted(event.excerpt)}` }];\n  }\n}\n\n/** The same sentence as one string, for a sidebar preview, an aria label, or a test. */\nexport function systemMessageText(event: SystemMessageEvent): string {\n  return formatSystemMessage(event).map(segment => segment.text).join(\"\");\n}\n\nexport type SystemMessageProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** The event to describe. Omit and pass `children` to render your own sentence in this style. */\n  event?: SystemMessageEvent;\n  platform?: Platform;\n  /** Override the platform's spacing, e.g. to close it up against a date header. */\n  gapAbove?: number;\n  gapBelow?: number;\n  /** Cap the wrapped column. Left open, the platform's edge inset does the wrapping. */\n  maxWidth?: number;\n  children?: ReactNode;\n};\n\nexport function SystemMessage({\n  event, platform: platformProp, gapAbove, gapBelow, maxWidth, children, className, style, ...props\n}: SystemMessageProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const m = systemMessageMetrics[platform];\n  const segments = event ? formatSystemMessage(event) : undefined;\n  return (\n    <div\n      data-slot=\"system-message\"\n      data-platform={platform}\n      data-kind={event?.type}\n      role=\"note\"\n      className={cn(\"flex w-full select-none flex-col items-center\", secondaryVars[platform], className)}\n      style={{\n        fontFamily: fontStack,\n        // The inset is what wraps the sentence, so the padding has to come out of the full width\n        // even where the host has no global reset. Without this the column is 402 wide inside a\n        // 402 frame and the measured 370 wrap turns into an overflow.\n        boxSizing: \"border-box\",\n        paddingTop: gapAbove ?? m.gapAbove,\n        paddingBottom: gapBelow ?? m.gapBelow,\n        paddingInline: m.inset,\n        ...style,\n      }}\n      {...props}\n    >\n      <p\n        data-slot=\"system-message-text\"\n        className=\"m-0 text-center\"\n        style={{\n          maxWidth,\n          fontSize: m.fontSize,\n          lineHeight: `${m.lineHeight}px`,\n          fontWeight: m.weight,\n          letterSpacing: m.letterSpacing,\n          color: \"var(--im-sys-label)\",\n        }}\n      >\n        {segments\n          ? segments.map((segment, index) =>\n              segment.emphasis ? (\n                <strong key={`${index}-${segment.text}`} style={{ fontWeight: m.emphasisWeight }}>\n                  {segment.text}\n                </strong>\n              ) : (\n                <span key={`${index}-${segment.text}`}>{segment.text}</span>\n              ),\n            )\n          : children}\n      </p>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/system-message.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "group-avatar",
      "title": "Group avatar",
      "description": "The stacked faces a group conversation shows in the list, the nav bar, the details header and the macOS sidebar.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/avatar.json"
      ],
      "files": [
        {
          "path": "registry/imessage/group-avatar.tsx",
          "content": "\"use client\";\n\nimport type { ComponentProps, CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, type AvatarProps } from \"@/components/imessage/avatar\";\n\n/**\n * The two avatar surfaces a group conversation has and a one-to-one does not: the stacked group\n * photo, and the small per-sender photo beside an incoming bubble.\n *\n * Nothing in `references/` captures a group conversation. `grouped-light.png` is named for message\n * *grouping* (clusters and tails) and is a two-person thread, so every number below was read out of\n * the frameworks on this machine rather than off a screenshot. The probes ran as a Mac Catalyst\n * binary (`clang -target arm64-apple-ios26.0-macabi`) that dlopens ChatKit and asks\n * `CKUIBehaviorPhone` — the iPhone behaviour object — directly, so these are the phone values and\n * not the Mac ones.\n *\n * ## The stack\n *\n * A group photo is not a ChatKit drawing at all: `CKAvatarView` is a `CNAvatarView` subclass, and\n * ContactsUICore lays the faces out through\n * `+[CNUIAvatarLayoutManager layoutConfigurationsForType:withItemCount:]`. Type 2 is\n * `SnowglobeAvatarLayoutConfigurations`, the one Messages uses (type 3, its sibling, is named\n * `SnowglobeGroupTypingIndicatorAvatarLayoutConfigurations`, which only a Messages transcript has).\n * Each entry is a `CNUIAvatarLayoutItemConfiguration` carrying `x`, `y`, `size` and `baseSize`, and\n * `-itemFrameInContainingBounds:isRTL:` turns one into a frame:\n *\n * ```\n * s = bounds.width / baseSize          baseSize is 88 for every Snowglobe entry\n * d = size * s\n * frame = (bounds.midX + x*s - d/2, bounds.midY + y*s - d/2, d, d)\n * ```\n *\n * `snowglobeStack` below is that table, verbatim, read back at a bounds of 88 so the numbers are the\n * configurations' own. One participant fills the circle; two through six extend one another; seven\n * re-lays the whole set. `+[CNUIAvatarLayoutManager maxAvatarCountForType:]` answers 10, but the\n * table itself stops at **seven faces**: asking for 8, 9, 10 or 11 returns the same seven entries,\n * so an eighth participant is simply not drawn. `isRTL:YES` mirrors x about the centre (verified:\n * two faces at a bounds of 88 return x 9.5 / 47.5 in LTR and 30.5 / 12.5 in RTL) and nothing else.\n *\n * Stacking order comes from `-[CNUIAvatarLayoutItemConfiguration updateLayer:inBounds:atIndex:isRTL:layoutType:]`,\n * which ends in `setZPosition:` of **-index**: the first (largest) face is in front and each later,\n * smaller one sits behind it. There is no ring, cut-out or hairline between the faces — no selector\n * in ContactsUI or ContactsUICore draws one, they simply overlap.\n *\n * `-[CKUIBehaviorPhone groupAvatarViewSize]` is 60 x 60, which is the same Ø 60 the nav-bar avatar\n * measures in `conv3-light.png`, so that is the default size here. (`detailsAvatarPancakeView*` on\n * the same object — diameter 37, cut-out 41, overlap 13.5, widths 58 and 72 for two and three — is a\n * *different* stack, `CKDetailsAvatarPancakeView`, used by the details screen's participant header.\n * It is not this one.)\n *\n * ## The per-sender avatar\n *\n * `-[CKUIBehaviorPhone transcriptContactImageDiameter]` is **32**, and\n * `-[CKTranscriptAvatarSupplementaryView initWithFrame:]` builds its `CKAvatarView` at exactly\n * `(0, 0, 32, 32)`, no inset. `-[CKUIBehaviorPhone contactPhotoBalloonMargin]` is **7**: it is added\n * to the diameter in `-[CKTranscriptAbstractLabelCell layoutSubviewsForContents]` (and in the\n * balloon, message and stamp cells) as `transcriptContactImageDiameter + contactPhotoBalloonMargin`\n * = **39**, the whole leading gutter an incoming row gives up in a group.\n *\n * Placement is `+[CKChatItemLayoutUtilities avatarSupplementaryItemForChatItem:layoutEnvironment:]`:\n * a square supplementary item of that diameter, anchored with\n * `[NSCollectionLayoutAnchor layoutAnchorWithEdges:6 absoluteOffset:(-32, 0)]`. Edges 6 is\n * `NSDirectionalRectEdge.leading | .bottom`, so the avatar hangs off the balloon's leading edge and\n * its **bottom lines up with the balloon's bottom** — which is why a cluster carries one avatar, on\n * its last (tailed) bubble. `+[CKChatItemLayoutUtilities balloonEdgeSpacingForItemWithLayoutEnvironment:orientation:itemSize:supplementaryItems:]`\n * then spends that same offset: leading spacing = `marginInsets.left - tailSize.width + |offset|`.\n *\n * The group typing indicator uses a bigger face:\n * `-[CKUIBehaviorPhone transcriptGroupTypingContactImageDiameter]` is 44, taken by the same\n * `avatarSupplementaryItemForChatItem:` when the item is a typing indicator. `typing-indicator.tsx`\n * does not draw one yet; the constant is exported here so it can.\n */\n\n/** One face in the stack. Same shape `avatar.tsx` takes, plus the name used for the composed label. */\nexport type GroupParticipant = { name?: string; initials?: string; src?: string };\n\n/** `{ x, y, diameter }` in the Snowglobe table's own 88-point box, centre-relative. */\nexport type StackSlot = { x: number; y: number; diameter: number };\n\n/** The base box every Snowglobe configuration is expressed in (`CNUIAvatarLayoutItemConfiguration.baseSize`). */\nexport const snowglobeBaseSize = 88;\n\n/** Most faces the table will draw. Participants past this many are not shown. */\nexport const snowglobeMaxFaces = 7;\n\n/**\n * `SnowglobeAvatarLayoutConfigurations`, read back from\n * `+[CNUIAvatarLayoutManager layoutConfigurationsForType:2 withItemCount:n]` at a bounds of 88.\n * Index 0 is the front-most, largest face.\n */\nexport const snowglobeStack: readonly (readonly StackSlot[])[] = [\n  [{ x: 0, y: 0, diameter: 88 }],\n  [{ x: -10.5, y: -10.5, diameter: 48 }, { x: 17.5, y: 17.5, diameter: 28 }],\n  [{ x: -12.5, y: -12.5, diameter: 42 }, { x: 21.5, y: 7.5, diameter: 32 }, { x: -6, y: 23.5, diameter: 26 }],\n  [{ x: -12.5, y: -12.5, diameter: 42 }, { x: 21.5, y: 7.5, diameter: 32 }, { x: -6, y: 23.5, diameter: 26 }, { x: 20, y: -20.5, diameter: 20 }],\n  [{ x: -12.5, y: -12.5, diameter: 42 }, { x: 21.5, y: 7.5, diameter: 32 }, { x: -6, y: 23.5, diameter: 26 }, { x: 20, y: -20.5, diameter: 20 }, { x: -27.5, y: 14, diameter: 14 }],\n  [{ x: -12.5, y: -12.5, diameter: 42 }, { x: 21.5, y: 7.5, diameter: 32 }, { x: -6, y: 23.5, diameter: 26 }, { x: 20, y: -20.5, diameter: 20 }, { x: -27.5, y: 14, diameter: 14 }, { x: 7.5, y: -32.5, diameter: 10 }],\n  [{ x: -12.5, y: -12.5, diameter: 42 }, { x: 19.5, y: 11.5, diameter: 30 }, { x: 21.5, y: -16.5, diameter: 22 }, { x: -21, y: 19.5, diameter: 20 }, { x: 2, y: 30, diameter: 15 }, { x: -3, y: 15, diameter: 11 }, { x: 9, y: -31.5, diameter: 10 }],\n];\n\n/** The table row for `count` faces: one through seven, then seven for anything larger. */\nexport function snowglobeSlots(count: number): readonly StackSlot[] {\n  return snowglobeStack[Math.min(Math.max(count, 1), snowglobeMaxFaces) - 1];\n}\n\n/**\n * One slot's box inside a circle of `size`, in CSS px, mirrored for RTL exactly as\n * `-itemFrameInContainingBounds:isRTL:` does.\n */\nexport function snowglobeFrame(slot: StackSlot, size: number, rtl = false): { left: number; top: number; size: number } {\n  const scale = size / snowglobeBaseSize;\n  const face = slot.diameter * scale;\n  const x = (rtl ? -slot.x : slot.x) * scale;\n  return { left: size / 2 + x - face / 2, top: size / 2 + slot.y * scale - face / 2, size: face };\n}\n\n/** `-[CKUIBehaviorPhone groupAvatarViewSize]`, 60 x 60. */\nexport const groupAvatarSize = 60;\n\n/**\n * Metrics for the avatar beside an incoming bubble in a group. `gutter` is what an incoming row\n * gives up on its leading side: the two are always spent together in ChatKit.\n */\nexport const senderAvatarMetrics = {\n  /** `-[CKUIBehaviorPhone transcriptContactImageDiameter]`. */\n  diameter: 32,\n  /** `-[CKUIBehaviorPhone contactPhotoBalloonMargin]`, between the avatar and the balloon. */\n  balloonMargin: 7,\n  /** diameter + balloonMargin, the leading inset an incoming row takes in a group. */\n  gutter: 39,\n  /** `-[CKUIBehaviorPhone transcriptGroupTypingContactImageDiameter]`, for the typing indicator. */\n  typingDiameter: 44,\n} as const;\n\nexport type GroupAvatarProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** Circle diameter. Defaults to the framework's 60. */\n  size?: number;\n  /** The people in the conversation, in the order the stack should draw them. */\n  participants: readonly GroupParticipant[];\n  /** Accessible name; falls back to the participants' names. */\n  name?: string;\n  /** Mirror the stack, the way `isRTL:YES` does. Defaults to the document direction. */\n  rtl?: boolean;\n};\n\n/** \"Alex Morgan, Jamie Chen and Sam Rivera\", or \"3 people\" when nobody is named. */\nfunction participantsLabel(participants: readonly GroupParticipant[]): string {\n  const named = participants.map(person => person.name ?? person.initials).filter((value): value is string => Boolean(value));\n  if (!named.length) return participants.length === 1 ? \"1 person\" : `${participants.length} people`;\n  if (named.length === 1) return named[0];\n  return `${named.slice(0, -1).join(\", \")} and ${named[named.length - 1]}`;\n}\n\n/**\n * A group conversation's photo: the participants' avatars stacked into one circle, largest in front.\n * Draws at most seven faces, as the framework table does.\n */\nexport function GroupAvatar({ size = groupAvatarSize, participants, name, rtl, className, style, ...props }: GroupAvatarProps) {\n  const faces = participants.slice(0, snowglobeMaxFaces);\n  const slots = snowglobeSlots(faces.length || 1);\n  const label = name ?? participantsLabel(participants);\n  return (\n    <span\n      data-slot=\"group-avatar\"\n      data-size={size}\n      data-count={participants.length}\n      role=\"img\"\n      aria-label={label}\n      dir={rtl === undefined ? undefined : rtl ? \"rtl\" : \"ltr\"}\n      className={cn(\"relative inline-block shrink-0 select-none align-middle\", className)}\n      style={{ width: size, height: size, ...style }}\n      {...props}\n    >\n      {/* An empty group still draws one circle, so the surface never collapses while a conversation loads. */}\n      {(faces.length ? faces : [{}]).map((person, index) => {\n        const frame = snowglobeFrame(slots[index], size, rtl);\n        // setZPosition: -index. The largest face is drawn first and stays in front.\n        const layer: CSSProperties = { position: \"absolute\", left: frame.left, top: frame.top, zIndex: faces.length - index };\n        const avatar: AvatarProps = { size: frame.size, initials: person.initials, src: person.src, name: person.name };\n        return <Avatar key={index} {...avatar} aria-hidden=\"true\" role={undefined} style={layer} />;\n      })}\n    </span>\n  );\n}\n\nexport type SenderAvatarProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  /** The sender's name, used as the accessible label. */\n  name?: string;\n  initials?: string;\n  src?: string;\n  /** Diameter. Defaults to the framework's 32; the group typing indicator uses 44. */\n  size?: number;\n};\n\n/**\n * The Ø 32 photo beside an incoming bubble in a group. One per cluster, on the last bubble, bottom\n * aligned with the balloon: `message-list.tsx` places it.\n */\nexport function SenderAvatar({ name, initials, src, size = senderAvatarMetrics.diameter, className, style, ...props }: SenderAvatarProps) {\n  return (\n    <Avatar\n      data-slot=\"sender-avatar\"\n      size={size}\n      initials={initials}\n      src={src}\n      name={name}\n      className={className}\n      style={style}\n      {...props}\n    />\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/group-avatar.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "audio-recorder",
      "title": "Audio recorder",
      "description": "The composer turning into a voice-message recorder: live waveform, running timer, and the send, stop and cancel controls.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/platform.json",
        "https://imessage.swerdlow.dev/r/tokens.json"
      ],
      "files": [
        {
          "path": "registry/imessage/audio-recorder.tsx",
          "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePlatform, type Platform } from \"@/components/imessage/platform\";\nimport { fontStack } from \"@/components/imessage/tokens\";\n\n/**\n * Recording an audio message: the composer's field becomes a row with a live waveform, a timer and\n * the stop control, and once you stop, a play control and a send button.\n *\n * ## Where these numbers come from\n *\n * **No capture in `references/` holds this screen on either platform.** Everything geometric below\n * was instead read out of ChatKit itself, which is the framework macOS Messages runs on: a Mac\n * Catalyst probe (`clang -target arm64-apple-ios18.0-macabi`) dlopens\n * `/System/iOSSupport/System/Library/PrivateFrameworks/ChatKit.framework/ChatKit`, swizzles\n * `-[UIDevice userInterfaceIdiom]` so `+[CKUIBehavior sharedBehaviors]` vends the Phone or the Mac\n * behaviour, and then **builds the real view**: `-[CKAudioMessageRecordingView initWithFrame:service:]`\n * (a Swift class, `ChatKit.AudioMessageRecordingView`), feeds it levels through\n * `-addToWaveformWithIntensity:`, walks `-setState:` 0 to 3 and reads every subview's frame back.\n * So the frames below are the frames Messages lays out, not a reading of a screenshot and not a guess.\n *\n * Read off `CKUIBehavior` (shared by both idioms unless noted):\n *\n * | Selector | Value |\n * |---|---|\n * | `audioRecordingViewDurationSpacing` | 12, and it is the only gap the row uses |\n * | `audioRecordingViewButtonSpacing` | 16, the waveform's leading inset with no play button |\n * | `audioWaveformViewHeight` / `audioWaveformHeight` | 39 / 35 |\n * | `waveformPowerLevelWidth` / `audioWaveformGapWidth` | 2 / 2, so the bar pitch is 4 |\n * | `minimumWaveformHeight` | 4 |\n * | `audioRecordingViewTimeBetweenWaveformSegments` | 0.0833333 s, i.e. 12 bars a second |\n * | `minAudioRecordingDuration` / `maxAudioRecordingDuration` | 0.25 / 60 |\n * | `audioRecordingViewMinimumDBLevel` / `MaximumDBLevel` | -60 / -10 |\n * | `audioBalloonTimeFont` | SF Regular **13** on Phone, **16** on Mac: the timer's type |\n * | `audioMessagePeakAnimationDuration` | 0.5 |\n *\n * Read off the built view (`-[CKAudioMessageRecordingView sizeThatFits:]` and its subviews' frames,\n * Phone idiom in a 294-wide box, which is the measured width of the iOS composer field):\n *\n * - the row is **52** tall (Mac: **49**), and every child is centred in it;\n * - play/pause: a **34** circle 9 in from the leading edge (Mac: **31**), fill `#767680` at 12%,\n *   `play.fill` / `pause.fill`;\n * - stop: a **34** circle 9 in from the trailing edge (Mac: 31), fill `#FF383C` at 19%, `stop.fill`;\n * - send: `CKGlassSendButton`, **30 x 22, radius 11**, 15 in from the trailing edge (Mac: 13.5),\n *   `#0088ff` with `arrow.up`;\n * - the timer: `ChatKit.AudioMessageRecordingAppendButton`, **29.5 x 16 radius 8** while recording\n *   (Mac 35 x 19 radius 9.5) and **61 x 26 radius 13** once stopped (Mac 62.5 x 27 radius 13.5),\n *   where it also carries a `plus` and appends to the take;\n * - the waveform box: 39 tall in the 52 row and 36.75 in the Mac 49, i.e. **0.75 of the row** both\n *   times, its leading edge 16 while recording and (button + 12) once the play control is there.\n *\n * Those four layouts reproduce exactly: leading inset + parts + 12 between each + trailing inset add\n * up to the framework's own subview frames to the pixel, on both idioms and in both states.\n *\n * Bar height is **not** linear in the level: feeding known intensities and reading the segment views\n * back gives `height = level^2 * waveformHeight`, floored at the 4 above (0.111 -> 4.333, 0.222 ->\n * 7.704, 0.444 -> 17.333, 1 -> 39, all at 39 of box). The bars are 2 wide with a radius of 1.\n *\n * Colours, resolved through `-resolvedColorWithTraitCollection:` in both styles:\n * recording bars `#FF383C` light / `#FF4245` dark; played-back bars `rgba(0,0,0,0.5)` /\n * `rgba(255,255,255,0.55)` with the part that has not played yet at half that alpha; the timer red\n * while recording and 85% label ink afterwards.\n *\n * The `x` that replaces the composer's `+` is `CKGlassCancelAudioRecordingButton`: **41** across on\n * Phone and **35** on Mac, its `xmark` ink 17 x 16 centred. Each glyph outline here was traced from\n * the framework's own rendering: the probe rasterised `xmark`, `stop.fill`, `play.fill`,\n * `pause.fill`, `plus` and `arrow.up` at 17 pt (arrow Bold) at 8x and measured the ink -- stop 14\n * square with a 1.5 corner, pause two 4.25 x 14 bars 2 apart, play a 12.5 x 14 triangle, plus and\n * xmark 1.5 of stroke over 14 and 13.5, arrow 13.5 x 16 with a 2.44 stem. The arrow is the same\n * glyph the composer's send pill draws, and the two agree: 2.44 of stroke here against the 2.41\n * traced from `conv3-light.png`.\n *\n * ## What is not from the framework\n *\n * - **Where the row sits.** It reuses the composer's measured geometry so it lands on the field:\n *   iOS 28 of padding, the leading circle, a 12 gap, then the row across the field's 294 with its\n *   bottom edge on the field's (`ios-composer.tsx`); macOS the field's own left 49, width 530 and\n *   bottom 11 (`macos-composer.tsx`). The row is taller than the field it replaces (52 against\n *   40.33, 49 against 31), so it grows upward. **Judgement**: nothing says the row is centred on the\n *   field's box rather than resting on its bottom edge.\n * - **The row's corner radius**: half its height, i.e. a capsule, because both composer fields are\n *   capsules at one line (iOS 20 on 40.33, macOS 15.5 on 31, both measured). Judgement.\n * - **The row's fill**: the composer's own glass (iOS) and field fill (macOS), copied from those two\n *   files rather than imported, so this stays a registry item with no dependency on either.\n * - **The stopped pill's fill and the play glyph's ink**: reused from the play button's measured fill\n *   and the pill's own label colour. The framework builds them from a `UIBackgroundConfiguration`\n *   that resolves to nothing outside a window, so the probe could not read them. Judgement.\n * - **Playback windowing.** While recording, the newest bar's right edge is the waveform box's right\n *   edge (framework). Once stopped, the framework resamples the take to the bars that fit\n *   (`waveformMinPowerLevelsCount` 25, `waveformMaxPowerLevelsCount` 50, and the built view showed\n *   27 bars in a 109-wide box), which is what this does; that the whole take spans the box rather\n *   than scrolling under a playhead is judgement.\n * - **Motion.** The state change is the framework's own `stateChangeAnimationDuration` **0.6** and\n *   `stateChangeSpringDamping` **0.86` (Swift ivars of `AudioMessageRecordingView`, read at the\n *   constructor's trap), but the spring frequency UIKit derives from that pair is not stored\n *   anywhere, so the `linear()` easing here samples a spring settled to 0.1% at 0.6 s: judgement.\n *   The entrance and exit reuse the measured 260/200 ms and `cubic-bezier(0.32, 0.72, 0, 1)` of the\n *   iOS effects screen (`effectsPickerMetrics.timing`), which is a reuse, not a measurement of this.\n *\n * Every animation is seekable rather than merely playable: the scroll is one linear Web Animations\n * timeline whose offset is linear in time, so `progress` pauses and seeks it to a frame that renders\n * identically on every run, and the state change takes `transition={{ from, progress }}` the way\n * `MacComposer` takes `grow`. `prefers-reduced-motion` builds no timeline at all and poses the row.\n */\nexport const audioRecorderMetrics = {\n  /** Both sets are ChatKit's, read per idiom. Points equal CSS px. */\n  ios: {\n    /** `-[CKAudioMessageRecordingView sizeThatFits:]`, Phone idiom. */\n    rowHeight: 52,\n    /** Composer geometry (`ios-composer.tsx`): the row takes the field's box and its bottom edge. */\n    composer: { padding: 28, leading: 41, leadingGap: 12, fieldWidth: 294 },\n    button: { size: 34, inset: 9 },\n    send: { width: 30, height: 22, radius: 11, inset: 15 },\n    timer: { width: 29.5, height: 16, radius: 8, fontSize: 13 },\n    append: { width: 61, height: 26, radius: 13 },\n    waveform: { leadingInset: 16, gap: 12, heightRatio: 0.75, barWidth: 2, barGap: 2, minBarHeight: 4 },\n  },\n  macos: {\n    rowHeight: 49,\n    /** `macos-composer.tsx`: field left 49, width 530, 11 above the pane's bottom. */\n    composer: { bottom: 11, left: 49, fieldWidth: 530, leading: 35, leadingInset: 8.5 },\n    button: { size: 31, inset: 9 },\n    send: { width: 30, height: 22, radius: 11, inset: 13.5 },\n    timer: { width: 35, height: 19, radius: 9.5, fontSize: 16 },\n    append: { width: 62.5, height: 27, radius: 13.5 },\n    waveform: { leadingInset: 16, gap: 12, heightRatio: 0.75, barWidth: 2, barGap: 2, minBarHeight: 4 },\n  },\n} as const;\n\n/** Timings. The first four are ChatKit's; the last two are borrowed, see the note above. */\nexport const audioRecorderMotion = {\n  /** `audioRecordingViewTimeBetweenWaveformSegments`: one bar every 83.33 ms. */\n  segment: 1000 / 12,\n  /** `minAudioRecordingDuration` / `maxAudioRecordingDuration`, in seconds. */\n  minDuration: 0.25,\n  maxDuration: 60,\n  /** `AudioMessageRecordingView.stateChangeAnimationDuration` and `.stateChangeSpringDamping`. */\n  stateChange: 600,\n  stateChangeDamping: 0.86,\n  /** Reused from the measured iOS effects screen; nothing measures this row appearing. */\n  enter: 260,\n  exit: 200,\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n} as const;\n\nexport type AudioRecorderState = \"recording\" | \"stopped\" | \"playing\";\n\nexport type AudioRecorderProps = Omit<ComponentProps<\"div\">, \"onChange\" | \"children\"> & {\n  platform?: Platform;\n  /** Which pose the row is in. */\n  state?: AudioRecorderState;\n  /**\n   * Levels 0..1, oldest first, one per 83.33 ms segment. Leave it out for a deterministic stand-in:\n   * this never touches a microphone, and a screenshot has to land on the same bars every run.\n   */\n  levels?: number[];\n  /** Length of the take in seconds. Defaults to `levels.length / 12`. */\n  duration?: number;\n  /** Seconds into the take, controlled. */\n  position?: number;\n  /**\n   * Seeks the clock to this fraction of `duration` instead of running it, which is what the harness\n   * does. It also stops the internal clock, so a checkpoint is a pure function of its props.\n   */\n  progress?: number;\n  /** Seek the state change instead of playing it, the way `MacComposer` takes `grow`. */\n  transition?: { from: AudioRecorderState; progress: number };\n  /** False plays the exit and then calls `onExited`. */\n  open?: boolean;\n  onExited?: () => void;\n  onCancel?: () => void;\n  onStop?: () => void;\n  onSend?: () => void;\n  /** The `+` inside the timer pill, which appends to the take. */\n  onAppend?: () => void;\n  onPlayChange?: (playing: boolean) => void;\n  onSeek?: (seconds: number) => void;\n  /** Width of the row. Defaults to the platform's measured composer field. */\n  width?: number;\n  /** Takes focus when it opens. Off by default so a seeked frame never moves the caret. */\n  autoFocus?: boolean;\n};\n\nconst clamp01 = (value: number) => Math.max(0, Math.min(1, value));\n\n/** m:ss, the way the framework's own label reads (\"0:00\", \"0:12\"). */\nfunction clock(seconds: number): string {\n  const whole = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, \"0\")}`;\n}\n\n/**\n * A stand-in take. Deterministic on purpose: no `Math.random` may reach a render, and the harness\n * screenshots this row. The shape is the one `message-audio.tsx` uses for its own stand-in peaks.\n */\nfunction fallbackLevels(count: number): number[] {\n  return Array.from({ length: count }, (_, i) => {\n    const a = Math.sin(i * 0.7) * 0.5 + 0.5;\n    const b = Math.sin(i * 1.9 + 1.1) * 0.5 + 0.5;\n    return 0.35 + Math.min(1, a * 0.6 + b * 0.55) * 0.65;\n  });\n}\n\n/**\n * The whole take, averaged down to the bars that fit, which is what the framework does once\n * recording stops (it holds between `waveformMinPowerLevelsCount` and `waveformMaxPowerLevelsCount`\n * levels for a balloon). Averaging, not sampling, so the summary does not flicker with the bucket.\n */\nfunction resample(levels: number[], count: number): number[] {\n  if (count <= 0) return [];\n  if (levels.length === 0) return Array.from({ length: count }, () => 0);\n  return Array.from({ length: count }, (_, i) => {\n    const from = Math.floor((i * levels.length) / count);\n    const to = Math.max(from + 1, Math.floor(((i + 1) * levels.length) / count));\n    let sum = 0;\n    for (let j = from; j < to; j++) sum += levels[j] ?? 0;\n    return sum / (to - from);\n  });\n}\n\n/**\n * A damped spring as a `linear()` easing, so the state change can be a single seekable timeline.\n * The damping ratio is the framework's 0.86; the frequency is not stored anywhere, so this picks\n * the one that settles to 0.1% exactly at the framework's 600 ms, which is a fit, not a reading.\n */\nfunction springEasing(damping: number, steps = 24): string {\n  const omega = -Math.log(0.001) / damping;\n  const damped = omega * Math.sqrt(Math.max(0.0001, 1 - damping * damping));\n  const points = Array.from({ length: steps + 1 }, (_, i) => {\n    const t = i / steps;\n    const value = 1 - Math.exp(-damping * omega * t) * (Math.cos(damped * t) + ((damping * omega) / damped) * Math.sin(damped * t));\n    return `${value.toFixed(4)} ${(t * 100).toFixed(2)}%`;\n  });\n  return `linear(${points.join(\", \")})`;\n}\n\n/**\n * Web Animations rejects an easing it cannot parse, and `linear()` needs a 2023 engine, so a browser\n * without it falls back to the measured decelerating curve rather than losing the animation.\n */\nfunction animateWithSpring(element: Element, frames: Keyframe[], duration: number, easing: string): Animation {\n  try {\n    return element.animate(frames, { duration, easing });\n  } catch {\n    return element.animate(frames, { duration, easing: audioRecorderMotion.ease });\n  }\n}\n\nconst reducedMotionQuery = () => (typeof window === \"undefined\" ? null : window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") ?? null);\nfunction subscribeReducedMotion(onChange: () => void) {\n  const query = reducedMotionQuery();\n  query?.addEventListener(\"change\", onChange);\n  return () => query?.removeEventListener(\"change\", onChange);\n}\n/** True when the viewer asked for less motion; false while server rendering. Its own copy, as in `macos-composer.tsx`. */\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReducedMotion, () => reducedMotionQuery()?.matches ?? false, () => false);\n}\n\n/* Glyphs. Every box below is the ink the framework's own 17 pt symbol renders at 8x, measured. */\n\n/** `xmark`, ink 13.5 square, 1.5 of stroke with round caps. */\nfunction XmarkGlyph({ size = 13.5 }: { size?: number }) {\n  return (\n    <svg aria-hidden=\"true\" width={size} height={size} viewBox=\"0 0 13.5 13.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\">\n      <path d=\"M0.75 0.75 12.75 12.75M12.75 0.75 0.75 12.75\" />\n    </svg>\n  );\n}\n\n/** `stop.fill`, a 14 square with a 1.5 corner (fitted to the rendered coverage at two depths). */\nfunction StopGlyph() {\n  return (\n    <svg aria-hidden=\"true\" width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"currentColor\">\n      <rect x=\"0\" y=\"0\" width=\"14\" height=\"14\" rx=\"1.5\" />\n    </svg>\n  );\n}\n\n/** `play.fill`, ink 12.5 x 14, its corners rounded by ~1.3 (the stroke rounds the joins). */\nfunction PlayGlyph() {\n  return (\n    <svg aria-hidden=\"true\" width=\"12.5\" height=\"14\" viewBox=\"0 0 12.5 14\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinejoin=\"round\">\n      <path d=\"M0.65 0.9 11.2 7 0.65 13.1Z\" />\n    </svg>\n  );\n}\n\n/** `pause.fill`, two 4.25 x 14 bars 2 apart, corner ~1.2. */\nfunction PauseGlyph() {\n  return (\n    <svg aria-hidden=\"true\" width=\"10.5\" height=\"14\" viewBox=\"0 0 10.5 14\" fill=\"currentColor\">\n      <rect x=\"0\" y=\"0\" width=\"4.25\" height=\"14\" rx=\"1.2\" />\n      <rect x=\"6.25\" y=\"0\" width=\"4.25\" height=\"14\" rx=\"1.2\" />\n    </svg>\n  );\n}\n\n/** `plus`, 14 across each way at 1.5 of stroke. */\nfunction PlusGlyph() {\n  return (\n    <svg aria-hidden=\"true\" width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\">\n      <path d=\"M0.75 7H13.25M7 0.75V13.25\" />\n    </svg>\n  );\n}\n\n/** `arrow.up` Bold, ink 13.5 x 16: 2.44 of stem, arms 5.28 across per 5.88 down. */\nfunction ArrowUpGlyph() {\n  return (\n    <svg aria-hidden=\"true\" width=\"13.5\" height=\"16\" viewBox=\"0 0 13.5 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.44\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M1.47 7.1 6.75 1.22 12.03 7.1M6.75 1.22V14.78\" />\n    </svg>\n  );\n}\n\nconst vars =\n  // iOS glass, copied from `ios-composer.tsx` (measured), and the framework's own recording colours.\n  \"[--ar-glass:rgba(255,255,255,0.9)] [--ar-rim:none] [--ar-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ar-round-shadow:0_5px_20px_6px_rgba(0,0,0,0.055)] \" +\n  \"[--ar-red:#ff383c] [--ar-ink:rgba(0,0,0,0.85)] [--ar-glyph:#1a1919] [--ar-wave:rgba(0,0,0,0.5)] [--ar-fill:rgba(118,118,128,0.12)] \" +\n  \"dark:[--ar-glass:rgba(28,28,28,0.9)] dark:[--ar-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ar-shadow:none] dark:[--ar-round-shadow:none] \" +\n  \"dark:[--ar-red:#ff4245] dark:[--ar-ink:rgba(255,255,255,0.85)] dark:[--ar-glyph:#f4f3f4] dark:[--ar-wave:rgba(255,255,255,0.55)]\";\n\nconst macVars =\n  // macOS field fill, rim and shadow, copied from `macos-composer.tsx` (measured).\n  \"[--ar-glass:#ffffff] [--ar-rim:none] [--ar-shadow:0_5px_25px_rgba(0,0,0,0.07)] [--ar-round-shadow:0_5px_25px_rgba(0,0,0,0.07)] [--ar-glyph:#010101] \" +\n  \"dark:[--ar-glass:#232323] dark:[--ar-rim:inset_0.774px_0.774px_0_0_#424242,inset_-0.774px_-0.774px_0_0_#424242] dark:[--ar-shadow:0_5px_25px_rgba(0,0,0,0.05)] dark:[--ar-round-shadow:0_5px_25px_rgba(0,0,0,0.05)] dark:[--ar-glyph:#f1f1f1]\";\n\ntype Geometry = {\n  waveLeft: number;\n  waveRight: number;\n  timerRight: number;\n  timerWidth: number;\n  timerHeight: number;\n  timerRadius: number;\n  stop: number;\n  send: number;\n  play: number;\n};\n\nexport function AudioRecorder({\n  platform: platformProp,\n  state = \"recording\",\n  levels,\n  duration: durationProp,\n  position: positionProp,\n  progress,\n  transition,\n  open = true,\n  onExited,\n  onCancel,\n  onStop,\n  onSend,\n  onAppend,\n  onPlayChange,\n  onSeek,\n  width,\n  autoFocus = false,\n  className,\n  style,\n  ...props\n}: AudioRecorderProps) {\n  const contextPlatform = usePlatform();\n  const platform = platformProp ?? contextPlatform;\n  const ios = platform === \"ios\";\n  const m = audioRecorderMetrics[platform];\n  const motion = audioRecorderMotion;\n  const reduced = usePrefersReducedMotion();\n\n  const rowWidth = width ?? m.composer.fieldWidth;\n  const take = useMemo(() => (levels?.length ? levels : fallbackLevels(60)), [levels]);\n  const duration = durationProp ?? take.length / 12;\n  const recording = state === \"recording\";\n\n  const root = useRef<HTMLDivElement>(null);\n  const strip = useRef<HTMLDivElement>(null);\n  const waveBox = useRef<HTMLDivElement>(null);\n  const timerBox = useRef<HTMLDivElement>(null);\n  const stopButton = useRef<HTMLButtonElement>(null);\n  const sendButton = useRef<HTMLButtonElement>(null);\n  const playButton = useRef<HTMLButtonElement>(null);\n  const laidOut = useRef<AudioRecorderState | null>(null);\n  const exited = useRef(false);\n  /** The callback lives in a ref so an inline arrow cannot restart the exit it just reported. */\n  const exitedCallback = useRef(onExited);\n  useEffect(() => {\n    exitedCallback.current = onExited;\n  });\n\n  /**\n   * The clock, for a caller that passes neither a position nor a seek. The elapsed time is stamped\n   * with the run it belongs to, so a new run reads as zero during render instead of needing the\n   * effect to write zero into state before the first tick lands.\n   */\n  const live = progress === undefined && positionProp === undefined;\n  const run = `${live}|${open}|${state}`;\n  const [tick, setTick] = useState({ run: \"\", seconds: 0 });\n  const ticking = tick.run === run ? tick.seconds : 0;\n  useEffect(() => {\n    if (!live || !open) return;\n    if (state !== \"recording\" && state !== \"playing\") return;\n    const started = performance.now();\n    const id = window.setInterval(() => {\n      const seconds = (performance.now() - started) / 1000;\n      setTick({ run, seconds: Math.min(seconds, duration) });\n    }, motion.segment);\n    return () => window.clearInterval(id);\n  }, [live, open, state, duration, motion.segment, run]);\n\n  const position = Math.max(0, Math.min(duration, positionProp ?? (progress !== undefined ? clamp01(progress) * duration : ticking)));\n\n  const geometry = useMemo(() => {\n    const build = (which: AudioRecorderState): Geometry => {\n      const isRecording = which === \"recording\";\n      const trailing = isRecording ? m.button.inset + m.button.size : m.send.inset + m.send.width;\n      const timerWidth = isRecording ? m.timer.width : m.append.width;\n      return {\n        waveLeft: isRecording ? m.waveform.leadingInset : m.button.inset + m.button.size + m.waveform.gap,\n        waveRight: trailing + m.waveform.gap + timerWidth + m.waveform.gap,\n        timerRight: trailing + m.waveform.gap,\n        timerWidth,\n        timerHeight: isRecording ? m.timer.height : m.append.height,\n        timerRadius: isRecording ? m.timer.radius : m.append.radius,\n        stop: isRecording ? 1 : 0,\n        send: isRecording ? 0 : 1,\n        play: isRecording ? 0 : 1,\n      };\n    };\n    return { recording: build(\"recording\"), stopped: build(\"stopped\"), playing: build(\"playing\") };\n  }, [m]);\n\n  const pose = geometry[state];\n  const waveWidth = Math.max(0, rowWidth - pose.waveLeft - pose.waveRight);\n  const waveHeight = m.rowHeight * m.waveform.heightRatio;\n  const pitch = m.waveform.barWidth + m.waveform.barGap;\n\n  /**\n   * While recording the strip carries the whole take and scrolls under the box's right edge, so the\n   * bar for \"now\" is always flush with it and the ones still to come are clipped. Once it stops, the\n   * take is averaged down to the bars that fit and the whole of it is in view.\n   */\n  const bars = recording ? take : resample(take, Math.max(1, Math.floor((waveWidth + m.waveform.barGap) / pitch)));\n  const played = duration > 0 ? position / duration : 0;\n  const stripOffset = (take.length - 1) * pitch;\n\n  /**\n   * The scroll. One linear timeline: the offset is `(N - 1) * pitch - 48 * t`, which is linear in\n   * time because the framework adds a bar every 83.33 ms at a pitch of 4. That makes it seekable to\n   * an exact frame, and it needs no measurement of the box, since the strip hangs off its right edge.\n   */\n  useLayoutEffect(() => {\n    const element = strip.current;\n    if (!element || !recording) return;\n    const total = duration * 1000;\n    const to = stripOffset - (total / motion.segment) * pitch;\n    if (reduced || total <= 0) {\n      const at = stripOffset - (position * 1000 / motion.segment) * pitch;\n      element.style.transform = `translateX(${at}px)`;\n      return () => { element.style.transform = \"\"; };\n    }\n    const animation = element.animate(\n      [{ transform: `translateX(${stripOffset}px)` }, { transform: `translateX(${to}px)` }],\n      { duration: total, easing: \"linear\", fill: \"both\" },\n    );\n    if (progress !== undefined || positionProp !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      animation.pause();\n      animation.currentTime = Math.max(0, Math.min(total, position * 1000));\n    }\n    return () => animation.cancel();\n  }, [recording, duration, stripOffset, pitch, position, progress, positionProp, reduced, motion.segment]);\n\n  /**\n   * The state change. It is a transient override of the layout the render already put in place (no\n   * `fill`), so the end of it is the settled row rather than a copy of it, and `transition` seeks it\n   * instead of playing it. Which direction it runs is read from the props during render.\n   */\n  const transitionFrom = transition?.from;\n  const transitionProgress = transition?.progress;\n  useLayoutEffect(() => {\n    const from = transitionFrom ?? laidOut.current;\n    laidOut.current = state;\n    if (from === null || from === undefined || from === state || reduced) return;\n    const a = geometry[from];\n    const b = geometry[state];\n    const easing = springEasing(motion.stateChangeDamping);\n    const animations: Animation[] = [];\n    const move = (element: Element | null, frames: Keyframe[]) => {\n      if (element) animations.push(animateWithSpring(element, frames, motion.stateChange, easing));\n    };\n    move(waveBox.current, [{ left: `${a.waveLeft}px`, right: `${a.waveRight}px` }, { left: `${b.waveLeft}px`, right: `${b.waveRight}px` }]);\n    move(timerBox.current, [\n      { right: `${a.timerRight}px`, width: `${a.timerWidth}px`, height: `${a.timerHeight}px`, borderRadius: `${a.timerRadius}px` },\n      { right: `${b.timerRight}px`, width: `${b.timerWidth}px`, height: `${b.timerHeight}px`, borderRadius: `${b.timerRadius}px` },\n    ]);\n    move(stopButton.current, [{ opacity: a.stop }, { opacity: b.stop }]);\n    move(sendButton.current, [{ opacity: a.send }, { opacity: b.send }]);\n    move(playButton.current, [{ opacity: a.play }, { opacity: b.play }]);\n    if (transitionProgress !== undefined) {\n      const at = clamp01(transitionProgress) * motion.stateChange;\n      for (const animation of animations) {\n        animation.pause();\n        animation.currentTime = at;\n      }\n    }\n    return () => { for (const animation of animations) animation.cancel(); };\n  }, [state, transitionFrom, transitionProgress, geometry, reduced, motion.stateChange, motion.stateChangeDamping]);\n\n  /**\n   * Entrance and exit, one timeline run forwards or backwards. `open` is read during render, so the\n   * closing pose gets its committed frames before `onExited` lets the caller unmount the row.\n   */\n  useEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    if (open) exited.current = false;\n    const closing = !open;\n    const finish = () => {\n      if (exited.current) return;\n      exited.current = true;\n      exitedCallback.current?.();\n    };\n    // Scrubbing is inspection, not a dismissal: a seeked exit poses the row and reports nothing.\n    const reports = closing && progress === undefined;\n    if (reduced) {\n      element.style.opacity = closing ? \"0\" : \"\";\n      if (!reports) return;\n      const frame = requestAnimationFrame(finish);\n      return () => cancelAnimationFrame(frame);\n    }\n    const away = { opacity: 0, transform: \"scale(0.97)\" };\n    const settled = { opacity: 1, transform: \"scale(1)\" };\n    const animation = element.animate(closing ? [settled, away] : [away, settled], {\n      duration: closing ? motion.exit : motion.enter,\n      easing: closing ? \"ease-out\" : motion.ease,\n      fill: \"both\",\n    });\n    if (progress !== undefined) {\n      animation.pause();\n      animation.currentTime = closing ? motion.exit : motion.enter;\n      return () => animation.cancel();\n    }\n    if (!closing) return () => animation.cancel();\n    animation.addEventListener(\"finish\", finish);\n    return () => animation.removeEventListener(\"finish\", finish);\n  }, [open, progress, reduced, motion.enter, motion.exit, motion.ease]);\n\n  useEffect(() => {\n    if (!autoFocus || !open || progress !== undefined) return;\n    (recording ? stopButton.current : playButton.current)?.focus({ preventScroll: true });\n  }, [autoFocus, open, progress, recording]);\n\n  const seek = (clientX: number) => {\n    const element = waveBox.current;\n    if (!element || !onSeek || recording) return;\n    const rect = element.getBoundingClientRect();\n    onSeek(clamp01((clientX - rect.left) / rect.width) * duration);\n  };\n\n  const timeText = recording || state === \"playing\" ? clock(position) : clock(duration);\n  const circle = \"absolute flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\";\n\n  const row = (\n    <div\n      data-slot=\"recording-row\"\n      className=\"relative select-none\"\n      style={{\n        width: rowWidth,\n        height: m.rowHeight,\n        borderRadius: m.rowHeight / 2,\n        background: \"var(--ar-glass)\",\n        boxShadow: \"var(--ar-rim), var(--ar-shadow)\",\n        backdropFilter: ios ? \"blur(24px)\" : undefined,\n        WebkitBackdropFilter: ios ? \"blur(24px)\" : undefined,\n      }}\n    >\n      <button\n        ref={playButton}\n        type=\"button\"\n        data-slot=\"play\"\n        aria-label={state === \"playing\" ? \"Pause audio message\" : \"Play audio message\"}\n        aria-pressed={state === \"playing\"}\n        aria-hidden={recording}\n        tabIndex={recording ? -1 : undefined}\n        onClick={() => onPlayChange?.(state !== \"playing\")}\n        className={circle}\n        style={{\n          left: m.button.inset, top: (m.rowHeight - m.button.size) / 2, width: m.button.size, height: m.button.size,\n          background: \"var(--ar-fill)\", color: \"var(--ar-ink)\", opacity: pose.play, pointerEvents: recording ? \"none\" : undefined,\n        }}\n      >\n        {state === \"playing\" ? <PauseGlyph /> : <PlayGlyph />}\n      </button>\n\n      <div\n        ref={waveBox}\n        data-slot=\"waveform\"\n        role={recording ? undefined : \"slider\"}\n        aria-hidden={recording || undefined}\n        tabIndex={recording ? undefined : 0}\n        aria-label={recording ? undefined : \"Playback position\"}\n        aria-valuemin={recording ? undefined : 0}\n        aria-valuemax={recording ? undefined : Math.round(duration)}\n        aria-valuenow={recording ? undefined : Math.round(position)}\n        aria-valuetext={recording ? undefined : clock(position)}\n        onPointerDown={event => { if (!recording && onSeek) { event.currentTarget.setPointerCapture(event.pointerId); seek(event.clientX); } }}\n        onPointerMove={event => { if (event.buttons === 1) seek(event.clientX); }}\n        onKeyDown={event => {\n          if (recording || !onSeek) return;\n          if (event.key === \"ArrowRight\") { event.preventDefault(); onSeek(Math.min(duration, position + 1)); }\n          if (event.key === \"ArrowLeft\") { event.preventDefault(); onSeek(Math.max(0, position - 1)); }\n        }}\n        className=\"absolute overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n        style={{ left: pose.waveLeft, right: pose.waveRight, top: (m.rowHeight - waveHeight) / 2, height: waveHeight }}\n      >\n        {/* Anchored to the box's trailing edge: while recording, the newest bar's right edge is that\n            edge and the rest of the take is clipped to its right until the scroll brings it in. */}\n        <div\n          ref={strip}\n          data-slot=\"waveform-bars\"\n          className=\"absolute flex items-center\"\n          style={{ right: 0, top: 0, height: waveHeight, gap: m.waveform.barGap, transform: recording ? `translateX(${stripOffset}px)` : undefined }}\n        >\n          {bars.map((level, index) => {\n            const reached = recording || (bars.length > 0 && index / bars.length < played);\n            return (\n              <span\n                key={index}\n                aria-hidden=\"true\"\n                style={{\n                  width: m.waveform.barWidth,\n                  height: Math.max(m.waveform.minBarHeight, level * level * waveHeight),\n                  borderRadius: m.waveform.barWidth / 2,\n                  background: recording ? \"var(--ar-red)\" : \"var(--ar-wave)\",\n                  opacity: reached ? 1 : 0.5,\n                }}\n              />\n            );\n          })}\n        </div>\n      </div>\n\n      <div\n        ref={timerBox}\n        data-slot=\"timer\"\n        className=\"absolute flex items-center justify-center\"\n        style={{\n          right: pose.timerRight, top: (m.rowHeight - pose.timerHeight) / 2, width: pose.timerWidth, height: pose.timerHeight,\n          borderRadius: pose.timerRadius, background: recording ? \"transparent\" : \"var(--ar-fill)\",\n          gap: 4, fontFamily: fontStack, fontSize: m.timer.fontSize, color: recording ? \"var(--ar-red)\" : \"var(--ar-ink)\",\n          fontVariantNumeric: \"tabular-nums\", whiteSpace: \"nowrap\",\n        }}\n      >\n        {!recording && onAppend ? (\n          <button type=\"button\" data-slot=\"append\" aria-label=\"Record more\" onClick={onAppend}\n            className=\"flex items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n            style={{ width: 11.5, height: 11.5, color: \"inherit\" }}>\n            <PlusGlyph />\n          </button>\n        ) : null}\n        <span role=\"timer\" aria-label={recording ? \"Recording time\" : \"Audio message length\"}>{timeText}</span>\n      </div>\n\n      <button\n        ref={stopButton}\n        type=\"button\"\n        data-slot=\"stop\"\n        aria-label=\"Stop recording\"\n        aria-hidden={!recording}\n        tabIndex={recording ? undefined : -1}\n        onClick={onStop}\n        className={circle}\n        style={{\n          right: m.button.inset, top: (m.rowHeight - m.button.size) / 2, width: m.button.size, height: m.button.size,\n          background: \"color-mix(in srgb, var(--ar-red) 19%, transparent)\", color: \"var(--ar-red)\",\n          opacity: pose.stop, pointerEvents: recording ? undefined : \"none\",\n        }}\n      >\n        <StopGlyph />\n      </button>\n\n      <button\n        ref={sendButton}\n        type=\"button\"\n        data-slot=\"send\"\n        aria-label=\"Send audio message\"\n        aria-hidden={recording}\n        tabIndex={recording ? -1 : undefined}\n        onClick={onSend}\n        className=\"absolute flex items-center justify-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n        style={{\n          right: m.send.inset, top: (m.rowHeight - m.send.height) / 2, width: m.send.width, height: m.send.height,\n          borderRadius: m.send.radius, background: \"#0088ff\", color: \"#ffffff\",\n          opacity: pose.send, pointerEvents: recording ? \"none\" : undefined,\n        }}\n      >\n        <ArrowUpGlyph />\n      </button>\n    </div>\n  );\n\n  const cancel = (\n    <button\n      type=\"button\"\n      data-slot=\"cancel\"\n      aria-label=\"Cancel audio message\"\n      onClick={onCancel}\n      className=\"flex shrink-0 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\"\n      style={{\n        width: ios ? audioRecorderMetrics.ios.composer.leading : audioRecorderMetrics.macos.composer.leading,\n        height: ios ? audioRecorderMetrics.ios.composer.leading : audioRecorderMetrics.macos.composer.leading,\n        background: \"var(--ar-glass)\", boxShadow: \"var(--ar-rim), var(--ar-round-shadow)\", color: \"var(--ar-glyph)\",\n        backdropFilter: ios ? \"blur(24px)\" : undefined, WebkitBackdropFilter: ios ? \"blur(24px)\" : undefined,\n      }}\n    >\n      <XmarkGlyph />\n    </button>\n  );\n\n  if (!ios) {\n    const mac = audioRecorderMetrics.macos;\n    return (\n      <div\n        ref={root}\n        data-slot=\"audio-recorder\"\n        data-state={state}\n        data-platform=\"macos\"\n        role=\"group\"\n        aria-label=\"Audio message\"\n        className={cn(\"absolute inset-x-0 bottom-0 select-none\", macVars, className)}\n        style={{ height: mac.composer.bottom + mac.rowHeight + 10, fontFamily: fontStack, ...style }}\n        {...props}\n      >\n        <div className=\"absolute\" style={{ left: mac.composer.leadingInset, bottom: mac.composer.bottom }}>{cancel}</div>\n        <div className=\"absolute\" style={{ left: mac.composer.left, bottom: mac.composer.bottom }}>{row}</div>\n      </div>\n    );\n  }\n\n  const composer = audioRecorderMetrics.ios.composer;\n  return (\n    <div\n      ref={root}\n      data-slot=\"audio-recorder\"\n      data-state={state}\n      data-platform=\"ios\"\n      role=\"group\"\n      aria-label=\"Audio message\"\n      className={cn(\"relative isolate flex w-full items-end select-none\", vars, className)}\n      style={{ padding: `0 ${composer.padding}px ${composer.padding}px ${composer.padding}px`, fontFamily: fontStack, ...style } as CSSProperties}\n      {...props}\n    >\n      {cancel}\n      <div style={{ marginLeft: composer.leadingGap }}>{row}</div>\n    </div>\n  );\n}\n",
          "type": "registry:ui",
          "target": "components/imessage/audio-recorder.tsx"
        }
      ],
      "type": "registry:ui"
    },
    {
      "$schema": "https://ui.shadcn.com/schema/registry-item.json",
      "name": "index",
      "title": "iMessage UI",
      "description": "The whole kit: every component plus the conversation pane and both app shells.",
      "registryDependencies": [
        "https://imessage.swerdlow.dev/r/audio-recorder.json",
        "https://imessage.swerdlow.dev/r/conversation.json",
        "https://imessage.swerdlow.dev/r/facetime-card.json",
        "https://imessage.swerdlow.dev/r/group-avatar.json",
        "https://imessage.swerdlow.dev/r/group-details.json",
        "https://imessage.swerdlow.dev/r/image-viewer.json",
        "https://imessage.swerdlow.dev/r/ios-details.json",
        "https://imessage.swerdlow.dev/r/ios-messages-app.json",
        "https://imessage.swerdlow.dev/r/ios-notices.json",
        "https://imessage.swerdlow.dev/r/ios-plus-menu.json",
        "https://imessage.swerdlow.dev/r/ios-search.json",
        "https://imessage.swerdlow.dev/r/ios-select-mode.json",
        "https://imessage.swerdlow.dev/r/ios-swipe-times.json",
        "https://imessage.swerdlow.dev/r/link-preview.json",
        "https://imessage.swerdlow.dev/r/macos-details.json",
        "https://imessage.swerdlow.dev/r/macos-messages-app.json",
        "https://imessage.swerdlow.dev/r/message-attachment.json",
        "https://imessage.swerdlow.dev/r/message-audio.json",
        "https://imessage.swerdlow.dev/r/message-edit.json",
        "https://imessage.swerdlow.dev/r/message-effects.json",
        "https://imessage.swerdlow.dev/r/message-image.json",
        "https://imessage.swerdlow.dev/r/message-motion.json",
        "https://imessage.swerdlow.dev/r/message-reply.json",
        "https://imessage.swerdlow.dev/r/photo-picker.json",
        "https://imessage.swerdlow.dev/r/screen-effects.json",
        "https://imessage.swerdlow.dev/r/sticker-picker.json",
        "https://imessage.swerdlow.dev/r/system-message.json",
        "https://imessage.swerdlow.dev/r/tapback-details.json",
        "https://imessage.swerdlow.dev/r/use-long-press.json"
      ],
      "type": "registry:style"
    }
  ]
}
