{
  "$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/message-ui/platform\";\nimport { emojiFontStack, fontStack } from \"@/components/message-ui/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.\n *\n * The dim is one material in both themes at alpha 0.21 over ≈(22, 21.5, 42), and it is measured, not\n * guessed: `longpress-ok-light.png` is `conv3-light.png` with one message pressed, so regressing one\n * capture on the other over the clean rows gives it directly - slope 0.7895/0.8067/0.7889, residual\n * rms 0.17/0.29/0.26 per channel. `conv2-dark.png` -> `longpress-ok-selected-dark.png` returns alpha\n * 0.217/0.210/0.213 over (22.5, 19.1, 42.3), i.e. the same dim. The (22,18,44) this used to carry\n * rendered white as (206,205,210) where every light capture reads (206,206,210).\n *\n * The two themes then carry different green channels (22 light, 21 dark) because no single 8-bit\n * value serves both in Chrome, which quantizes the 0.21 alpha to 54/255 = 0.211765 before\n * compositing. Green 21.5 lands half a level either side of a rounding boundary at both ends of the\n * range: over white it composites to 205.55, and the light captures read 206 (G 22 gives 205.66, G 21\n * gives 205.45); over black it composites to 4.55, and the dark captures read 4 (G 21 gives 4.45,\n * G 22 gives 4.66). R 22 and B 42 clear their boundaries at both ends and need no such split. Over\n * `longpress-ok-light.png` the light nudge takes the frame's interior from -0.9 to -0.1 mean signed\n * green and 1.30 to 1.15 levels of mean absolute error; over `longpress-dark.png` holding dark at 21\n * keeps it at 1.21 rather than 1.46. `messageActionsDim`'s fallback carries the dark value.\n *\n * `--im-menu-glass` was refitted against the captures rather than guessed. Because the tint, the\n * alpha and the brightness term all trade off against each other, only two things are actually\n * determined by a capture: the constant the material contributes, and how much of the backdrop\n * survives it. Both were solved by rendering the menu twice at a fixed alpha and filter, once with a\n * black tint and once with a white one, which makes the output linear in the tint, and then solving\n * for the tint that lands on the capture.\n *\n * The solve runs on the menu's lower 55% only. Above that the menu laps the message it was opened\n * on, and there NO tint at this alpha can reach the capture: at y 610 of `longpress-ok-light.png` the\n * glass reads (181,238,198) over a dimmed bubble the filter turns into ≈(84,238,128), so at alpha 0.8\n * the tint would have to be (-59,218,14). Native carries far more of the bubble's colour through than\n * a flat fill plus `wash` can, and that is a `context-menu.tsx` / `message-actions.tsx` question\n * (the `tint` gradient the menu already supports and the overlay never passes), not a material one.\n * Ours runs +18 R / +12 B over that band and matches to 1-3 levels everywhere below it.\n *\n * The light value was confirmed first, against the old 0.16 shadow: with `longpress-two-line`\n * reconstructing, ok / incoming / two-line solve to (240.0,243.6,243.7), (240.2,240.8,244.0) and\n * (241.2,244.4,244.7), i.e. rgba(241,243,244,0.8) holds and two-line, which used to sit seven levels\n * off, now agrees with the other two. The number in the table below is 4 levels lower than that only\n * because the shadow underneath it changed; see the next paragraph.\n *\n * Both values are then re-solved at the measured shadow, because `context-menu.tsx` paints\n * `menu-shadow` INSIDE the menu, under the glass, so the glass's own backdrop carries it and the\n * tint absorbs whatever it contributes. Taking the shadow from 0.16 to the measured 0.098 lightens\n * that backdrop and drops the solved tint by 4 levels; the slope is 64.5 tint levels per unit of\n * shadow alpha, so a `context-menu.tsx` that masked its own shape out of the shadow (which is what\n * native draws - a drop shadow is not visible under the menu) would want ≈(231,233,234) instead.\n * At 0.098 the three light captures solve to (236.2,239.9,239.9), (236.3,236.9,240.0) and\n * (237.2,240.5,240.7): rgba(237,239,240,0.8), residuals 1.75 / 1.84 / 1.52 levels.\n *\n * Dark is rgba(20,22,23,0.8), fitted the same way now that the dark scenes reconstruct:\n * `longpress-first` -> `longpress-dark.png` (19.4,22.2,22.7), `longpress-last` ->\n * `longpress-last-bubble-dark.png` (20.2,20.5,23.2) and `longpress-selected` ->\n * `longpress-ok-selected-dark.png` (20.4,21.8,23.9), i.e. three captures inside 1.7 levels of each\n * other. It takes the same band from 2.76/2.45/2.43 levels of mean absolute error to 1.61/1.31/1.36.\n * The rgba(20,25,25,0.8) it used to carry was 3 levels green-heavy.\n *\n * `--im-menu-shadow` is the menu's drop shadow, and 0.098 is measured, not the 0.16 the component\n * defaults to. `longpress-ok-light.png` divided by `conv3-light.png` and the dim gives the shadow's\n * alpha directly on every pixel around the menu; fitting a Gaussian to the ring below and beside it\n * returns alpha 0.0992, sigma 17.52 pt, offset 7.00 pt down (rms 0.0025 alpha), and\n * `longpress-incoming-light.png` over `incoming-light.png` returns 0.0971 / 17.18 / 7.00. It is a\n * pure black shadow: the three channels' alphas agree to 0.001. NOTE for `context-menu.tsx`: its\n * `blur(11px)` is a sigma of 11 and needs to be 17.4 for the falloff to match - the shadow reaches\n * 41 pt below the menu in the capture and 28 in ours. The alpha is right either way (fitting it at\n * the wrong sigma returns 0.1012), so this value stands on its own.\n *\n * The dark theme's shadow is UNVERIFIED: every dark capture puts the menu over a near-black ground,\n * where a black shadow at any alpha is invisible. It carries the light value rather than the\n * component's unmeasured 0.16.\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,22,42,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(237,239,240,0.8)\", \"--im-menu-glass-filter\": \"blur(9px) brightness(1.32) saturate(1.35)\", \"--im-menu-bg\": \"#edeff1\", \"--im-menu-text\": \"#000000\", \"--im-menu-shadow\": \"rgba(0,0,0,0.098)\",\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,21,42,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,22,23,0.8)\", \"--im-menu-glass-filter\": \"blur(9px) saturate(2)\", \"--im-menu-bg\": \"#121316\", \"--im-menu-text\": \"#ffffff\", \"--im-menu-shadow\": \"rgba(0,0,0,0.098)\",\n    \"--im-menu-separator\": \"rgba(255,255,255,0.15)\", \"--im-menu-destructive\": \"#ff453a\", \"--im-tapback-own\": tapbackColors.ownDark, \"--im-tapback-theirs\": \"#3b3b3d\", \"--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 *\n * **ChatKit agrees with both captures, once its rim is taken off.** Read out of the live framework on\n * 2026-09-10 with the Catalyst probe in `references/tapback-details.md` (`CKUIBehaviorPhone` and\n * `CKUIBehaviorMac`, one process, no scaling):\n * `-[CKUIBehavior messageAcknowledgmentTranscriptBalloonSize]` is **{36, 36} on iPhone and {29, 29} on\n * Mac**, against the Ø34 and Ø28 measured here. The difference is exactly the rim `aggregateStack`\n * below already documents from the artwork — `AcknowledgmentStackBackground-*` is the silhouette 1.0\n * (phone) / 0.5 (Mac) larger on every side — so 34 + 2×1 = 36 and 28 + 2×0.5 = 29. The framework sizes\n * the balloon *view*; `main` is the ink inside it. Two independent readings, no disagreement.\n *\n * `-[CKUIBehavior messageAcknowledgmentTranscriptGlyphInset]` is **4 on iPhone, 3 on Mac**, so the\n * glyph's image box is 36 − 8 = 28 and 29 − 6 = 23. `glyph` is the heart's *ink* inside that box\n * (18.34 of 28, 14.64 of 23), which is what a capture can see; the two are not the same number and\n * neither contradicts the other.\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 as `reactionOffsets` and is the one that\n * actually places a balloon — for a photo, a link card, an audio row and a bare emoji as well as for\n * a text bubble. Its macOS row used to read { 19.6, −19.1, −9.9 }, 7.8 / 3.0 / 1.9 pt off; it now\n * carries this pair, and `tests/unit/tapback-slot.test.ts` holds the three copies together.\n *\n * **ChatKit carries a position too, and it does NOT reproduce these captures.**\n * `-[CKUIBehavior messageAcknowledgmentTranscriptBalloonRelativePosition]` is {12.5, −15.5} on iPhone\n * and {9, −13} on Mac (same probe as `balloonGeometry`). Four readings of what that point could be\n * were tried against the iOS capture, whose Ø34 ink sits at (−13.85, −27.39) from the body's top\n * corner, i.e. ink centre (+3.15, −10.39) and 36-pt view centre the same:\n *\n * | reading of the point                          | ink left | ink top | capture |\n * |-----------------------------------------------|----------|---------|---------|\n * | view centre at corner + (−12.5, −15.5)        | −29.50   | −32.50  | −13.85 / −27.39 |\n * | view centre at corner + (+12.5, −15.5)        |  −4.50   | −32.50  | −13.85 / −27.39 |\n * | view top-left at corner + (−12.5, −15.5)      | −11.50   | −14.50  | −13.85 / −27.39 |\n * | 12.5 of the 36-wide view overlapping the body | −22.50   | —       | −13.85 |\n *\n * None lands. The capture is what this kit keeps (two captures, outgoing and incoming, agreeing to\n * 0.16 / 0.34 pt), and the framework number is recorded here rather than used. Whatever coordinate\n * space it is in, it is not \"offset from the balloon's top corner in points\", and nothing was found\n * that says which space it is.\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/**\n * More than one person reacting does not make a pill with a number on it - that was invented here.\n * ChatKit draws an aggregate balloon out of its own artwork: `-[CKUIBehavior\n * aggregateAcknowledgmentStackName2:]` and `...StackName3:` name `AcknowledgmentStack-2` and\n * `AcknowledgmentStack-3`, and `aggregateAcknowledgmentTranscriptBalloonSize` is {46, 40} on the\n * phone against {36, 36} for a single balloon, {35, 30} against {29, 29} on the Mac. Pulling those\n * renditions out of `ChatKit.framework/Resources/Assets.car` at both idioms and reading their alpha\n * shows what the extra width is: the frontmost balloon carries the glyph and one or two more sit\n * behind it, each shifted along the tail's direction, so only a crescent of each shows. Two\n * reactions draw one crescent, three or more draw two - there is no fourth asset and no number.\n *\n * The widest row of `AcknowledgmentStack-3` at 2x measures, in points: fill 1.00..34.00, gap to\n * 35.00, crescent to 37.00, gap to 38.00, crescent to 40.00. So the phone's artwork is a 33.0 ball\n * with a 1.0 gap and a 3.0 pitch, and the whole silhouette's ink runs 1.00..42.00 for -2 against\n * 1.00..45.00 for -3, which is the same 3.0 read a second way. The Mac's is a 25.0 ball with a 0.5\n * gap and a 2.5 pitch (ink 0.50..32.00 against 0.50..34.50). `AcknowledgmentStackBackground-*` is\n * the same silhouette 1.0 (phone) / 0.5 (Mac) larger on every side: the gaps are that rim showing\n * through between the balls, so one rim value draws both.\n *\n * Our balls are 34 and 28, measured off captures rather than off the artwork, so the pitch and gap\n * are scaled by 34/33 and 28/25 to keep the artwork's proportions against the ball we actually draw.\n */\nexport const aggregateStack: Record<Platform, { pitch: number; gap: number; max: number }> = {\n  ios: { pitch: 3 * (34 / 33), gap: 1 * (34 / 33), max: 2 },\n  macos: { pitch: 2.5 * (28 / 25), gap: 0.5 * (28 / 25), max: 2 },\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, ring }: { geometry: BalloonGeometry; side: \"left\" | \"right\"; color: string; /** The rim an aggregate stack needs, so the ball behind shows as a crescent beside this one too. */ ring?: 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, boxShadow: ring,\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  // See `aggregateStack`: extra reactions are crescents of the same balloon behind this one, capped\n  // at two because ChatKit's artwork stops at `AcknowledgmentStack-3`.\n  const a = aggregateStack[platform];\n  const behind = Math.min(Math.max((count ?? 1) - 1, 0), a.max);\n  // One rim, not two: the Mac already knocks a 0.51 pt pane rim out of what the ball laps, and the\n  // stack's gap is 0.56, so the wider of the pair is the only one that shows.\n  const gapRing = `0 0 0 ${Math.max(a.gap, platform === \"macos\" ? macosBalloonRim : 0)}px var(--im-bg, #fff)`;\n  const rootStyle: CSSProperties = {\n    position: \"relative\", display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", boxSizing: \"border-box\",\n    width: g.main, minWidth: g.main, height: g.main, borderRadius: g.main / 2,\n    // Stacked, the front ball is a layer of its own so the ones behind it can paint first; the root\n    // would otherwise paint its background before any child and cover them.\n    background: behind ? undefined : surface,\n    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: behind ? ring ?? undefined : [rim, ring].filter(Boolean).join(\", \") || undefined,\n    ...style,\n  };\n  const ball = (index: number) => (\n    <span key={index} aria-hidden=\"true\" data-slot={index ? \"balloon-behind\" : \"balloon-front\"} style={{\n      position: \"absolute\", width: g.main, height: g.main, borderRadius: \"50%\", background: surface, top: 0,\n      [side === \"left\" ? \"left\" : \"right\"]: -index * a.pitch,\n      boxShadow: gapRing,\n    }}>\n      <BalloonTrail geometry={g} side={side} color={fill} ring={gapRing} />\n    </span>\n  );\n  const content = (\n    <>\n      {behind > 0 && Array.from({ length: behind }, (_, i) => ball(behind - i)).concat(ball(0))}\n      {/* Positioned, because the stacked balls are and CSS paints every positioned descendant after\n          the in-flow content however the DOM is ordered - without this the front ball covers the glyph. */}\n      <TapbackGlyph type={emoji ? undefined : reaction} emoji={emoji} size={g.glyph} onAccent={own} style={{ position: \"relative\", marginTop: emoji ? 0 : 2 * (g.glyphOffsetY ?? 0) }} />\n      {behind === 0 && <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/message-ui/tapback.tsx"
    }
  ],
  "type": "registry:ui"
}
