{
  "$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 { useCallback, 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 * ## Under a finger\n *\n * Every pressable thing on this screen has a held state, and none of it is in a capture — a still of\n * a resting screen cannot hold one. Both values come from the framework instead, at the **phone**\n * idiom, and `iosDetailsPress` records which is which: a grouped-list row fills with\n * `-[CKUITheme detailsSelectedCellColor]` (#dcdcdc / #464646) and a round button or a photo tile\n * dims to `-[CKUIBehaviorPhone replyButtonTouchAlpha]` (0.4). The Hide Alerts row is the exception\n * and stays flat, as a `UITableViewCell` carrying a `UISwitch` does. See `usePressed` for why the\n * state is pointer-driven rather than `:active`, and `IosSwitch` for what \"the switch does not\n * switch\" actually was.\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] [--ios-dt-press:#dcdcdc] \" +\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-press:#464646] 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  /** Controlled Hide Alerts. Omit it and the switch keeps its own state; see `IosSwitch`. */\n  hideAlerts?: boolean;\n  defaultHideAlerts?: 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  /**\n   * The destructive row's two states. It is a toggle in Messages, not a one-way trip: the row reads\n   * `BLOCK_CONTACT` (\"Block Contact\") and, once the contact is blocked, `UNBLOCK_CONTACT` (\"Unblock\n   * Contact\") — both ChatKit's own strings. Omit `blocked` and the row keeps its own state, the way\n   * `IosSwitch` does, so the row answers a press with nothing wired.\n   *\n   * **NOT BUILT: the confirmation.** iOS puts an action sheet in front of this whose message is\n   * `BLOCK_SENDER_TITLE` (\"You will not receive phone calls, messages, or FaceTime from people on\n   * the block list.\") over a destructive `BLOCK_CONTACT` and a Cancel. No capture in `references/`\n   * holds it, and its geometry would be invented, so the row toggles and reports instead.\n   */\n  blocked?: boolean;\n  defaultBlocked?: boolean;\n  blockLabel?: string;\n  unblockLabel?: string;\n  onBlock?: (blocked: boolean) => 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 * Re-measured 2026-09-09 on the same two frames and the same region: **1639 light, 3624 dark**. The\n * light number is better than the one above and the dark one is worse, and neither move is from the\n * press work in this file — reverting the row clip alone, and then the action circles' paint order\n * alone, each left the dark frame at exactly 3624. Whatever moved it is outside these three files.\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\n/**\n * What a control does under a finger. Two values, both from the framework rather than from a still —\n * no capture in this repo holds a pressed control, and a screenshot of a resting screen cannot show\n * one:\n *\n * - **A grouped-list row fills.** `-[CKUITheme detailsSelectedCellColor]` read at the **phone** idiom\n *   is **#dcdcdc** light and **#464646** dark (`--ios-dt-press`). It is an opaque replacement fill,\n *   not an overlay: the same property is `conversationListSelectedCellColor`, which the list paints\n *   over a white/black ground while details paints it over `detailsBackgroundColor` #ececec / #1e1e1e.\n *   Read as a delta on the details ground that is −16 light and +40 dark, and this screen's own\n *   measured cell composite (#efeff0 over white, #1c1c1f over black) moves −19 and +42 to reach it —\n *   the same step to within three levels, which is the check that the value belongs on this surface\n *   too. **The Mac disagrees and must not be given this value**: at idiom 5 the same selector returns\n *   #ffc726 / #ffc600, a find-highlight yellow.\n * - **A round button, or a photo tile, dims.** `-[CKUIBehaviorPhone replyButtonTouchAlpha]` is\n *   **0.4**, and every idiom including Mac agrees. It is ChatKit's alpha for a round chrome button\n *   held under a finger, so the glass circles and the back button take it directly; applying it to a\n *   shared-photo tile as well is an extension of that reading, not a second one.\n *\n * UNMEASURED, and marked as such where they are used: `pressFade` (how long the fill takes to leave\n * once the finger lifts — instant on the way in, as a `UITableViewCell`'s selected background is) and\n * `pressSlop` (how far the finger may travel before the highlight is given up to a scroll; 10 pt is\n * `UIScrollView`'s own pan threshold).\n */\nexport const iosDetailsPress = { touchAlpha: 0.4, pressFade: 200, pressSlop: 10 } as const;\n\n/**\n * Press state driven by pointer events rather than by `:active`.\n *\n * `:active` is not usable here. A `Input.dispatchTouchEvent` hold — a real finger, as far as the\n * engine is concerned — leaves `el.matches(\":active\")` **false** in Chromium, so a `:active` rule is\n * a press state that a finger never opens; and even where it does latch, it survives a scroll that\n * has already carried the row out from under the finger. Pointer events give both: the flag opens on\n * `pointerdown`, is given up the moment the finger travels `pressSlop`, and is cleared by a\n * `pointerup` or `pointercancel` **anywhere** — a listener on the element alone leaves the highlight\n * stuck on when the finger lifts off the edge of it.\n *\n * Nothing here focuses anything. That is deliberate: both engines match `:focus-visible` on a\n * programmatic focus taken while a pointer is still held, so a press state that focused its own row\n * would draw the ring the captures do not have (`tapback-bar.tsx` and `audio-recorder.tsx` document\n * the same trap). A tap still focuses the button natively, which does *not* match `:focus-visible`.\n */\nexport function usePressed(enabled = true) {\n  const [pressed, setPressed] = useState(false);\n  const origin = useRef<{ id: number; x: number; y: number } | null>(null);\n  const release = useCallback(() => { origin.current = null; setPressed(false); }, []);\n  useEffect(() => {\n    if (!pressed) return;\n    const off = () => release();\n    window.addEventListener(\"pointerup\", off);\n    window.addEventListener(\"pointercancel\", off);\n    return () => { window.removeEventListener(\"pointerup\", off); window.removeEventListener(\"pointercancel\", off); };\n  }, [pressed, release]);\n  const handlers = enabled ? {\n    onPointerDown: (event: ReactPointerEvent) => {\n      if (event.pointerType === \"mouse\" && event.button !== 0) return;\n      origin.current = { id: event.pointerId, x: event.clientX, y: event.clientY };\n      setPressed(true);\n    },\n    onPointerMove: (event: ReactPointerEvent) => {\n      const from = origin.current;\n      if (!from || from.id !== event.pointerId) return;\n      if (Math.hypot(event.clientX - from.x, event.clientY - from.y) > iosDetailsPress.pressSlop) release();\n    },\n    onPointerUp: release,\n    onPointerCancel: release,\n    onPointerLeave: release,\n  } : {};\n  const on = enabled && pressed;\n  return { pressed: on, pressAttr: on ? (\"\" as const) : undefined, handlers };\n}\n\n/**\n * The fill a held grouped-list row paints, under the row's own content. It is a sibling of the row's\n * button rather than a child of it, because a row nudges its text by the measured 0.6667 and the fill\n * must stay on the row's own box. The cell clips it, so a first or last row is cut by the measured\n * radius 26 continuous corner and nothing has to know which row it is.\n */\nfunction PressFill({ on }: { on: boolean }) {\n  return (\n    <span aria-hidden=\"true\" data-slot=\"row-press\" className=\"pointer-events-none absolute inset-0 motion-reduce:!transition-none\"\n      style={{ background: \"var(--ios-dt-press)\", opacity: on ? 1 : 0, transition: on ? \"none\" : `opacity ${iosDetailsPress.pressFade}ms ease-out` }} />\n  );\n}\n\ntype GlassCircleProps = ComponentProps<\"button\"> & { size: number; \"data-slot\"?: string; \"data-action\"?: string };\n\nfunction GlassCircle({ size, className, style, children, disabled, ...rest }: GlassCircleProps) {\n  // An unavailable action keeps its circle and drops its glyph (measured); it must not dim under a\n  // finger either, and it is marked `aria-disabled` rather than `disabled` so it stays focusable.\n  const off = Boolean(disabled) || rest[\"aria-disabled\"] === true || rest[\"aria-disabled\"] === \"true\";\n  const { pressed, pressAttr, handlers } = usePressed(!off);\n  return (\n    <button type=\"button\" data-pressed={pressAttr} disabled={disabled}\n      className={cn(\"absolute flex items-center justify-center rounded-full motion-reduce:!transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\", className)}\n      style={{\n        width: size, height: size, background: \"var(--ios-dt-fill)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\",\n        // `replyButtonTouchAlpha`. Instant in, so the circle is already dim by the time the eye\n        // arrives; the fade back out is the unmeasured `pressFade`.\n        opacity: pressed ? iosDetailsPress.touchAlpha : 1,\n        transition: pressed ? \"none\" : `opacity ${iosDetailsPress.pressFade}ms ease-out`,\n        ...style,\n      }}\n      {...handlers} {...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\" | \"defaultChecked\"> & {\n  /**\n   * Controlled state. **Omit it and the switch keeps its own**, so a consumer that only wants a\n   * working switch gets one; hand it a boolean and the consumer owns every change, as React means it.\n   */\n  checked?: boolean;\n  /** Where an uncontrolled switch starts. Ignored while `checked` is given. */\n  defaultChecked?: 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 *\n * It used to be a switch that could not switch: `checked` defaulted to `false`, so a consumer that\n * passed neither a value nor a handler — which is what `IosMessagesApp` does when its caller hands it\n * no `detailsContent` — got a control pinned off, `aria-checked=\"false\"` before the tap and\n * `aria-checked=\"false\"` after it. The default is gone; `checked === undefined` now means\n * uncontrolled, and only a caller that actually passes a boolean owns the state.\n */\nexport function IosSwitch({ checked, defaultChecked = false, onChange, label, className, style, ...rest }: IosSwitchProps) {\n  const [own, setOwn] = useState(defaultChecked);\n  const on = checked ?? own;\n  const toggle = () => {\n    if (checked === undefined) setOwn(!on);\n    onChange?.(!on);\n  };\n  return (\n    <button type=\"button\" data-slot=\"ios-switch\" role=\"switch\" aria-checked={on} aria-label={label} onClick={toggle}\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: on ? \"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        The knob slides: 220 ms on the sheet's own curve, which is a transition on `transform` and so\n        is a real interpolation `document.getAnimations()` can be caught mid-flight on, not a jump\n        between two committed frames.\n      */}\n      <span aria-hidden=\"true\" data-slot=\"ios-switch-knob\" 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(${on ? 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\n/**\n * The rows sit inside a clip of the cell's own radius, so a held row's fill is cut by the measured\n * corner and no row has to know whether it is the first or the last one.\n *\n * The clip is a wrapper around the rows rather than `overflow: hidden` on the cell itself, and the\n * difference is measurable. Clipping the cell clips `cell-fill` too, and that fill already paints\n * the same superellipse: the hard mask lands on an edge the fill has already antialiased and the\n * corner comes out a shade dark. Over `details-dark.png` that alone took the settled screen from\n * 3136 mismatched pixels to 5862, a ring two device pixels wide on all sixteen corners of the\n * measured stack. With the clip on the rows instead, the fill is untouched and the dark frame is\n * back where it was. Nothing else inside the wrapper reaches a corner — separators are inset the\n * measured 16, and so is every glyph and tile — so the clip costs nothing but the press fill.\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      <div data-slot=\"cell-clip\" className=\"absolute inset-0\" style={{ borderRadius: cell.radius, overflow: \"hidden\", ...continuous }}>\n        {children}\n      </div>\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 { pressed, pressAttr, handlers } = usePressed(Boolean(onOpen));\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 && <PressFill on={pressed} />}\n      {onOpen\n        ? <button type=\"button\" data-slot=\"section-header\" data-pressed={pressAttr} onClick={onOpen} className={cn(\"absolute inset-0 flex items-center text-left\", rowFocus)} style={style} {...handlers}>{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 { pressed, pressAttr, handlers } = usePressed(Boolean(item.onPress));\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      {item.onPress && <PressFill on={pressed} />}\n      {/* Every item row sits under a row: the header above the first one, an item above the rest.\n          The hairline stays over the fill, the way a held cell's separator does natively. */}\n      <Separator top={0} />\n      {item.onPress\n        ? <button type=\"button\" data-slot=\"detail-row\" data-pressed={pressAttr} onClick={item.onPress} className={cn(\"absolute inset-0 text-left\", !twoLine && \"flex items-center\", rowFocus)} style={style} {...handlers}>{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\n/** A tile of the shared grid. It dims under a finger at the framework's `replyButtonTouchAlpha`. */\nfunction PhotoTile({ photo, label, box, media }: { photo: IosDetailsPhoto; label: string; box: CSSProperties; media: ReactNode }) {\n  const { pressed, pressAttr, handlers } = usePressed();\n  return (\n    <button type=\"button\" data-slot=\"photo\" data-pressed={pressAttr} aria-label={label} onClick={photo.onPress}\n      className={cn(\"absolute overflow-hidden motion-reduce:!transition-none\", \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0088ff]\")}\n      style={{ ...box, opacity: pressed ? iosDetailsPress.touchAlpha : 1, transition: pressed ? \"none\" : `opacity ${iosDetailsPress.pressFade}ms ease-out` }}\n      {...handlers}>\n      {media}\n    </button>\n  );\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          ? <PhotoTile key={photo.id} photo={photo} label={label} box={box} media={media} />\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\n/** One of the second cell's blue rows (\"Create New Contact\"). It fills while it is held. */\nfunction LinkRow({ link, top, separated }: { link: { id: string; label: string; onPress?: () => void }; top: number; separated: boolean }) {\n  const { pressed, pressAttr, handlers } = usePressed();\n  return (\n    <div className=\"absolute\" style={{ left: 0, right: 0, top, height: cell.row }}>\n      <PressFill on={pressed} />\n      {separated && <Separator top={0} />}\n      <button type=\"button\" data-slot=\"link\" data-pressed={pressAttr} 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        {...handlers}>\n        {link.label}\n      </button>\n    </div>\n  );\n}\n\n/** The destructive row. Its own cell, so the fill is cut by all four of the measured corners. */\nfunction BlockRow({ label, onPress }: { label: string; onPress?: () => void }) {\n  const { pressed, pressAttr, handlers } = usePressed();\n  return (\n    <>\n      <PressFill on={pressed} />\n      <button type=\"button\" data-slot=\"block\" data-pressed={pressAttr} onClick={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-red)\" }}\n        {...handlers}>\n        {label}\n      </button>\n    </>\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  // No `= false` here: that default is what made the switch uncontrolled-but-pinned. `undefined`\n  // has to reach `IosSwitch` for it to keep its own state.\n  hideAlerts, defaultHideAlerts, onHideAlertsChange, hideAlertsLabel = \"Hide Alerts\",\n  blocked, defaultBlocked = false, blockLabel = \"Block Contact\", unblockLabel = \"Unblock 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  // Uncontrolled unless the caller states it, the same contract `IosSwitch` documents.\n  const [ownBlocked, setOwnBlocked] = useState(defaultBlocked);\n  const isBlocked = blocked ?? ownBlocked;\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          <LinkRow key={link.id} link={link} top={index * cell.row} separated={index > 0} />\n        ))}\n      </Cell>\n    ));\n  }\n  place(cell.row, top => (\n    <Cell key=\"hide-alerts\" top={top} height={cell.row}>\n      {/* No press fill: a table cell whose accessory is a switch takes `selectionStyle .none`, and\n          the row is not a button — only the switch is. */}\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} defaultChecked={defaultHideAlerts} 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      <BlockRow label={isBlocked ? unblockLabel : blockLabel} onPress={() => { if (blocked === undefined) setOwnBlocked(!isBlocked); onBlock?.(!isBlocked); }} />\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      {/*\n        The morphing avatar and title *are* the nav bar's, moving. The backdrop draws the whole\n        conversation behind, its own nav bar included, so at progress 0 - where the morph starts\n        exactly on top of the pill, and where the backdrop's blur has not come up yet - the name was\n        painted twice within 0.2 pt of itself, at two different sizes, and read as a smear. UIKit\n        hides a morph's source view for the length of the transition; this is that, in one rule.\n        Scoped to the backdrop, which is `aria-hidden` and inert either way.\n      */}\n      <style>{`[data-slot=\"ios-details\"] [data-slot=\"backdrop\"] [data-slot=\"ios-nav-bar\"] :is([data-slot=\"name\"],[data-slot=\"avatar\"]){visibility:hidden}`}</style>\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        {/* 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        {/*\n          The action circles come AFTER the scroll surface, with the rest of the header. They used to\n          come before it, and `details-scroll` is `absolute inset-0` — the full screen — so it covered\n          all three of them: a finger on Call landed on the scroller and the button was never pressed\n          at all, which is exactly what driving one measured (no press, no click, no anything). Here\n          they are part of the header the cells pass under, which is also what `iosDetailsCollapse`\n          already assumed when it fades them out by the scroll that reaches their measured top.\n        */}\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        {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/message-ui/ios-details.tsx"
    }
  ],
  "type": "registry:ui"
}
