{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expand-card",
  "type": "registry:ui",
  "title": "Expand Card",
  "description": "A card that opens into a full article, growing from its place in the page to fill the screen, and shrinks back into its slot when you pull it down, like the App Store's Today tab.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/expand-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControlsWithThen,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * EXPAND CARD STORYBOARD\n *\n *  press   the card sinks a little under the finger\n *  open    it lifts out of the page and grows to fill the\n *          screen on a spring. Nothing is scaled up: the card\n *          is the article's own header seen through a smaller\n *          window, so the window just widens. Corners square\n *          off, the image uncrops, the title rides along at\n *          its own size, and the page dims behind\n *  reveal  the body fades up under the cover as it lands\n *  read    the article scrolls on its own; the page stays put\n *  lift    at the top, pull down (or swipe in from the left\n *          edge) and the article comes loose under the finger,\n *          shrinking and rounding as the page shows through\n *  home    let go moving down and it flies back into its slot\n *          at the finger's speed, scrolling the page so the\n *          slot is in view; let go moving up and it springs\n *          back to full screen\n *  catch   grab it while it's moving, either way, to take it\n *          back\n *  close   ×, Esc, a click on the page around it, or the back\n *          button\n * ───────────────────────────────────────────────────────── */\n\nconst EXPAND = {\n  open: { type: \"spring\", visualDuration: 0.5, bounce: 0.14 },\n  home: { type: \"spring\", visualDuration: 0.42, bounce: 0.12 },\n  settle: { type: \"spring\", visualDuration: 0.36, bounce: 0.2 }, // a cancelled drag springs back\n  scroll: { type: \"spring\", visualDuration: 0.42, bounce: 0 }, // the page brings the slot into view\n  reveal: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay: 0.16 },\n  fadeIn: { duration: 0.28, ease: \"easeOut\" },\n  fadeOut: { duration: 0.16, ease: \"easeOut\" },\n  quick: { duration: 0.2, ease: \"easeOut\" }, // reduced motion: snaps and fades instead of springs\n  narrow: 640, // px; narrower screens give the article the whole screen\n  margin: 40, // px kept around the article on wider screens, at most\n  radius: 24, // px; corners of the open article on wider screens, and of a lifted one\n  edge: 24, // px from the article's left edge where a swipe back can start\n  lock: 6, // px a mouse press travels before it drags\n  minScale: 0.6, // a lifted article shrinks no smaller than this\n  shrink: 1.4, // drag distance, in screen heights, to reach minScale\n  fade: 0.5, // drag distance, in screen heights, to bring the page back into view\n  dismiss: 0.12, // projected drag, in screen heights, that sends it home\n  flick: 400, // px/s; a faster release counts as a flick\n} as const;\n\ntype Spring = { type: \"spring\"; visualDuration: number; bounce: number };\ntype Rect = { x: number; y: number; width: number; height: number };\ntype Viewport = { width: number; height: number };\ntype Frame = { rect: Rect; radius: number };\n/** The card on screen: its layout box, the scale it shows at (pressed, or in a scaled parent), its corners in screen px. */\ntype Pose = { rect: Rect; scale: number; radius: number };\ntype Scroll = { element: Element; axis: \"top\" | \"left\"; to: number };\ntype Phase = \"opening\" | \"open\" | \"lifting\" | \"closing\";\n\ntype Session = {\n  key: number;\n  /** Where the card is. Null when it isn't on screen: the article fades in instead. */\n  origin: Pose | null;\n  /** The cover's height in the open article. */\n  cover: number;\n  cardWidth: number;\n  /** The card's own look, so the cover sits on the same ground in the article. */\n  skin: React.CSSProperties;\n  viewport: Viewport;\n  reduceMotion: boolean;\n};\n\ntype Drag = {\n  source: \"touch\" | \"mouse\";\n  pointer: number;\n  mode: \"pending\" | \"lift\" | \"native\";\n  axis: \"x\" | \"y\";\n  pull: boolean; // the article is at its top, so pulling down lifts it\n  edge: boolean; // started at the left edge, so swiping right lifts it\n  block: boolean; // nothing to scroll, so keep the gesture from the page behind\n  startX: number;\n  startY: number;\n  samples: { x: number; y: number; t: number }[];\n  // lift: the article's center, the point under the finger in its own px, and where it started\n  baseX: number;\n  baseY: number;\n  grabX: number;\n  grabY: number;\n  scaleFrom: number;\n  backdropFrom: number;\n  radiusFrom: number;\n};\n\ntype ArticleHandle = { close: () => void; reopen: () => void };\n\nexport interface ExpandCardProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /**\n   * The card's face, and the top of the open article. It fills the card, then\n   * the article's header, at its own size: nothing is scaled. Give its text a\n   * max width so it wraps the same in both.\n   */\n  cover: React.ReactNode;\n  /** The article's body, revealed under the cover. Rendered only while open. */\n  children?: React.ReactNode;\n  /**\n   * Gives the article a link of its own, `#id`: opening it adds a history\n   * entry (so the back button closes it), and loading the link opens it. Also\n   * the card's DOM id.\n   */\n  id?: string;\n  /** Opens or closes the article. Leave unset to let the card manage it. */\n  open?: boolean;\n  /** Called when the reader opens or closes the article. */\n  onOpenChange?: (open: boolean) => void;\n  /** Width of the open article on screens 640px and wider, in px. Narrower screens get the whole screen. */\n  width?: number;\n  /** Height of the cover in the open article, in px. Defaults to the card's height. */\n  coverHeight?: number;\n  /** Classes for the open article. Defaults to `bg-background text-foreground`. */\n  articleClassName?: string;\n  /** Shown in place of the body while it suspends. Defaults to a few placeholder lines. */\n  fallback?: React.ReactNode;\n}\n\n/**\n * A card that opens into a full article: it grows from its place in the page\n * to fill the screen, and shrinks back into it when it's pulled down.\n */\nexport function ExpandCard({\n  cover,\n  children,\n  id,\n  open,\n  onOpenChange,\n  width = 720,\n  coverHeight,\n  articleClassName,\n  fallback,\n  className,\n  onClick,\n  onDragStart,\n  ...props\n}: ExpandCardProps) {\n  const cardRef = React.useRef<HTMLElement | null>(null);\n  const articleRef = React.useRef<ArticleHandle | null>(null);\n  const [session, setSession] = React.useState<Session | null>(null);\n  const live = React.useRef({\n    sessions: 0,\n    open: false,\n    pushed: false,\n    deepLinked: false,\n    restoration: null as ScrollRestoration | null,\n  });\n\n  // Is the article the one the URL points at?\n  const linked = () => id !== undefined && readHash() === id;\n\n  // Give the article its own history entry, so the back button closes it.\n  const push = (slug: string) => {\n    const state = live.current;\n    // Going back to the page would restore its scroll from when the article\n    // opened, undoing the scroll that brings the card's slot into view. Scroll\n    // restoration is set per entry, so it's turned off on the page's entry\n    // before the article's goes on top, and turned back on when we return.\n    state.restoration ??= window.history.scrollRestoration;\n    window.history.scrollRestoration = \"manual\";\n    window.history.pushState(window.history.state, \"\", `#${encodeURIComponent(slug)}`);\n    state.pushed = true;\n  };\n\n  const link = () => {\n    if (id !== undefined && !linked()) push(id);\n  };\n\n  // Closed: take the entry back off, or drop the link from the URL.\n  const unlink = () => {\n    const state = live.current;\n    const pushed = state.pushed;\n    state.pushed = false;\n    if (!linked()) return;\n    if (pushed) window.history.back();\n    else window.history.replaceState(window.history.state, \"\", location.pathname + location.search);\n  };\n\n  const commit = (next: boolean, fromProp = false) => {\n    const state = live.current;\n    if (state.open === next) return;\n    state.open = next;\n    if (next) link();\n    if (!fromProp) onOpenChange?.(next);\n  };\n\n  const show = (instant = false, fromProp = false) => {\n    if (articleRef.current) {\n      // Still on its way home: catch it.\n      articleRef.current.reopen();\n      return;\n    }\n    const card = cardRef.current;\n    if (!card) return;\n    const viewport = readViewport();\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const next: Session = {\n      key: ++live.current.sessions,\n      origin: instant || reduceMotion ? null : poseOf(card, viewport),\n      cover: coverHeight ?? card.offsetHeight,\n      cardWidth: card.offsetWidth,\n      skin: skinOf(card),\n      viewport,\n      reduceMotion,\n    };\n    setSession((current) => current ?? next);\n    commit(true, fromProp);\n  };\n\n  // Listeners that outlive a render call the latest versions of these.\n  const actions = React.useRef({ show, push });\n  React.useLayoutEffect(() => {\n    actions.current = { show, push };\n  });\n\n  // The back and forward buttons, and links to `#id` elsewhere on the page.\n  React.useEffect(() => {\n    if (id === undefined) return;\n    const sync = () => {\n      const state = live.current;\n      const wanted = readHash() === id;\n      if (!wanted && state.restoration) {\n        // Back on the page's own entry: give it its scroll restoration back,\n        // once the browser is done with this traversal (it restores, or\n        // doesn't, after popstate).\n        const restoration = state.restoration;\n        state.restoration = null;\n        window.setTimeout(() => (window.history.scrollRestoration = restoration));\n      }\n      if (wanted && !state.open) {\n        state.pushed = true; // the entry before it is this page\n        actions.current.show();\n      } else if (!wanted && state.open) {\n        state.pushed = false;\n        articleRef.current?.close();\n      }\n    };\n    window.addEventListener(\"popstate\", sync);\n    return () => window.removeEventListener(\"popstate\", sync);\n  }, [id]);\n\n  // A link straight to the article opens it. The page goes under it in\n  // history, so the back button closes the article rather than leaving.\n  React.useEffect(() => {\n    const state = live.current;\n    if (id === undefined || state.deepLinked || readHash() !== id) return;\n    state.deepLinked = true;\n    window.history.replaceState(window.history.state, \"\", location.pathname + location.search);\n    actions.current.push(id);\n    actions.current.show(true);\n  }, [id]);\n\n  // Controlled: follow `open`.\n  React.useEffect(() => {\n    if (open === undefined || open === live.current.open) return;\n    if (open) actions.current.show(false, true);\n    else {\n      live.current.open = false;\n      articleRef.current?.close();\n    }\n  }, [open]);\n\n  const Card = (id === undefined ? \"button\" : \"a\") as React.ElementType;\n\n  return (\n    <>\n      <Card\n        {...props}\n        ref={cardRef}\n        id={id}\n        {...(id === undefined ? { type: \"button\" } : { href: `#${encodeURIComponent(id)}` })}\n        aria-haspopup=\"dialog\"\n        className={cn(\n          \"relative block cursor-pointer overflow-hidden text-start outline-none select-none\",\n          \"[-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none]\",\n          \"transition-[scale] duration-300 ease-out active:scale-[0.97] active:duration-150\",\n          \"motion-reduce:transition-none motion-reduce:active:scale-100\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          className,\n        )}\n        onClick={(event: React.MouseEvent<HTMLElement>) => {\n          onClick?.(event);\n          if (event.defaultPrevented) return;\n          // A link opened in a new tab or window opens straight into the article there.\n          const elsewhere = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;\n          if (id !== undefined && (elsewhere || event.button !== 0)) return;\n          event.preventDefault();\n          show();\n        }}\n        onDragStart={(event: React.DragEvent<HTMLElement>) => {\n          onDragStart?.(event);\n          event.preventDefault();\n        }}\n      >\n        <div className=\"relative size-full\">{cover}</div>\n      </Card>\n\n      {session ? (\n        <Article\n          key={session.key}\n          session={session}\n          card={cardRef}\n          handleRef={articleRef}\n          cover={cover}\n          width={width}\n          className={articleClassName}\n          fallback={fallback}\n          onIntent={(next) => commit(next)}\n          onClosed={() => {\n            setSession(null);\n            unlink();\n          }}\n        >\n          {children}\n        </Article>\n      ) : null}\n    </>\n  );\n}\n\nfunction Article({\n  session,\n  card,\n  handleRef,\n  cover,\n  children,\n  width,\n  className,\n  fallback,\n  onIntent,\n  onClosed,\n}: {\n  session: Session;\n  card: React.RefObject<HTMLElement | null>;\n  handleRef: React.RefObject<ArticleHandle | null>;\n  cover: React.ReactNode;\n  children: React.ReactNode;\n  width: number;\n  className?: string;\n  fallback?: React.ReactNode;\n  onIntent: (open: boolean) => void;\n  onClosed: () => void;\n}) {\n  const { origin, reduceMotion } = session;\n  const [viewport, setViewport] = React.useState(session.viewport);\n  const frame = frameOf(viewport, Math.max(width, session.cardWidth));\n  const titleId = React.useId();\n\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const shellRef = React.useRef<HTMLDivElement>(null);\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const coverRef = React.useRef<HTMLDivElement>(null);\n  const live = React.useRef({\n    phase: \"opening\" as Phase,\n    generation: 0,\n    done: false,\n    hidden: null as HTMLElement | null,\n    drag: null as Drag | null,\n    runs: [] as AnimationPlaybackControlsWithThen[],\n    frame,\n    viewport,\n  });\n\n  // The article's box (its layout size, so the cover lays out at real size),\n  // plus a drag offset and scale on top.\n  const start = origin?.rect ?? frame.rect;\n  const left = useMotionValue(start.x);\n  const top = useMotionValue(start.y);\n  const boxWidth = useMotionValue(start.width);\n  const boxHeight = useMotionValue(start.height);\n  const coverHeight = useMotionValue(origin ? origin.rect.height : session.cover);\n  const dx = useMotionValue(0);\n  const dy = useMotionValue(0);\n  const scale = useMotionValue(origin?.scale ?? (reduceMotion ? 1 : 0.96));\n  const radius = useMotionValue(origin?.radius ?? frame.radius); // screen px\n  const opacity = useMotionValue(origin ? 1 : 0);\n  const backdrop = useMotionValue(0);\n  const reveal = useMotionValue(origin ? 0 : 1); // the body and the close button\n  const x = useTransform(() => left.get() + dx.get());\n  const y = useTransform(() => top.get() + dy.get());\n  const borderRadius = useTransform(() => radius.get() / Math.max(scale.get(), 0.01));\n  const bodyY = useTransform(() => (1 - reveal.get()) * 20);\n  const shadow = useTransform(\n    () => `0 24px 64px -16px rgb(0 0 0 / ${(0.4 * backdrop.get()).toFixed(3)})`,\n  );\n\n  const stop = () => {\n    const state = live.current;\n    for (const value of [left, top, boxWidth, boxHeight, coverHeight, dx, dy, scale, radius, opacity, backdrop, reveal]) {\n      value.stop();\n    }\n    for (const run of state.runs) run.stop();\n    state.runs = [];\n  };\n\n  const setScrollable = (scrollable: boolean) => {\n    const scroller = scrollerRef.current;\n    if (scroller) scroller.style.overflowY = scrollable ? \"auto\" : \"hidden\";\n  };\n\n  // Grow into the open article: on open, after a cancelled drag, or when\n  // caught on its way home.\n  const expand = (velocity = { x: 0, y: 0 }, spring: Spring = EXPAND.open) => {\n    const state = live.current;\n    stop(); // including the page scrolling the slot into view\n    state.phase = \"opening\";\n    const generation = ++state.generation;\n    setScrollable(true);\n    const { rect, radius: corners } = state.frame;\n    const move = reduceMotion ? EXPAND.quick : spring;\n    const runs = [\n      animate(left, rect.x, move),\n      animate(top, rect.y, move),\n      animate(boxWidth, rect.width, move),\n      animate(boxHeight, rect.height, move),\n      animate(coverHeight, session.cover, move),\n      animate(dx, 0, { ...move, velocity: velocity.x }),\n      animate(dy, 0, { ...move, velocity: velocity.y }),\n      animate(scale, 1, { ...move, restDelta: 0.001 }),\n      animate(radius, corners, move),\n      animate(opacity, 1, EXPAND.fadeIn),\n      animate(backdrop, 1, EXPAND.fadeIn),\n      animate(reveal, 1, reveal.get() > 0 ? EXPAND.fadeIn : EXPAND.reveal),\n    ];\n    state.runs = runs;\n    Promise.all(runs).then(() => {\n      if (state.generation === generation) state.phase = \"open\";\n    });\n  };\n\n  // Landed: show the card, close the dialog and hand focus back, all in one\n  // task so no frame shows both or neither. Also runs if the browser closes\n  // the dialog on its own.\n  const finish = () => {\n    const state = live.current;\n    if (state.done) return;\n    state.done = true;\n    stop();\n    revealCard(state);\n    dialogRef.current?.close();\n    card.current?.focus({ preventScroll: true });\n    onClosed();\n  };\n\n  const close = (velocity = { x: 0, y: 0 }) => {\n    const state = live.current;\n    if (state.done || state.phase === \"closing\") return;\n    stop();\n    state.drag = null;\n    state.phase = \"closing\";\n    const generation = ++state.generation;\n    onIntent(false);\n    setScrollable(false);\n\n    const scroller = scrollerRef.current;\n    const home = reduceMotion ? null : landing(card.current, readViewport());\n    const runs = [\n      animate(backdrop, 0, { duration: 0.34, ease: \"easeOut\" }),\n      animate(reveal, 0, EXPAND.fadeOut),\n    ];\n    if (home) {\n      // Back to the top, so the cover is what lands.\n      if (scroller && scroller.scrollTop > 0) runs.push(scrollTo(scroller, \"top\", 0));\n      runs.push(\n        animate(left, home.rect.x, EXPAND.home),\n        animate(top, home.rect.y, EXPAND.home),\n        animate(boxWidth, home.rect.width, EXPAND.home),\n        animate(boxHeight, home.rect.height, EXPAND.home),\n        animate(coverHeight, home.rect.height, EXPAND.home),\n        animate(dx, 0, { ...EXPAND.home, velocity: velocity.x }),\n        animate(dy, 0, { ...EXPAND.home, velocity: velocity.y }),\n        animate(scale, home.scale, { ...EXPAND.home, restDelta: 0.001 }),\n        animate(radius, home.radius, EXPAND.home),\n        animate(opacity, 1, EXPAND.fadeIn),\n        // The page scrolls the slot into view as the article shrinks.\n        ...home.scrolls.map((scroll) => scrollTo(scroll.element, scroll.axis, scroll.to)),\n      );\n    } else {\n      // Nowhere on screen to land: fade out where it is.\n      runs.push(animate(opacity, 0, EXPAND.fadeOut));\n      if (!reduceMotion) runs.push(animate(scale, scale.get() * 0.96, EXPAND.fadeOut));\n    }\n    state.runs = runs;\n    Promise.all(runs).then(() => {\n      if (live.current.generation === generation) finish();\n    });\n  };\n\n  const settle = (velocity = { x: 0, y: 0 }) => {\n    onIntent(true);\n    expand(velocity, EXPAND.settle);\n  };\n\n  const reopen = () => {\n    const state = live.current;\n    if (state.done || state.phase !== \"closing\") return;\n    settle();\n  };\n\n  /** Still visibly on its way in or out, so a press grabs it. */\n  const flying = () => {\n    const state = live.current;\n    if (state.phase === \"closing\") return true;\n    if (state.phase !== \"opening\" || reduceMotion) return false;\n    const { rect } = state.frame;\n    const off =\n      Math.abs(left.get() + dx.get() - rect.x) +\n      Math.abs(top.get() + dy.get() - rect.y) +\n      Math.abs(boxWidth.get() - rect.width) +\n      Math.abs(boxHeight.get() - rect.height);\n    return off > 12 || Math.abs(scale.get() - 1) > 0.02;\n  };\n\n  // ── Lift ────────────────────────────────────────────────\n\n  const beginLift = (drag: Drag, axis: \"x\" | \"y\") => {\n    const state = live.current;\n    stop();\n    state.generation++; // whatever was about to land, won't\n    state.phase = \"lifting\";\n    setScrollable(false);\n    drag.mode = \"lift\";\n    drag.axis = axis;\n    // The point under the finger, in the article's own px from its center, so\n    // it stays under the finger however much the article shrinks.\n    const s = scale.get();\n    drag.baseX = left.get() + boxWidth.get() / 2;\n    drag.baseY = top.get() + boxHeight.get() / 2;\n    drag.grabX = (drag.startX - drag.baseX - dx.get()) / s;\n    drag.grabY = (drag.startY - drag.baseY - dy.get()) / s;\n    drag.scaleFrom = s;\n    drag.backdropFrom = backdrop.get();\n    drag.radiusFrom = radius.get();\n    animate(opacity, 1, EXPAND.fadeIn);\n  };\n\n  const moveLift = (drag: Drag, pointerX: number, pointerY: number) => {\n    const { width: vw, height: vh } = live.current.viewport;\n    const along = drag.axis === \"y\" ? pointerY - drag.startY : pointerX - drag.startX;\n    const pull = Math.max(along, 0);\n    const size = drag.scaleFrom * Math.max(EXPAND.minScale, 1 - pull / (vh * EXPAND.shrink));\n    // The wrong way gives a little, then resists.\n    let toX = pointerX;\n    let toY = pointerY;\n    if (along < 0 && drag.axis === \"y\") toY = drag.startY - rubberband(-along, vh);\n    if (along < 0 && drag.axis === \"x\") toX = drag.startX - rubberband(-along, vw);\n    dx.set(toX - drag.grabX * size - drag.baseX);\n    dy.set(toY - drag.grabY * size - drag.baseY);\n    scale.set(size);\n    const lifted = Math.max(drag.radiusFrom, EXPAND.radius);\n    radius.set(drag.radiusFrom + (lifted - drag.radiusFrom) * Math.min(pull / 80, 1));\n    backdrop.set(drag.backdropFrom * (1 - 0.7 * Math.min(pull / (vh * EXPAND.fade), 1)));\n  };\n\n  const endLift = (drag: Drag, pointerX: number, pointerY: number, time: number, cancelled: boolean) => {\n    const velocity = cancelled ? { x: 0, y: 0 } : velocityOf(drag.samples, time);\n    const { width: vw, height: vh } = live.current.viewport;\n    const along = drag.axis === \"y\" ? pointerY - drag.startY : pointerX - drag.startX;\n    const speed = drag.axis === \"y\" ? velocity.y : velocity.x;\n    const threshold = Math.max(80, (drag.axis === \"y\" ? vh : vw) * EXPAND.dismiss);\n    // Decide by where the throw was heading, not where the finger stopped.\n    const home = !cancelled && speed > -50 && (speed > EXPAND.flick || along + project(speed) > threshold);\n    if (home) close(velocity);\n    else settle(velocity);\n  };\n\n  // Touch: decided on the first move. At the top, pulling down lifts the\n  // article; anything else is left to the browser to scroll. iOS only lets a\n  // gesture be taken from scrolling on its first move.\n  const onTouchStart = (event: TouchEvent) => {\n    const state = live.current;\n    if (state.done) return;\n    if (event.touches.length > 1) {\n      // A second finger: let go of whatever the first one was doing.\n      const drag = state.drag;\n      state.drag = null;\n      if (drag?.mode === \"lift\") settle();\n      return;\n    }\n    const touch = event.changedTouches[0];\n    const drag = createDrag(\"touch\", touch.identifier, touch.clientX, touch.clientY, event.timeStamp);\n    state.drag = drag;\n    if (flying()) {\n      // Caught on its way: it's the finger's now.\n      event.preventDefault();\n      beginLift(drag, \"y\");\n      return;\n    }\n    const scroller = scrollerRef.current;\n    const shell = shellRef.current?.getBoundingClientRect();\n    drag.pull = !scroller || scroller.scrollTop <= 1;\n    drag.edge = touch.clientX - (shell?.left ?? 0) <= EXPAND.edge;\n  };\n\n  const onTouchMove = (event: TouchEvent) => {\n    const drag = live.current.drag;\n    if (!drag || drag.source !== \"touch\") return;\n    const touch = findTouch(event.changedTouches, drag.pointer);\n    if (!touch) return;\n    record(drag, touch.clientX, touch.clientY, event.timeStamp);\n\n    if (drag.mode === \"native\") {\n      if (drag.block && event.cancelable) event.preventDefault();\n      return;\n    }\n    if (drag.mode === \"pending\") {\n      const moveX = touch.clientX - drag.startX;\n      const moveY = touch.clientY - drag.startY;\n      if (!moveX && !moveY) return;\n      if (!event.cancelable) {\n        drag.mode = \"native\"; // the browser is already scrolling\n        return;\n      }\n      const axis =\n        drag.pull && moveY > 0 && moveY >= Math.abs(moveX)\n          ? \"y\"\n          : drag.edge && moveX > 0 && moveX > Math.abs(moveY)\n            ? \"x\"\n            : null;\n      if (!axis) {\n        drag.mode = \"native\";\n        // Nothing to scroll: don't let the page behind take it.\n        const scroller = scrollerRef.current;\n        drag.block = !scroller || scroller.scrollHeight <= scroller.clientHeight;\n        if (drag.block) event.preventDefault();\n        return;\n      }\n      beginLift(drag, axis);\n    }\n    if (event.cancelable) event.preventDefault();\n    moveLift(drag, touch.clientX, touch.clientY);\n  };\n\n  const onTouchEnd = (event: TouchEvent) => {\n    const state = live.current;\n    const drag = state.drag;\n    if (!drag || drag.source !== \"touch\") return;\n    const touch = findTouch(event.changedTouches, drag.pointer);\n    if (!touch) return;\n    state.drag = null;\n    if (drag.mode !== \"lift\") return;\n    const cancelled = event.type === \"touchcancel\";\n    if (!cancelled) record(drag, touch.clientX, touch.clientY, event.timeStamp);\n    endLift(drag, touch.clientX, touch.clientY, event.timeStamp, cancelled);\n  };\n\n  // Mouse and pen: drag the cover down, or grab the article mid-flight.\n  // The body is left alone, so its text can be selected.\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"touch\" || event.button !== 0) return;\n    const state = live.current;\n    if (state.done || state.drag) return;\n    const target = event.target as Element;\n    const caught = flying();\n    const onCover = coverRef.current?.contains(target) && !target.closest(INTERACTIVE);\n    if (!caught && !onCover) return;\n    const drag = createDrag(\"mouse\", event.pointerId, event.clientX, event.clientY, event.timeStamp);\n    state.drag = drag;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    if (caught) {\n      event.preventDefault();\n      beginLift(drag, \"y\");\n    }\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = live.current.drag;\n    if (!drag || drag.source !== \"mouse\" || drag.pointer !== event.pointerId) return;\n    record(drag, event.clientX, event.clientY, event.timeStamp);\n    if (drag.mode === \"pending\") {\n      const moveX = event.clientX - drag.startX;\n      const moveY = event.clientY - drag.startY;\n      if (Math.hypot(moveX, moveY) < EXPAND.lock) return;\n      if (moveY <= Math.abs(moveX)) {\n        drag.mode = \"native\";\n        return;\n      }\n      beginLift(drag, \"y\");\n    }\n    if (drag.mode === \"lift\") moveLift(drag, event.clientX, event.clientY);\n  };\n\n  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {\n    const state = live.current;\n    const drag = state.drag;\n    if (!drag || drag.source !== \"mouse\" || drag.pointer !== event.pointerId) return;\n    state.drag = null;\n    if (drag.mode !== \"lift\") return;\n    const cancelled = event.type === \"pointercancel\";\n    if (!cancelled) record(drag, event.clientX, event.clientY, event.timeStamp);\n    endLift(drag, event.clientX, event.clientY, event.timeStamp, cancelled);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {\n    if (event.key === \"Escape\") {\n      // Handled here rather than by the dialog, which can skip the animation.\n      event.preventDefault();\n      close();\n      return;\n    }\n    const scroller = scrollerRef.current;\n    if (!scroller || !SCROLL_KEYS.has(event.key) || event.altKey || event.metaKey || event.ctrlKey) return;\n    const target = event.target as Element;\n    if (target.closest(\"input, textarea, select, [contenteditable]\")) return;\n    if (event.key === \" \" && target.closest(\"a, button, summary, [role='button']\")) return;\n    const down =\n      event.key === \"ArrowDown\" || event.key === \"PageDown\" || event.key === \"End\" || (event.key === \" \" && !event.shiftKey);\n    if (live.current.phase !== \"closing\" && scroller.contains(target)) {\n      // The article scrolls itself. At its ends, keep the page behind still.\n      const end = down\n        ? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1\n        : scroller.scrollTop <= 0;\n      if (end) event.preventDefault();\n      return;\n    }\n    // Focus is on the close button: scroll the article, never the page.\n    event.preventDefault();\n    if (live.current.phase === \"closing\") return;\n    const page = scroller.clientHeight * 0.85;\n    const step: Record<string, number> = {\n      ArrowDown: 64,\n      ArrowUp: -64,\n      PageDown: page,\n      PageUp: -page,\n      \" \": event.shiftKey ? -page : page,\n      Home: -scroller.scrollHeight,\n      End: scroller.scrollHeight,\n    };\n    scroller.scrollBy({ top: step[event.key], behavior: reduceMotion ? \"instant\" : \"smooth\" });\n  };\n\n  // Native listeners call the latest versions of these.\n  const actions = React.useRef({ expand, close, reopen, onTouchStart, onTouchMove, onTouchEnd });\n  React.useLayoutEffect(() => {\n    actions.current = { expand, close, reopen, onTouchStart, onTouchMove, onTouchEnd };\n  });\n\n  React.useLayoutEffect(() => {\n    handleRef.current = {\n      close: () => actions.current.close(),\n      reopen: () => actions.current.reopen(),\n    };\n    return () => {\n      handleRef.current = null;\n    };\n  }, [handleRef]);\n\n  // Open: lift the dialog into the top layer, hide the card the article came\n  // from, and grow it in.\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    if (!dialog.open) dialog.showModal();\n    focusTitle(coverRef.current, dialog, titleId);\n    const state = live.current;\n    // With reduced motion the article fades instead of flying, so the card stays.\n    if (!reduceMotion) hideCard(state, card.current);\n    actions.current.expand();\n    return () => revealCard(state);\n  }, [card, reduceMotion, titleId]);\n\n  // Follow the window size.\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog) return;\n    const observer = new ResizeObserver(() => {\n      const next = { width: dialog.clientWidth, height: dialog.clientHeight };\n      if (!next.width || !next.height) return;\n      live.current.viewport = next;\n      setViewport((prev) => (prev.width === next.width && prev.height === next.height ? prev : next));\n    });\n    observer.observe(dialog);\n    return () => observer.disconnect();\n  }, []);\n\n  const { x: fx, y: fy, width: fw, height: fh } = frame.rect;\n  React.useLayoutEffect(() => {\n    const state = live.current;\n    const next = { rect: { x: fx, y: fy, width: fw, height: fh }, radius: frame.radius };\n    if (sameFrame(state.frame, next)) return;\n    state.frame = next;\n    if (state.phase === \"open\") {\n      left.jump(fx);\n      top.jump(fy);\n      boxWidth.jump(fw);\n      boxHeight.jump(fh);\n      radius.jump(next.radius);\n    } else if (state.phase === \"opening\") {\n      actions.current.expand();\n    }\n  }, [fx, fy, fw, fh, frame.radius, left, top, boxWidth, boxHeight, radius]);\n\n  React.useLayoutEffect(() => {\n    const shell = shellRef.current;\n    const dialog = dialogRef.current;\n    if (!shell || !dialog) return;\n    const start = (event: TouchEvent) => actions.current.onTouchStart(event);\n    const move = (event: TouchEvent) => actions.current.onTouchMove(event);\n    const end = (event: TouchEvent) => actions.current.onTouchEnd(event);\n    // The wheel scrolls the article, and never the page behind it.\n    const wheel = (event: WheelEvent) => {\n      const state = live.current;\n      const inside = scrollerRef.current?.contains(event.target as Node);\n      if (!inside || state.phase === \"closing\" || state.phase === \"lifting\") event.preventDefault();\n    };\n    shell.addEventListener(\"touchstart\", start, { passive: false });\n    shell.addEventListener(\"touchmove\", move, { passive: false });\n    shell.addEventListener(\"touchend\", end);\n    shell.addEventListener(\"touchcancel\", end);\n    dialog.addEventListener(\"wheel\", wheel, { passive: false });\n    return () => {\n      shell.removeEventListener(\"touchstart\", start);\n      shell.removeEventListener(\"touchmove\", move);\n      shell.removeEventListener(\"touchend\", end);\n      shell.removeEventListener(\"touchcancel\", end);\n      dialog.removeEventListener(\"wheel\", wheel);\n    };\n  }, []);\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-labelledby={titleId}\n      onCancel={(event) => {\n        event.preventDefault();\n        close();\n      }}\n      onClose={finish}\n      onKeyDown={onKeyDown}\n      className=\"fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 outline-none backdrop:bg-transparent\"\n    >\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0 touch-none bg-black/45 backdrop-blur-xs\"\n        style={{ opacity: backdrop }}\n        onClick={() => close()}\n      />\n\n      <motion.div\n        ref={shellRef}\n        className={cn(\"absolute top-0 left-0 isolate overflow-hidden bg-background text-foreground\", className)}\n        style={{\n          x,\n          y,\n          width: boxWidth,\n          height: boxHeight,\n          scale,\n          borderRadius,\n          opacity,\n          boxShadow: shadow,\n        }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n      >\n        <motion.button\n          type=\"button\"\n          aria-label=\"Close\"\n          className={cn(\n            \"absolute top-[max(0.75rem,env(safe-area-inset-top))] right-3 z-10 grid size-9 place-items-center rounded-full\",\n            \"bg-black/35 text-white ring-1 ring-white/15 backdrop-blur-md outline-none\",\n            \"transition-[background-color,scale] duration-150 ease-out hover:bg-black/50 active:scale-90\",\n            \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white\",\n          )}\n          style={{ opacity: reveal }}\n          onClick={() => close()}\n        >\n          <svg\n            aria-hidden\n            viewBox=\"0 0 24 24\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2.25}\n            strokeLinecap=\"round\"\n          >\n            <path d=\"M6 6l12 12M18 6L6 18\" />\n          </svg>\n        </motion.button>\n\n        <div\n          ref={scrollerRef}\n          className=\"size-full overflow-x-hidden overflow-y-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        >\n          <motion.div\n            ref={coverRef}\n            id={titleId}\n            className=\"relative w-full cursor-grab overflow-hidden outline-none select-none active:cursor-grabbing [&_:focus]:outline-none\"\n            style={{ ...session.skin, height: coverHeight }}\n          >\n            <div className=\"relative size-full\">{cover}</div>\n          </motion.div>\n\n          {/* Laid out at the open width from the start, so it never reflows mid-flight. */}\n          <motion.div\n            className=\"relative\"\n            style={{\n              width: frame.rect.width,\n              marginLeft: `calc(50% - ${frame.rect.width / 2}px)`,\n              opacity: reveal,\n              y: bodyY,\n            }}\n          >\n            <React.Suspense fallback={fallback ?? <Placeholder />}>{children}</React.Suspense>\n          </motion.div>\n        </div>\n      </motion.div>\n    </dialog>\n  );\n}\n\nfunction Placeholder() {\n  return (\n    <div role=\"status\" aria-label=\"Loading\" className=\"mx-auto max-w-prose animate-pulse space-y-3 px-6 py-8\">\n      <div className=\"h-3 w-3/4 rounded-full bg-current opacity-10\" />\n      <div className=\"h-3 rounded-full bg-current opacity-10\" />\n      <div className=\"h-3 w-5/6 rounded-full bg-current opacity-10\" />\n    </div>\n  );\n}\n\n// ── Geometry ──────────────────────────────────────────────\n\nconst INTERACTIVE = \"a, button, input, select, textarea, label, summary, [contenteditable]\";\nconst SCROLL_KEYS = new Set([\"ArrowUp\", \"ArrowDown\", \"PageUp\", \"PageDown\", \"Home\", \"End\", \" \"]);\nconst SCROLLS = /auto|scroll|overlay/;\n\n// What the cover inherits from the card, so it sits on the same ground in\n// the article: the card's background, text styles and padding.\nconst SKIN = [\n  \"color\",\n  \"backgroundColor\",\n  \"backgroundImage\",\n  \"backgroundPosition\",\n  \"backgroundSize\",\n  \"backgroundRepeat\",\n  \"fontFamily\",\n  \"fontSize\",\n  \"fontStyle\",\n  \"fontWeight\",\n  \"lineHeight\",\n  \"letterSpacing\",\n  \"textAlign\",\n  \"textTransform\",\n  \"paddingTop\",\n  \"paddingRight\",\n  \"paddingBottom\",\n  \"paddingLeft\",\n] as const;\n\nfunction skinOf(card: HTMLElement): React.CSSProperties {\n  const style = getComputedStyle(card);\n  return Object.fromEntries(SKIN.map((key) => [key, style[key]])) as React.CSSProperties;\n}\n\nfunction readViewport(): Viewport {\n  return {\n    width: document.documentElement.clientWidth,\n    height: document.documentElement.clientHeight,\n  };\n}\n\nfunction readHash() {\n  try {\n    return decodeURIComponent(window.location.hash.slice(1));\n  } catch {\n    return window.location.hash.slice(1);\n  }\n}\n\n/** The open article: the whole screen when it's narrow, a centered column otherwise. */\nfunction frameOf(viewport: Viewport, width: number): Frame {\n  if (viewport.width < EXPAND.narrow) {\n    return { rect: { x: 0, y: 0, width: viewport.width, height: viewport.height }, radius: 0 };\n  }\n  const margin = clamp(Math.round(viewport.height * 0.05), 16, EXPAND.margin);\n  const columnWidth = Math.min(width, viewport.width - margin * 2);\n  return {\n    rect: {\n      x: Math.round((viewport.width - columnWidth) / 2),\n      y: margin,\n      width: columnWidth,\n      height: viewport.height - margin * 2,\n    },\n    radius: EXPAND.radius,\n  };\n}\n\nfunction sameFrame(a: Frame, b: Frame) {\n  return (\n    a.radius === b.radius &&\n    a.rect.x === b.rect.x &&\n    a.rect.y === b.rect.y &&\n    a.rect.width === b.rect.width &&\n    a.rect.height === b.rect.height\n  );\n}\n\n/**\n * The card's layout box, centered where it shows. Its rendered box may be\n * smaller while it's pressed; the article starts at that scale instead, so\n * the cover lays out at the card's real width and wraps the same.\n */\nfunction poseOf(card: HTMLElement, viewport: Viewport): Pose | null {\n  const width = card.offsetWidth;\n  const height = card.offsetHeight;\n  if (!width || !height) return null;\n  const box = card.getBoundingClientRect();\n  const visible =\n    box.bottom > 0 && box.right > 0 && box.top < viewport.height && box.left < viewport.width;\n  if (!visible) return null;\n  const scale = box.width / width;\n  const centerX = box.left + box.width / 2;\n  const centerY = box.top + box.height / 2;\n  return {\n    rect: { x: centerX - width / 2, y: centerY - height / 2, width, height },\n    scale,\n    radius: cornerOf(card) * scale,\n  };\n}\n\n/**\n * Where the article lands: the card's slot, after scrolling it into view.\n * Null when the card is gone, or clipped somewhere scrolling can't reach.\n */\nfunction landing(card: HTMLElement | null, viewport: Viewport) {\n  if (!card?.isConnected) return null;\n  const width = card.offsetWidth;\n  const height = card.offsetHeight;\n  if (!width || !height) return null;\n  const box = card.getBoundingClientRect();\n  const { scrolls, shiftX, shiftY } = planScroll(card, box, viewport);\n  const centerX = box.left + box.width / 2 - shiftX;\n  const centerY = box.top + box.height / 2 - shiftY;\n  const visible =\n    centerY + box.height / 2 > 0 &&\n    centerY - box.height / 2 < viewport.height &&\n    centerX + box.width / 2 > 0 &&\n    centerX - box.width / 2 < viewport.width;\n  if (!visible) return null;\n  const scale = box.width / width;\n  return {\n    rect: { x: centerX - width / 2, y: centerY - height / 2, width, height },\n    scale,\n    radius: cornerOf(card) * scale,\n    scrolls,\n  };\n}\n\n/**\n * The scrolls that bring the card into view, innermost container first, and\n * how far they move it on screen. Like `scrollIntoView({ block: \"nearest\" })`,\n * but planned rather than done, so the article can fly to where the card will\n * be while the page scrolls.\n */\nfunction planScroll(card: HTMLElement, box: DOMRect, viewport: Viewport) {\n  const scrolls: Scroll[] = [];\n  let shiftX = 0;\n  let shiftY = 0;\n\n  const bring = (element: Element, axis: \"top\" | \"left\", viewStart: number, viewEnd: number) => {\n    const vertical = axis === \"top\";\n    // Respect scroll-padding, which is how a page says a sticky header covers its top.\n    const style = getComputedStyle(element);\n    viewStart += parseFloat(vertical ? style.scrollPaddingTop : style.scrollPaddingLeft) || 0;\n    viewEnd -= parseFloat(vertical ? style.scrollPaddingBottom : style.scrollPaddingRight) || 0;\n    const start = (vertical ? box.top : box.left) - (vertical ? shiftY : shiftX);\n    const end = (vertical ? box.bottom : box.right) - (vertical ? shiftY : shiftX);\n    const pad = Math.min(24, Math.max((viewEnd - viewStart - (end - start)) / 2, 0));\n    let delta = 0;\n    if (start < viewStart + pad) delta = start - (viewStart + pad);\n    else if (end > viewEnd - pad) delta = Math.min(end - (viewEnd - pad), start - (viewStart + pad));\n    if (!delta) return;\n    const from = vertical ? element.scrollTop : element.scrollLeft;\n    const max = vertical\n      ? element.scrollHeight - element.clientHeight\n      : element.scrollWidth - element.clientWidth;\n    const to = clamp(from + delta, 0, Math.max(max, 0));\n    if (Math.abs(to - from) < 1) return;\n    scrolls.push({ element, axis, to });\n    if (vertical) shiftY += to - from;\n    else shiftX += to - from;\n  };\n\n  for (let element = card.parentElement; element; element = element.parentElement) {\n    if (element === document.body || element === document.documentElement) break;\n    const style = getComputedStyle(element);\n    const scrollsY = SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight;\n    const scrollsX = SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth;\n    if (scrollsY || scrollsX) {\n      const view = element.getBoundingClientRect();\n      const top = view.top + element.clientTop;\n      const left = view.left + element.clientLeft;\n      if (scrollsY) bring(element, \"top\", top, top + element.clientHeight);\n      if (scrollsX) bring(element, \"left\", left, left + element.clientWidth);\n    }\n    // A fixed container doesn't move with the page.\n    if (style.position === \"fixed\") return { scrolls, shiftX, shiftY };\n  }\n\n  const page = document.scrollingElement;\n  if (page) {\n    bring(page, \"top\", 0, viewport.height);\n    bring(page, \"left\", 0, viewport.width);\n  }\n  return { scrolls, shiftX, shiftY };\n}\n\nfunction cornerOf(element: HTMLElement) {\n  return parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0;\n}\n\n// ── Helpers ───────────────────────────────────────────────\n\n/** Moves focus to the cover's heading, which also names the dialog. */\nfunction focusTitle(cover: HTMLElement | null, dialog: HTMLDialogElement, fallbackId: string) {\n  if (!cover) return;\n  const heading = cover.querySelector<HTMLElement>(\"h1, h2, h3, h4, h5, h6\");\n  const title = heading ?? cover;\n  if (heading && !heading.id) heading.id = `${fallbackId}-title`;\n  dialog.setAttribute(\"aria-labelledby\", title.id);\n  if (!title.hasAttribute(\"tabindex\")) title.tabIndex = -1;\n  title.focus({ preventScroll: true });\n}\n\n/** Hides the card while its article is out, so the two read as one object. */\nfunction hideCard(state: { hidden: HTMLElement | null }, card: HTMLElement | null) {\n  if (!card) return;\n  card.style.visibility = \"hidden\";\n  state.hidden = card;\n}\n\nfunction revealCard(state: { hidden: HTMLElement | null }) {\n  if (state.hidden) state.hidden.style.visibility = \"\";\n  state.hidden = null;\n}\n\n/** Animates a scroll position, ignoring any CSS `scroll-behavior`. */\nfunction scrollTo(element: Element, axis: \"top\" | \"left\", to: number) {\n  const from = axis === \"top\" ? element.scrollTop : element.scrollLeft;\n  return animate(from, to, {\n    ...EXPAND.scroll,\n    onUpdate: (value) => element.scrollTo({ [axis]: value, behavior: \"instant\" }),\n  });\n}\n\nfunction createDrag(source: Drag[\"source\"], pointer: number, x: number, y: number, t: number): Drag {\n  return {\n    source,\n    pointer,\n    mode: \"pending\",\n    axis: \"y\",\n    pull: false,\n    edge: false,\n    block: false,\n    startX: x,\n    startY: y,\n    samples: [{ x, y, t }],\n    baseX: 0,\n    baseY: 0,\n    grabX: 0,\n    grabY: 0,\n    scaleFrom: 1,\n    backdropFrom: 1,\n    radiusFrom: 0,\n  };\n}\n\nfunction findTouch(list: TouchList, id: number) {\n  for (let i = 0; i < list.length; i++) if (list[i].identifier === id) return list[i];\n  return null;\n}\n\nfunction record(drag: Drag, x: number, y: number, t: number) {\n  drag.samples.push({ x, y, t });\n  while (drag.samples.length > 2 && t - drag.samples[0].t > 100) drag.samples.shift();\n}\n\n/** Pointer velocity over the last 80ms, in px/s. Zero if the finger had stopped. */\nfunction velocityOf(samples: Drag[\"samples\"], now: number) {\n  const last = samples[samples.length - 1];\n  if (!last || now - last.t > 80) return { x: 0, y: 0 };\n  const first = samples.find((sample) => last.t - sample.t <= 80) ?? last;\n  const dt = (last.t - first.t) / 1000;\n  if (dt <= 0) return { x: 0, y: 0 };\n  const cap = (v: number) => clamp(v, -4000, 4000);\n  return { x: cap((last.x - first.x) / dt), y: cap((last.y - first.y) / dt) };\n}\n\n/** How much further a flick travels before it comes to rest, like a scroll view. */\nfunction project(velocity: number, rate = 0.99) {\n  return ((velocity / 1000) * rate) / (1 - rate);\n}\n\n/** Past a boundary, follow less and less the further it's pulled. */\nfunction rubberband(overshoot: number, dimension: number, constant = 0.55) {\n  return (overshoot * dimension * constant) / (dimension + constant * overshoot);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n"
    }
  ]
}