{"slug":"crash","name":"Crash","description":"A crash game: a paper plane flies the multiplier up a curve, banking with its slope through a speed field that streams faster as it climbs, until the round crashes at a fair random point. Cash out by hand or at an auto target and a parachute pops open at the cash point while the plane flies on, so you see where it would have crashed. On a crash the plane tumbles out of the sky in a burst of paper shreds, milestones pulse the readout, and recent rounds slide into a history strip. Server-decided crash points, optional synthesized sound, reduced-motion safe.","category":"casino","source_url":"https://beui.dev/r/crash/raw","detail_url":"https://beui.dev/r/crash","raw_url":"https://beui.dev/r/crash/raw","page_url":"https://beui.dev/casino/crash","markdown_url":"https://beui.dev/casino/crash.md","published_at":"2026-11-05","updated_at":"2026-11-05","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["../magnetic","./action-swap","./base","./crash/crash-parts","./crash/use-crash-game","./magnetic","./metallic","./stateful","./tooltip/grace","./tooltip/positioner","./tooltip/use-position","./use-crash-game","./use-position","@/components/motion/action-swap-cascade","@/components/motion/button","@/components/motion/crash","@/components/motion/number-ticker","@/components/motion/select","@/components/motion/tooltip","@/components/motion/tooltip-surface","@/lib/audio-context","@/lib/ease","@/lib/game-sounds","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-capable","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-listbox","@/lib/hooks/use-on-open","@/lib/hooks/use-row-cursor","@/lib/hooks/use-tap-gesture","@/lib/presence-gate","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/crash.tsx","type":"component","content":"\"use client\";\n// beui.dev/casino/crash\n\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_IN, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  CHUTE,\n  type CrashMiniatureProps,\n  CrashShell,\n  MINI,\n  MINI_REPLAY,\n  niceAtLeast,\n  Parachute,\n  PRIMARY_TEXT,\n  STAGE_LABEL,\n  STAGE_W,\n  wrap,\n} from \"./crash/crash-parts\";\nimport {\n  BURST,\n  type CrashProps,\n  EXIT,\n  formatShort,\n  GROWTH,\n  growth,\n  SWAP,\n  timeAt,\n  useCrashGame,\n} from \"./crash/use-crash-game\";\n\nexport { type CrashProps, type CrashReveal, crashPoint, formatMultiplier } from \"./crash/use-crash-game\";\n\n// Graph geometry in SVG units, matching the shell's 10:7 stage.\nconst W = STAGE_W;\nconst H = 280;\n// The y labels sit in the right pad; the left pad clears the parked plane's tail.\nconst PAD = { left: 18, right: 40, top: 20, bottom: 24 };\n// The axes start at 8s and 2.2×, then grow so the head stays near 82% across\n// and 72% up, which keeps them continuous with no rescale jumps.\nconst X_MIN = 8;\n// 2.2×, not lower: while the axes are fixed the head must stay under the\n// readout's corner (top 40%, left 45%) even on a narrow stage.\nconst Y_MIN = 2.2;\nconst HEAD_X = 0.82;\nconst HEAD_Y = 0.72;\n\n/**\n * A crash game: a paper plane flies the multiplier up a curve until it\n * crashes at a random point. Cash out before it does. After a cash out the\n * flight keeps going, so the player sees what they left on the table.\n */\nexport function Crash({ \"aria-label\": ariaLabel = \"Crash\", className, ...options }: CrashProps) {\n  const game = useCrashGame(options);\n  // The shell pins the readout top left: an exponential curve never reaches\n  // that corner, so the flight can't run under the numbers.\n  return (\n    <CrashShell game={game} aria-label={ariaLabel} className={className} miniature={(props) => <MiniFlight {...props} />}>\n      <Graph\n        t={game.t}\n        cashed={game.cashed}\n        crashed={game.crashed}\n        round={game.round}\n        reduce={game.reduce}\n        skipping={game.skipping}\n        target={game.target}\n      />\n    </CrashShell>\n  );\n}\n\ninterface GraphProps {\n  t: number;\n  cashed: number | null;\n  crashed: boolean;\n  round: number;\n  reduce: boolean;\n  skipping: boolean;\n  target: number | null;\n}\n\n/** The flight: a curve from 1× at 0s to the current multiplier, on axes that grow with it. */\nfunction Graph({ t, cashed, crashed, round, reduce, target, skipping }: GraphProps) {\n  const fill = `crash-fill-${useId().replace(/:/g, \"\")}`;\n  const m = growth(t);\n  const xMax = Math.max(X_MIN, t / HEAD_X);\n  const yMax = Math.max(Y_MIN, 1 + (m - 1) / HEAD_Y);\n  const x = (seconds: number) => PAD.left + (seconds / xMax) * (W - PAD.left - PAD.right);\n  const y = (value: number) => H - PAD.bottom - ((value - 1) / (yMax - 1)) * (H - PAD.top - PAD.bottom);\n  const base = y(1);\n\n  // Sampled densely enough that the bend reads as smooth at any scale.\n  const path = (from: number, to: number) => {\n    let d = \"\";\n    for (let i = 0; i <= 48; i++) {\n      const s = from + ((to - from) * i) / 48;\n      d += `${i === 0 ? \"M\" : \"L\"}${x(s).toFixed(2)},${y(growth(s)).toFixed(2)}`;\n    }\n    return d;\n  };\n  const cashT = cashed === null ? null : timeAt(cashed);\n  const flown = cashT === null ? t : Math.min(t, cashT);\n  const line = path(0, flown);\n  const area = `${line}L${x(flown).toFixed(2)},${base}L${x(0)},${base}Z`;\n  const lost = crashed && cashed === null;\n  const missed = cashT !== null && t > cashT && !crashed ? path(cashT, t) : null;\n\n  const yStep = niceAtLeast((yMax - 1) / 4);\n  // 1× plus round multiples of the step (500×, 1K×, not 501×), skipping one\n  // too close to the baseline. The epsilon keeps 0.8 / 0.2 from flooring to 3.\n  const yTicks = [1];\n  for (let k = Math.ceil(1 / yStep + 1e-9); k * yStep <= yMax + 1e-9; k++) {\n    if (k * yStep - 1 >= yStep * 0.5) yTicks.push(+(k * yStep).toFixed(6));\n  }\n  const xStep = niceAtLeast(xMax / 4);\n  const xTicks = Array.from({ length: Math.floor(xMax / xStep + 1e-9) }, (_, i) => (i + 1) * xStep);\n\n  const head = { x: x(t), y: y(m) };\n  // Bank along the curve's on-screen slope, levelling out on the runway at\n  // 0s and easing up over the first 0.6s of take-off. A slight bob keeps it\n  // airborne; reduced motion drops the bob and the take-off ease, so the\n  // plane just points along the curve.\n  const slope = Math.atan2(\n    -((H - PAD.top - PAD.bottom) / (yMax - 1)) * GROWTH * m,\n    (W - PAD.left - PAD.right) / xMax,\n  );\n  const flying = t > 0 && !crashed;\n  const bob = flying && !reduce ? Math.sin(t * 6) * 3 : 0;\n  const takeOff = reduce ? (t > 0 ? 1 : 0) : Math.min(1, t / 0.6);\n  const bank = Math.max(-70, (slope * 180) / Math.PI) * takeOff + bob;\n\n  const travel = reduce ? 0 : 30 * t + 9 * Math.min(t, 20) ** 2 + 360 * Math.max(0, t - 20);\n  const near = travel;\n  const far = travel * 0.35;\n  const streakLength = 4 + Math.min(14, (30 + 18 * Math.min(t, 20)) * 0.035);\n  const marker = cashT === null || cashed === null ? null : { x: x(cashT), y: y(cashed) };\n\n  const targetLine =\n    target === null ? null : { y: y(Math.min(target, yMax)), above: target > yMax, label: formatShort(target) };\n\n  return (\n    <svg aria-hidden=\"true\" viewBox={`0 0 ${W} ${H}`} className=\"block w-full\">\n      <defs>\n        <linearGradient id={fill} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity={0.22} />\n          <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity={0} />\n        </linearGradient>\n        {/* Grid lines fade in from the left, leaving the readout's corner clear. */}\n        <linearGradient id={`${fill}-fade`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          <stop offset=\"0.2\" stopColor=\"white\" stopOpacity={0} />\n          <stop offset=\"0.6\" stopColor=\"white\" stopOpacity={1} />\n        </linearGradient>\n        <mask id={`${fill}-grid`} maskUnits=\"userSpaceOnUse\" x={0} y={0} width={W} height={H}>\n          <rect width={W} height={H} fill={`url(#${fill}-fade)`} />\n        </mask>\n      </defs>\n\n      {/* Speed field: far dots and near streaks stream past, faster as the\n          plane climbs. Positions derive from t, so a skip would only\n          flicker; it fades out then. Shown only in flight, so a parked or\n          crashed graph is clean. */}\n      <g className={cn(\"transition-opacity duration-200\", flying && !skipping ? \"opacity-100\" : \"opacity-0\")}>\n        {FAR.map((dot) => (\n          <circle\n            key={dot.id}\n            cx={wrap(dot.x - far, W)}\n            cy={dot.y}\n            r={1}\n            className=\"fill-foreground/15\"\n          />\n        ))}\n        {NEAR.map((streak) => {\n          const from = wrap(streak.x - near, W);\n          return (\n            <line\n              key={streak.id}\n              x1={from}\n              x2={from + streakLength}\n              y1={streak.y}\n              y2={streak.y}\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              className=\"stroke-foreground/10\"\n            />\n          );\n        })}\n      </g>\n\n      <g className={cn(STAGE_LABEL, \"text-muted-foreground tabular-nums\")}>\n        <AnimatePresence initial={false}>\n          {yTicks.map((value) => (\n            <motion.g\n              key={`y${formatShort(value)}`}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: EXIT }}\n              transition={{ duration: 0.15, ease: EASE_OUT }}\n            >\n              <line\n                x1={PAD.left}\n                x2={W - PAD.right}\n                y1={y(value)}\n                y2={y(value)}\n                mask={value === 1 ? undefined : `url(#${fill}-grid)`}\n                className={value === 1 ? \"stroke-border\" : \"stroke-foreground/[0.07]\"}\n              />\n              {/* Labels on the right, past the head, which stays at 82% across.\n                  Right-aligned, so a label grown for a narrow stage stays on it. */}\n              <text x={W - 2} y={y(value)} dy=\"0.32em\" textAnchor=\"end\" className=\"fill-current\">\n                {formatShort(value)}\n              </text>\n            </motion.g>\n          ))}\n          {xTicks.map((value) => (\n            // Motion reads `x` as a transform, so the position sits on a plain text.\n            <motion.g\n              key={`x${+value.toFixed(1)}`}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: EXIT }}\n              transition={{ duration: 0.15, ease: EASE_OUT }}\n            >\n              <text x={x(value)} y={H - 8} textAnchor=\"middle\" className=\"fill-current\">\n                {`${+value.toFixed(1)}s`}\n              </text>\n            </motion.g>\n          ))}\n        </AnimatePresence>\n      </g>\n\n      <AnimatePresence>\n        {targetLine ? (\n          // The auto target, ahead of the plane: a dashed line that fades in\n          // from the left like the grid, or a caret at the top while it is\n          // still above the axes. It fades out once it pays.\n          <motion.g\n            key=\"target\"\n            className=\"text-primary\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n          >\n            {targetLine.above ? null : (\n              <line\n                opacity={0.75}\n                x1={PAD.left}\n                x2={W - PAD.right}\n                y1={targetLine.y}\n                y2={targetLine.y}\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n                strokeDasharray=\"5 4\"\n                mask={`url(#${fill}-grid)`}\n              />\n            )}\n            <text\n              x={W - PAD.right - 4}\n              y={targetLine.above ? PAD.top + 4 : targetLine.y - 6}\n              textAnchor=\"end\"\n              className={cn(\"fill-current font-medium tabular-nums\", STAGE_LABEL, PRIMARY_TEXT)}\n            >\n              {`Auto ${targetLine.label}${targetLine.above ? \" ↑\" : \"\"}`}\n            </text>\n          </motion.g>\n        ) : null}\n      </AnimatePresence>\n\n      <g className={cn(\"transition-colors duration-150\", lost ? \"text-rose-500\" : \"text-primary\")}>\n        <path d={area} fill={`url(#${fill})`} />\n        <path d={line} fill=\"none\" stroke=\"currentColor\" strokeWidth={3} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </g>\n      <AnimatePresence>\n        {missed ? (\n          // After a cash out the plane's trail shows what was left on the\n          // table while it flies on; it clears at the crash so a finished\n          // graph doesn't keep a stray line behind the result.\n          <motion.path\n            key={`missed-${round}`}\n            d={missed}\n            fill=\"none\"\n            strokeWidth={2}\n            strokeDasharray=\"4 5\"\n            strokeLinecap=\"round\"\n            className=\"stroke-muted-foreground/50\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={{ duration: 0.15, ease: EASE_OUT }}\n          />\n        ) : null}\n      </AnimatePresence>\n\n      {marker ? (\n        <g transform={`translate(${marker.x.toFixed(2)},${marker.y.toFixed(2)})`}>\n          {reduce ? null : (\n            // The player bails out: a parachute opens at the cash point and\n            // drifts down, swaying, before it fades.\n            <motion.g\n              key={`chute-${round}`}\n              // Swings like a pendulum from the canopy, not round its middle.\n              style={{ transformBox: \"fill-box\", transformOrigin: \"50% 0%\" }}\n              initial={{ y: 2, rotate: -12, opacity: 0 }}\n              animate={{ y: 40, rotate: [-12, 9, -6, 4, 0], opacity: [0, 1, 1, 0] }}\n              transition={CHUTE}\n            >\n              <motion.g initial={{ scale: 0.6 }} animate={{ scale: 1 }} transition={SWAP}>\n                <Parachute />\n              </motion.g>\n            </motion.g>\n          )}\n          {/* The badge in the readout names the value; the curve marks where. */}\n          <motion.circle\n            key={round}\n            r={4.5}\n            strokeWidth={2.5}\n            className=\"fill-card stroke-primary\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={SWAP}\n          />\n        </g>\n      ) : null}\n\n      <g\n        transform={`translate(${head.x.toFixed(2)},${head.y.toFixed(2)})`}\n        className={cn(\n          \"transition-colors duration-150\",\n          // Red only for a loss, like the readout.\n          lost ? \"text-rose-500\" : cashed !== null ? \"text-muted-foreground\" : \"text-primary\",\n        )}\n      >\n        {crashed && !reduce ? (\n          <g key={`burst-${round}`}>\n            <motion.circle\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2}\n              vectorEffect=\"non-scaling-stroke\"\n              r={28}\n              initial={{ scale: 5 / 28, opacity: 0.7 }}\n              animate={{ scale: 1, opacity: 0 }}\n              transition={BURST}\n            />\n            {SHREDS.map((shred) => (\n              <motion.path\n                key={shred.id}\n                d=\"M0,-2.5L3,2L-3,2Z\"\n                fill=\"currentColor\"\n                initial={{ x: 0, y: 0, rotate: 0, opacity: 1 }}\n                animate={{ x: shred.x, y: shred.y, rotate: shred.spin, opacity: 0 }}\n                transition={BURST}\n              />\n            ))}\n          </g>\n        ) : null}\n        {/* Screen-space fall on the outside, the plane's bank inside it. */}\n        <motion.g\n          key={`plane-${round}`}\n          initial={false}\n          animate={\n            crashed\n              ? reduce\n                ? { opacity: 0 }\n                : { x: 16, y: 70, rotate: 150, scale: 0.75, opacity: [1, 1, 0] }\n              : { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }\n          }\n          transition={crashed && !reduce ? TUMBLE : { duration: 0.2, ease: EASE_OUT }}\n        >\n          <g transform={`rotate(${bank.toFixed(2)}) translate(0,-3)`}>\n            <PaperPlane />\n          </g>\n        </motion.g>\n        {crashed ? (\n          // Where the flight ended, left behind once the plane has fallen, so\n          // a finished graph still says how far it got.\n          <motion.g\n            key={`end-${round}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ ...SWAP, delay: reduce ? 0 : 0.35 }}\n          >\n            <circle r={7} fill=\"currentColor\" opacity={0.15} />\n            <path d=\"M-3,-3L3,3M3,-3L-3,3\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" />\n          </motion.g>\n        ) : null}\n      </g>\n    </svg>\n  );\n}\n\n// The crashed plane tumbles and drops out of the sky, accelerating.\nconst TUMBLE: Transition = { duration: 0.6, ease: EASE_IN, opacity: { duration: 0.6, times: [0, 0.6, 1] } };\n\n// Paper shreds thrown out by the crash, spread round the head and pulled\n// down a little, as if by gravity. Fixed, so every crash is the same shape.\nconst SHREDS = Array.from({ length: 7 }, (_, i) => {\n  const angle = ((-160 + i * 50 + (i % 2) * 14) * Math.PI) / 180;\n  const reach = 20 + (i % 3) * 8;\n  return {\n    id: i,\n    x: Math.cos(angle) * reach,\n    y: Math.sin(angle) * reach + 12,\n    spin: (i % 2 ? 1 : -1) * (140 + i * 30),\n  };\n});\n\n// Speed field points, scattered over the plot by fixed strides.\nconst PLOT_H = H - PAD.top - PAD.bottom;\nconst FAR = Array.from({ length: 22 }, (_, i) => ({ id: i, x: (i * 97.3) % W, y: PAD.top + ((i * 53.7) % PLOT_H) }));\nconst NEAR = Array.from({ length: 9 }, (_, i) => ({ id: i, x: (i * 151.9) % W, y: PAD.top + 8 + ((i * 71.3) % (PLOT_H - 16)) }));\n\n/** A folded paper plane, nose to the right, about 26 units long. */\nfunction PaperPlane() {\n  return (\n    <g>\n      <path d=\"M13,0L-4,-1L-8,6Z\" fill=\"currentColor\" opacity={0.55} />\n      <path d=\"M13,0L-11,-9L-4,-1Z\" fill=\"currentColor\" />\n      <path d=\"M13,0L-4,-1\" className=\"stroke-card/40\" strokeWidth={0.75} />\n    </g>\n  );\n}\n\n/**\n * The round's flight in miniature, on its own axes so a skip to a huge crash\n * still reads: the curve replays up to your cash out, runs on dashed to the\n * crash, and ends in a cross; a missed target is a dashed line above.\n */\nfunction MiniFlight({\n  crash,\n  cashed,\n  target,\n  reduce,\n}: CrashMiniatureProps) {\n  const top = Math.max(crash, target ?? 1, 1.01);\n  const end = timeAt(top);\n  const x = (seconds: number) => 4 + (seconds / end) * (MINI.width - 8);\n  const y = (value: number) => MINI.height - 4 - ((value - 1) / (top - 1)) * (MINI.height - 8);\n  const path = (from: number, to: number) => {\n    let d = \"\";\n    for (let i = 0; i <= 24; i++) {\n      const s = from + ((to - from) * i) / 24;\n      d += `${i === 0 ? \"M\" : \"L\"}${x(s).toFixed(2)},${y(growth(s)).toFixed(2)}`;\n    }\n    return d;\n  };\n  const crashT = timeAt(crash);\n  const cashT = cashed === null ? null : timeAt(cashed);\n  const draw = (delay: number) => ({\n    initial: { pathLength: reduce ? 1 : 0 },\n    animate: { pathLength: 1 },\n    transition: { ...MINI_REPLAY, delay: reduce ? 0 : delay },\n  });\n  return (\n    <svg aria-hidden=\"true\" viewBox={`0 0 ${MINI.width} ${MINI.height}`} width={MINI.width} height={MINI.height} className=\"shrink-0 overflow-visible\">\n      <line x1={4} x2={MINI.width - 4} y1={MINI.height - 4} y2={MINI.height - 4} className=\"stroke-border\" />\n      {target !== null ? (\n        <line\n          x1={4}\n          x2={MINI.width - 4}\n          y1={y(target)}\n          y2={y(target)}\n          strokeDasharray=\"2 3\"\n          className=\"stroke-muted-foreground/60\"\n        />\n      ) : null}\n      {cashT === null ? (\n        <motion.path d={path(0, crashT)} fill=\"none\" strokeWidth={2} strokeLinecap=\"round\" className=\"stroke-rose-500\" {...draw(0.3)} />\n      ) : (\n        <>\n          <motion.path d={path(0, cashT)} fill=\"none\" strokeWidth={2} strokeLinecap=\"round\" className=\"stroke-primary\" {...draw(0.3)} />\n          <motion.path\n            d={path(cashT, crashT)}\n            fill=\"none\"\n            strokeWidth={1.5}\n            strokeDasharray=\"2 3\"\n            strokeLinecap=\"round\"\n            className=\"stroke-muted-foreground/60\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{ duration: 0.25, ease: EASE_OUT, delay: reduce ? 0 : 0.8 }}\n          />\n          <circle cx={x(cashT)} cy={y(cashed ?? 1)} r={3} strokeWidth={1.5} className=\"fill-card stroke-primary\" />\n        </>\n      )}\n      <motion.path\n        d={`M${x(crashT) - 2.5},${y(crash) - 2.5}l5,5m0,-5l-5,5`}\n        strokeWidth={1.75}\n        strokeLinecap=\"round\"\n        className=\"stroke-rose-500\"\n        initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25 }}\n        animate={{ opacity: 1, scale: 1 }}\n        style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n        transition={{ ...SWAP, delay: reduce ? 0 : 0.85 }}\n      />\n    </svg>\n  );\n}\n"},{"path":"components/motion/crash/crash-parts.tsx","type":"util","content":"\"use client\";\n\n// Pieces every Crash design shares, so each design only draws its stage.\n\nimport { Check, History as HistoryIcon, Volume2, VolumeX, X } from \"lucide-react\";\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\nimport { memo, type ReactNode, type Ref, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { ActionSwapCascadeIcon } from \"@/components/motion/action-swap-cascade\";\nimport { Button } from \"@/components/motion/button\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/motion/select\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { PresenceGate, type PresenceGateRenderProps } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  type CrashGame,\n  EXIT,\n  formatMultiplier,\n  formatShort,\n  growth,\n  heat,\n  PULSE,\n  SHAKE,\n  SWAP,\n  useStable,\n} from \"./use-crash-game\";\n\n/**\n * Primary for small text: a shade darker on light surfaces and lighter on\n * dark ones, so 10-12px labels keep 4.5:1. Large text and marks use plain\n * primary.\n */\nexport const PRIMARY_TEXT =\n  \"text-[color:color-mix(in_oklab,var(--color-primary)_75%,black)] dark:text-[color:color-mix(in_oklab,var(--color-primary)_80%,white)]\";\n\n// Readout and history colours per heat: neutral, then primary, then amber.\nconst HEAT_TEXT = {\n  cool: \"text-foreground\",\n  warm: \"text-primary\",\n  hot: \"text-amber-700 dark:text-amber-400\",\n} as const;\nconst HEAT_PILL = {\n  cool: \"bg-foreground/5 text-muted-foreground\",\n  warm: `bg-primary/10 ${PRIMARY_TEXT}`,\n  hot: \"bg-amber-500/15 text-amber-700 dark:text-amber-400\",\n} as const;\n\n/** The width of the viewBox an SVG stage draws in. */\nexport const STAGE_W = 400;\n/**\n * SVG stage labels: 10px on screen however narrow the card. A viewBox scales\n * its text with the stage, so the shell sets --crash-label in viewBox units.\n * The server render has no width to go on and draws them at 10 units.\n */\nexport const STAGE_LABEL = \"text-[length:var(--crash-label,10px)]\";\n\n/**\n * The frame every design sits in: one card holding the recent rounds and\n * sound, the stage with the readout pinned top left, and the controls. Each\n * design draws only its stage, which must keep the top-left corner clear.\n */\nexport function CrashShell({\n  game,\n  \"aria-label\": ariaLabel,\n  className,\n  children,\n  miniature,\n}: {\n  game: CrashGame;\n  \"aria-label\": string;\n  className?: string;\n  children: ReactNode;\n  /** The design's own replay of the round on the result ticket. */\n  miniature?: (props: CrashMiniatureProps) => ReactNode;\n}) {\n  const mainRef = useRef<HTMLButtonElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  // Written straight to the style, so a resize doesn't re-render the game.\n  useLayoutEffect(() => {\n    const node = stageRef.current;\n    if (!node) return;\n    const measure = () => {\n      const width = node.clientWidth;\n      if (width > 0) node.style.setProperty(\"--crash-label\", `${Math.max(10, (10 * STAGE_W) / width)}px`);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // The ticket opens per round once the crash has played; tapping the stage\n  // or Escape puts it away, and the next round clears it.\n  const [openRound, setOpenRound] = useState<number | null>(null);\n  const resultShown = game.crashed && openRound === game.round;\n  const { crashed, round } = game;\n  // Read when the round ends, so a reduced-motion change can't reopen a closed ticket.\n  const ticketDelay = useStable(() => (game.reduce ? 0.15 : TICKET_DELAY) * 1000);\n  useEffect(() => {\n    if (!crashed) return;\n    // A timer, not an animation callback, so a cut-short animation can't keep it shut.\n    // Focus stays where it is: nothing focusable sits under the ticket, and\n    // the main button, which now says Play again, kept focus through the round.\n    const id = setTimeout(() => setOpenRound(round), ticketDelay());\n    return () => clearTimeout(id);\n  }, [crashed, round, ticketDelay]);\n  const closeResult = () => {\n    setOpenRound(null);\n    mainRef.current?.focus();\n  };\n\n  return (\n    <section\n      aria-label={ariaLabel}\n      // Focusable by script and click only: a tap on the stage keeps focus in\n      // the game, so Escape still reaches the ticket.\n      tabIndex={-1}\n      className={cn(\"w-full max-w-md outline-none\", className)}\n      onKeyDown={(event) => {\n        if (event.key !== \"Escape\" || !resultShown) return;\n        // An open Auto menu takes this Escape (it listens on window, after us).\n        if (event.target instanceof Element && event.target.closest('[role=\"listbox\"],[aria-expanded=\"true\"]')) return;\n        // Escape is used up here; an enclosing dialog must not close too.\n        event.stopPropagation();\n        closeResult();\n      }}\n    >\n      {/* 24px card corners = 12px stage and control corners + 12px padding. */}\n      <div className=\"flex flex-col gap-3 rounded-3xl border border-border p-3\">\n        <div className=\"flex h-8 items-center gap-2 pl-1\">\n          <HistoryIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" strokeWidth={2} />\n          {/* The game changes every frame; these take only what they show, so they skip those renders. */}\n          <CrashHistory history={game.history} reduce={game.reduce} className=\"min-w-0 flex-1\" />\n          <SoundToggle on={game.soundOn} onToggle={game.toggleSound} />\n        </div>\n        {/* A 10:7 stage in every design, so switching between them never\n            jumps. A container: its width also fixes its height. */}\n        <div ref={stageRef} className=\"@container relative aspect-[10/7] overflow-hidden rounded-xl bg-muted/40\">\n          {children}\n          <div className=\"pointer-events-none absolute top-4 left-4\">\n            <CrashEyebrow game={game} />\n            {/* Smaller on narrow stages, so the readout leaves the design room. */}\n            <CrashMultiplier game={game} className=\"mt-1 text-3xl @min-[20rem]:text-4xl [&>span]:origin-left\" />\n            <CrashStatus game={game} className=\"mt-2\" />\n          </div>\n          <AnimatePresence>\n            {resultShown ? (\n              <PresenceGate key=\"result\">\n                {({ gate }) => <ResultTicket game={game} gate={gate} onClose={closeResult} miniature={miniature} />}\n              </PresenceGate>\n            ) : null}\n          </AnimatePresence>\n        </div>\n        <CrashControls game={game} mainRef={mainRef} />\n      </div>\n      <CrashAnnouncer game={game} />\n    </section>\n  );\n}\n\n// The ticket rises once each design's crash (tumble, pop, topple: 0.6s to\n// 0.75s) has mostly played, the same receipt Mines ends on: a stub notched at a\n// dashed tear line, the outcome above, the round's figures and a miniature\n// of the flight below.\nconst TICKET_DELAY = 0.7;\n// Rises with a little settle (a rare, end-of-round moment), leaves quickly.\nconst CARD_IN = { type: \"spring\", duration: 0.4, bounce: 0.15 } as const;\nconst CARD_OUT = { duration: 0.15, ease: EASE_OUT } as const;\n// Inside, chunks arrive in reading order, then the flight replays.\nconst CHUNK = { duration: 0.25, ease: EASE_OUT } as const;\n// The tear line sits this far down; the side notches are cut there.\nconst TEAR = 104;\n// A drop-shadow on the ticket's wrapper: the notch mask would clip a\n// box-shadow on the ticket itself.\nconst TICKET_SHADOW = \"[filter:drop-shadow(0_1px_1px_rgb(0_0_0/0.12))_drop-shadow(0_12px_16px_rgb(0_0_0/0.3))]\";\nconst NOTCH_MASK = `radial-gradient(circle 9px at 0 ${TEAR}px, transparent 98%, black), radial-gradient(circle 9px at 100% ${TEAR}px, transparent 98%, black)`;\n\n/**\n * The end-of-round receipt over a dimmed stage. It has no buttons: the main\n * action already says Play again, and tapping the stage (or Escape) puts it\n * away to look at the flight.\n */\nfunction ResultTicket({\n  game,\n  gate,\n  onClose,\n  miniature,\n}: {\n  game: CrashGame;\n  gate: PresenceGateRenderProps[\"gate\"];\n  onClose: () => void;\n  miniature?: (props: CrashMiniatureProps) => ReactNode;\n}) {\n  const { reduce } = game;\n  return (\n    <>\n      <motion.button\n        type=\"button\"\n        aria-label=\"Hide result and show the flight\"\n        onClick={onClose}\n        {...gate}\n        className=\"absolute inset-0 z-20 cursor-default bg-background/55 outline-none backdrop-blur-[2px] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: CARD_OUT }}\n        transition={{ duration: 0.2, ease: EASE_OUT }}\n      />\n      <div inert={gate.inert} className=\"pointer-events-none absolute inset-0 z-20 grid place-items-center p-4\">\n        <motion.div\n          role=\"group\"\n          aria-label=\"Round result\"\n          // Sized here: the ticket is a size container, so it can't take its\n          // width from its contents.\n          className={cn(\"w-full max-w-[17rem]\", TICKET_SHADOW)}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.96 }}\n          animate={{ opacity: 1, y: 0, scale: 1 }}\n          exit={reduce ? { opacity: 0, transition: CARD_OUT } : { opacity: 0, y: 8, scale: 0.98, transition: CARD_OUT }}\n          transition={reduce ? { duration: 0.2 } : CARD_IN}\n        >\n          <Ticket game={game} miniature={miniature} />\n        </motion.div>\n      </div>\n    </>\n  );\n}\n\nfunction Chunk({ delay, reduce, className, children }: { delay: number; reduce: boolean; className?: string; children: ReactNode }) {\n  return (\n    <motion.div\n      className={className}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ ...CHUNK, delay: reduce ? 0 : delay }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction Ticket({ game, miniature }: { game: CrashGame; miniature?: (props: CrashMiniatureProps) => ReactNode }) {\n  const { cashed, multiplier: crash, roundAuto, reduce, t } = game;\n  const won = cashed !== null;\n  return (\n    <div\n      // The stub is about 200px tall; a short stage (its width sets its\n      // height) scales it down rather than clipping it.\n      className=\"@container w-full rounded-2xl bg-card text-foreground @max-[17rem]:scale-[0.72] @min-[17rem]:@max-[21rem]:scale-[0.85]\"\n      style={{ WebkitMaskImage: NOTCH_MASK, WebkitMaskComposite: \"source-in\", maskImage: NOTCH_MASK, maskComposite: \"intersect\" }}\n    >\n      <div\n        className={cn(\n          \"flex flex-col items-center justify-center gap-1 rounded-t-2xl px-5 text-center\",\n          won ? \"bg-primary/10\" : \"bg-rose-500/10\",\n        )}\n        style={{ height: TEAR }}\n      >\n        <Chunk delay={0.05} reduce={reduce}>\n          <p className={cn(\"text-xs font-medium tracking-wide uppercase\", won ? PRIMARY_TEXT : \"text-rose-700 dark:text-rose-400\")}>\n            {won ? \"Cashed out\" : \"Crashed\"}\n          </p>\n        </Chunk>\n        <Chunk delay={0.1} reduce={reduce}>\n          <p className={cn(\"text-3xl font-semibold\", !won && \"text-muted-foreground line-through decoration-2\")}>\n            {won ? (\n              <NumberTicker\n                value={cashed}\n                decimals={2}\n                format={(value) => formatMultiplier(value).slice(0, -1)}\n                suffix=\"×\"\n                startOnView={false}\n                duration={0.6}\n                stagger={0.05}\n              />\n            ) : (\n              // The multiplier is lost: struck through, as Mines does. A plain\n              // span, since a line-through skips the ticker's inline-block digits.\n              <span className=\"tabular-nums\">{formatMultiplier(crash)}</span>\n            )}\n          </p>\n        </Chunk>\n      </div>\n      <div aria-hidden=\"true\" className=\"mx-4 border-t-2 border-dashed border-border\" />\n      <div className=\"flex items-center gap-4 px-5 pt-4 pb-5\">\n        <Chunk delay={0.18} reduce={reduce} className=\"flex-1\">\n          <dl className=\"grid gap-1.5 text-xs\">\n            <Row label=\"Crashed at\" value={formatMultiplier(crash)} />\n            <Row label=\"Flight\" value={`${t.toFixed(1)}s`} />\n            {won && game.fair ? (\n              // The built-in round reaches m with a chance of 1 in m, as with\n              // Mines' odds; a custom crash point makes no such promise.\n              <Row label=\"Odds beaten\" value={`1 in ${formatMultiplier(cashed).slice(0, -1)}`} />\n            ) : won ? (\n              <Row label=\"You left at\" value={formatMultiplier(cashed)} />\n            ) : (\n              <Row label=\"Auto target\" value={roundAuto === null ? \"Off\" : formatShort(roundAuto)} />\n            )}\n          </dl>\n        </Chunk>\n        {/* A narrow ticket keeps the figures on one line each and drops the miniature. */}\n        {miniature ? (\n          <Chunk delay={0.24} reduce={reduce} className=\"@max-[15rem]:hidden\">\n            {miniature({ crash, cashed, cashedT: game.cashedT, target: won ? null : roundAuto, reduce })}\n          </Chunk>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nfunction Row({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"flex justify-between gap-2 whitespace-nowrap\">\n      <dt className=\"text-muted-foreground\">{label}</dt>\n      <dd className=\"font-medium tabular-nums\">{value}</dd>\n    </div>\n  );\n}\n\n/** What a design's ticket miniature replays: the round's crash, your cash out and a missed target. */\nexport interface CrashMiniatureProps {\n  crash: number;\n  cashed: number | null;\n  /** Seconds into the flight at the cash out, for designs that count steps. */\n  cashedT: number | null;\n  /** The auto target, only when the round crashed short of it. */\n  target: number | null;\n  reduce: boolean;\n}\n\n// The parachute drifting down from a cash out is a once-a-round moment, so it\n// may take its time.\nexport const CHUTE: Transition = {\n  y: { duration: 2.2, ease: EASE_OUT },\n  rotate: { duration: 2.2, ease: EASE_IN_OUT },\n  opacity: { duration: 2.2, times: [0, 0.08, 0.7, 1] },\n};\n\nexport const clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\n/** Wraps scenery that streams past into a loop `span` long. */\nexport const wrap = (value: number, span: number) => ((value % span) + span) % span;\n\n/** A tick step of 1, 2 or 5 times a power of ten, at least `min`. */\nexport function niceAtLeast(min: number) {\n  const power = 10 ** Math.floor(Math.log10(min));\n  // toPrecision drops float noise (0.30000000000000004), so steps add up cleanly.\n  return Number((([1, 2, 5, 10].find((unit) => unit * power >= min) ?? 10) * power).toPrecision(12));\n}\n\n/** The parachute that opens at a cash out: a scalloped canopy, three lines and the player. */\nexport function Parachute() {\n  return (\n    <g className=\"text-primary\">\n      <path d=\"M-9,0Q-9,-11 0,-11Q9,-11 9,0Q6,-3 3,0Q0,-3 -3,0Q-6,-3 -9,0Z\" fill=\"currentColor\" />\n      <path d=\"M-9,0L0,10M0,0L0,10M9,0L0,10\" stroke=\"currentColor\" strokeWidth={0.75} opacity={0.6} fill=\"none\" />\n      <circle cy={11.5} r={2} fill=\"currentColor\" />\n    </g>\n  );\n}\n\n/** Every miniature is 88 × 48, like Mines' mini map. */\nexport const MINI = { width: 88, height: 48 } as const;\n// The replay starts once the ticket's figures are in, and draws over 0.6s.\nexport const MINI_REPLAY = { delay: 0.3, duration: 0.6, ease: EASE_OUT } as const;\n\n/** The sound toggle: a quiet 32px icon button in the card's header. */\nconst SoundToggle = memo(function SoundToggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {\n  return (\n    // aria-label names the button; the tooltip shows the same label on hover\n    // and focus. Tooltip's asLabel would hydrate differently from the server.\n    <Tooltip content=\"Sound\" side=\"top\">\n      <Button\n        variant=\"ghost\"\n        onClick={onToggle}\n        aria-label=\"Sound\"\n        aria-pressed={on}\n        pressScale={0.96}\n        className={cn(\"size-8 shrink-0 rounded-xl px-0\", on && \"text-foreground\")}\n      >\n        <ActionSwapCascadeIcon value={on ? \"on\" : \"off\"} className=\"size-4\">\n          {on ? (\n            <Volume2 aria-hidden=\"true\" className=\"size-4\" />\n          ) : (\n            <VolumeX aria-hidden=\"true\" className=\"size-4\" />\n          )}\n        </ActionSwapCascadeIcon>\n      </Button>\n    </Tooltip>\n  );\n});\n\n/** The Auto menu and the main action, in one 40px row with 12px corners. */\nfunction CrashControls({ game, mainRef }: { game: CrashGame; mainRef: Ref<HTMLButtonElement> }) {\n  return (\n    <div className=\"flex items-center gap-2\">\n      <AutoMenu\n        auto={game.auto}\n        options={game.autoOptions}\n        onChange={game.setAuto}\n        disabled={game.running || game.starting}\n      />\n\n      <Button\n        ref={mainRef}\n        className=\"relative h-10 flex-1 overflow-hidden rounded-xl tabular-nums aria-disabled:cursor-wait aria-disabled:opacity-60 aria-disabled:hover:bg-primary\"\n        // aria-disabled, not disabled: the button keeps keyboard focus while the\n        // crash point is on its way.\n        aria-disabled={game.actionBusy || undefined}\n        aria-busy={game.starting || undefined}\n        // No press or hover response while it can't act. Passed only then:\n        // Button spreads props last, so an explicit undefined would drop its hover.\n        pressScale={game.actionBusy ? 1 : 0.96}\n        {...(game.actionBusy ? { whileHover: {} } : {})}\n        onClick={game.act}\n        // A held Enter repeats clicks; it would cash out, skip and start\n        // rounds on its own.\n        onKeyDown={(event) => {\n          if (event.repeat && event.key === \"Enter\") event.preventDefault();\n        }}\n      >\n        {game.actionLabel}\n        {/* Progress toward the auto target, on the same log scale the\n            multiplier grows on, so it fills at a steady pace. */}\n        {game.running && game.target !== null ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[3px] origin-left bg-primary-foreground/45\"\n            style={{ transform: `scaleX(${Math.min(1, Math.log(growth(game.t)) / Math.log(game.target)).toFixed(4)})` }}\n          />\n        ) : null}\n      </Button>\n    </div>\n  );\n}\n\n/** The Auto cash-out menu. */\nconst AutoMenu = memo(function AutoMenu({\n  auto,\n  options,\n  onChange,\n  disabled,\n}: {\n  auto: number | null;\n  options: number[];\n  onChange: (value: string) => void;\n  disabled: boolean;\n}) {\n  return (\n    <Select value={auto === null ? \"off\" : String(auto)} onValueChange={onChange} disabled={disabled} className=\"w-32 shrink-0\">\n      <SelectTrigger className=\"h-10 tabular-nums\">\n        <span className=\"flex items-baseline gap-1.5\">\n          <span className=\"text-xs text-muted-foreground\">Auto</span>\n          {/* Options register after mount; the placeholder covers the server render. */}\n          <SelectValue placeholder={auto === null ? \"Off\" : formatShort(auto)} />\n        </span>\n      </SelectTrigger>\n      <SelectContent>\n        <SelectItem value=\"off\">Off</SelectItem>\n        {options.map((step) => (\n          <SelectItem key={step} value={String(step)}>\n            {formatShort(step)}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n});\n\n/** Recent crash points, newest first, sliding in from the left. */\nconst CrashHistory = memo(function CrashHistory({\n  history,\n  reduce,\n  className,\n}: {\n  history: CrashGame[\"history\"];\n  reduce: boolean;\n  className?: string;\n}) {\n  return (\n    <ul\n      aria-label=\"Recent crashes\"\n      className={cn(\n        \"flex items-center gap-1.5 overflow-hidden [mask-image:linear-gradient(to_right,black_85%,transparent)]\",\n        className,\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {history.length === 0 ? (\n          <motion.li key=\"empty\" className=\"text-xs text-muted-foreground\" initial={false} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: EXIT }}>\n            Recent rounds show here\n          </motion.li>\n        ) : (\n          history.map((entry) => (\n            <motion.li\n              key={entry.id}\n              layout={!reduce}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, x: -8 }}\n              animate={{ opacity: 1, scale: 1, x: 0 }}\n              exit={{ opacity: 0, transition: EXIT }}\n              transition={reduce ? { duration: 0.15 } : SPRING_LAYOUT}\n              className={cn(\n                \"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums\",\n                HEAT_PILL[heat(entry.value)],\n              )}\n            >\n              {formatMultiplier(entry.value)}\n            </motion.li>\n          ))\n        )}\n      </AnimatePresence>\n    </ul>\n  );\n});\n\n/**\n * The live multiplier, warming as it climbs, that becomes the result at the\n * crash: red and shaking on a loss, your cash out in primary on a win.\n */\nfunction CrashMultiplier({ game, className }: { game: CrashGame; className?: string }) {\n  const { lost, crashed, cashed, reduce, pulse, round } = game;\n  // After a crash you cashed out of, the number that counts is yours.\n  const final = crashed && cashed !== null;\n  return (\n    <motion.p\n      key={round}\n      className={cn(\n        \"font-semibold tracking-tight tabular-nums transition-colors duration-150\",\n        className,\n        // Warms with the climb, so size reads without the digits; the result overrides.\n        lost ? \"text-rose-600 dark:text-rose-400\" : final ? \"text-primary\" : HEAT_TEXT[heat(game.multiplier)],\n      )}\n      animate={lost && !reduce ? { x: [0, -6, 6, -3, 3, 0] } : { x: 0 }}\n      transition={SHAKE}\n    >\n      {/* Remounts per milestone for a quick swell, and once more when the\n          crash hands the readout your cash out, which blurs in. */}\n      <motion.span\n        key={final ? \"final\" : pulse}\n        className=\"inline-block\"\n        initial={\n          reduce\n            ? final\n              ? { opacity: 0 }\n              : false\n            : final\n              ? { opacity: 0, scale: 0.96, filter: \"blur(4px)\" }\n              : pulse > 0\n                ? { scale: 1.08 }\n                : false\n        }\n        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n        transition={final ? SWAP : PULSE}\n      >\n        {formatMultiplier(final ? cashed : game.multiplier)}\n      </motion.span>\n    </motion.p>\n  );\n}\n\n/**\n * The line under the multiplier: a hint before and during the flight, a\n * solid badge from the cash out until the crash, then the round's context.\n */\nfunction CrashStatus({ game, className }: { game: CrashGame; className?: string }) {\n  const { cashed, crashed, status, reduce } = game;\n  return (\n    <div className={cn(\"flex h-6 items-center\", className)}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {cashed !== null && !crashed ? (\n          <motion.p\n            key=\"cashed\"\n            className=\"inline-flex h-6 items-center gap-1 rounded-full bg-primary pr-2.5 pl-2 text-xs font-medium text-primary-foreground tabular-nums\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={SWAP}\n          >\n            <Check aria-hidden=\"true\" className=\"size-3.5\" strokeWidth={2.5} />\n            Cashed out {formatMultiplier(cashed)}\n          </motion.p>\n        ) : (\n          <motion.p\n            key={status}\n            className=\"text-xs text-muted-foreground tabular-nums\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={SWAP}\n          >\n            {status}\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/**\n * The small uppercase state label: Ready, In flight, then the result:\n * Cashed out with a check, or Crashed with a cross.\n */\nfunction CrashEyebrow({ game, className }: { game: CrashGame; className?: string }) {\n  const { lost, crashed, running, cashed, reduce } = game;\n  const icon = crashed ? (lost ? \"lost\" : \"won\") : running && cashed === null ? \"live\" : null;\n  return (\n    <p\n      className={cn(\n        \"flex h-4 items-center gap-1.5 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors duration-150\",\n        lost ? \"text-rose-700 dark:text-rose-400\" : crashed ? PRIMARY_TEXT : \"text-muted-foreground\",\n        className,\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {icon ? (\n          <motion.span\n            key={icon}\n            aria-hidden=\"true\"\n            className=\"grid size-3.5 place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={SWAP}\n          >\n            {icon === \"live\" ? (\n              <span className=\"size-1.5 rounded-full bg-primary\" />\n            ) : icon === \"won\" ? (\n              <Check className=\"size-3.5\" strokeWidth={3} />\n            ) : (\n              <X className=\"size-3.5\" strokeWidth={3} />\n            )}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n      {game.eyebrow}\n    </p>\n  );\n}\n\n/** The polite live region for starts, milestones, cash outs and crashes. */\nfunction CrashAnnouncer({ game }: { game: CrashGame }) {\n  return (\n    <p aria-live=\"polite\" className=\"sr-only\">\n      {game.announcement}\n    </p>\n  );\n}\n"},{"path":"components/motion/crash/use-crash-game.ts","type":"util","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { createGameSounds, type GameSounds } from \"@/lib/game-sounds\";\n\n// The game every Crash design shares: the clock, the crash point, cashing out,\n// milestones, history, sound and announcements. A design only draws it.\n\nexport interface CrashProps {\n  /** Multiplier to cash out at automatically (controlled), or null for manual play. Applies from the next round. */\n  autoCashOut?: number | null;\n  /** Initial auto cash-out when uncontrolled. Default null (off). */\n  defaultAutoCashOut?: number | null;\n  /** Called when the Auto menu changes between rounds. */\n  onAutoCashOutChange?: (autoCashOut: number | null) => void;\n  /**\n   * Decides each round's crash point, called once when the round starts.\n   * Return a number, or a Promise for one (a server round, a provably fair\n   * hash): the main button shows Starting… until it settles, and a rejection\n   * returns to Start. Values below 1 or not finite become 1, and values are\n   * floored to two decimals. Default `crashPoint()`. Ignored when `crashAt`\n   * is passed.\n   */\n  getCrashPoint?: () => number | Promise<number>;\n  /**\n   * Live rounds (controlled), for a server that keeps the crash point secret\n   * until it happens: the round `onStart` was given and its crash point once\n   * revealed, or null. A round flies open-ended until its own crash point\n   * arrives; a value for any other round is ignored, so a reveal from last\n   * round can't end this one. A value at or below the current multiplier\n   * crashes at once, at that value, and a cash out above it never happened:\n   * the round ends lost and `onCashOut` is not called. A value still ahead\n   * means the display is behind the server: the flight runs out to it\n   * quickly and takes no more hand cash outs (an auto target below it still\n   * pays). Whether a round is live is fixed when it starts; dropping\n   * `crashAt` mid-round ends it at the current multiplier.\n   */\n  crashAt?: CrashReveal | null;\n  /** Called when the player starts a round, before its crash point is known, with the round's number (unique on the page). */\n  onStart?: (round: number) => void;\n  /**\n   * Called when the player cashes out, by hand or automatically. In a live\n   * round it waits until the crash point is revealed, so it only fires for a\n   * cash out that stands.\n   */\n  onCashOut?: (multiplier: number) => void;\n  /** Called when the round crashes, with the cash-out multiplier or null if the player was still in. */\n  onCrash?: (crashPoint: number, cashedOut: number | null) => void;\n  /** Play synthesized sounds (controlled): a climbing chime at each milestone (2× to 5×, then 10×, 20×, 50×…), a cash-out arpeggio and a crash blast. */\n  sound?: boolean;\n  /** Initial sound state when uncontrolled. Default false. */\n  defaultSound?: boolean;\n  /** Called when the sound button is toggled. */\n  onSoundChange?: (sound: boolean) => void;\n  /** Accessible name for the game. Default \"Crash\". */\n  \"aria-label\"?: string;\n  className?: string;\n}\n\nexport type CrashPhase = \"idle\" | \"starting\" | \"running\" | \"crashed\";\n\n/** A live round's crash point, tagged with the round `onStart` was given. */\nexport interface CrashReveal {\n  round: number;\n  value: number;\n}\n\n// The multiplier grows exponentially: 2× after 5.8s, 10× after 19s.\nexport const GROWTH = 0.12;\nexport const growth = (seconds: number) => Math.exp(GROWTH * seconds);\nexport const timeAt = (multiplier: number) => Math.log(multiplier) / GROWTH;\nconst MAX_CRASH = 1_000_000;\n// The highest multiplier a round can show: a passed crash point settles no\n// higher, and an open live round stops climbing here (about 32 minutes in)\n// rather than overflowing to Infinity.\nconst CEILING = 1e100;\n\n/**\n * A crash point from one uniform draw in [0, 1): the round reaches `m` with\n * probability (1 − houseEdge) / m, floored to two decimals and capped at\n * 1,000,000×. The default edge of 0 is a fair game. Pass your own `random`\n * for a seeded or provably fair round, for example the first 52 bits of a\n * round's HMAC-SHA256: `crashPoint(() => parseInt(hash.slice(0, 13), 16) / 2 ** 52, 0.01)`.\n */\nexport function crashPoint(random: () => number = Math.random, houseEdge = 0) {\n  const edge = Math.min(Math.max(houseEdge, 0), 1);\n  return Math.min(MAX_CRASH, Math.max(1, Math.floor((100 * (1 - edge)) / (1 - random())) / 100));\n}\n\n/**\n * A passed crash point as the game plays it: at least 1 and floored to two\n * decimals, the way crash games display them. The round ends on exactly the\n * caller's value up to the 1e100 ceiling; `crashPoint()` caps at 1,000,000.\n * Anything not finite plays as 1.\n */\nconst settle = (point: number) => {\n  if (!Number.isFinite(point)) return 1;\n  return Math.max(1, floor2(Math.min(point, CEILING)));\n};\n\n/**\n * Multipliers announced (and chimed) while the player is in: 2× to 5×, then\n * 10×, 20×, 50×, 100× and on, so a long flight stays quiet enough to follow.\n */\nexport function milestone(index: number) {\n  if (index < 4) return index + 2;\n  return [10, 20, 50][(index - 4) % 3] * 10 ** Math.floor((index - 4) / 3);\n}\n\n// Auto cash-out targets offered in the menu, after Off.\nexport const AUTO_STEPS = [1.5, 2, 3, 5, 10, 25, 100];\n\n// Skipping after a cash out runs the rest of the flight in at most this long.\nconst SKIP_TIME = 0.8;\n// Presses this soon after a start, a cash-out press or a crash are the same\n// press repeated (a double tap, a mash), not the next action (ms).\nconst PRESS_GUARD = 400;\n// Round numbers, unique on the page, so a live reveal kept by the parent\n// across a remount can't match a fresh round.\nlet rounds = 0;\nconst HISTORY = 12;\n\n// Motion shared by every design.\n// A loss shakes the readout once, quickly; a burst spreads from the crash.\nexport const SHAKE = { duration: 0.3, ease: EASE_OUT } as const;\nexport const BURST = { duration: 0.45, ease: EASE_OUT } as const;\n// Status lines and markers swap on a spring with no bounce.\nexport const SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// A milestone swells the readout and lets it settle with a little give.\nexport const PULSE = { type: \"spring\", duration: 0.35, bounce: 0.3 } as const;\n// Exits are shorter than entrances.\nexport const EXIT = { duration: 0.12, ease: EASE_OUT } as const;\n\nexport function useCrashGame({\n  autoCashOut: autoProp,\n  defaultAutoCashOut = null,\n  onAutoCashOutChange,\n  getCrashPoint,\n  crashAt,\n  onStart,\n  onCashOut,\n  onCrash,\n  sound,\n  defaultSound = false,\n  onSoundChange,\n}: Omit<CrashProps, \"aria-label\" | \"className\">) {\n  // Live rounds: the crash point arrives through `crashAt` while flying.\n  const live = crashAt !== undefined;\n  const reduce = useReducedMotion() ?? false;\n  const [internalAuto, setInternalAuto] = useState(defaultAutoCashOut);\n  const rawAuto = autoProp === undefined ? internalAuto : autoProp;\n  const auto = rawAuto === null || !Number.isFinite(rawAuto) ? null : Math.max(1.01, floor2(rawAuto));\n  const [phase, setPhase] = useState<CrashPhase>(\"idle\");\n  const [round, setRound] = useState(0);\n  const [elapsed, setElapsed] = useState(0);\n  const [cashed, setCashed] = useState<number | null>(null);\n  // Seconds into the flight at the cash out. The value is floored to two\n  // decimals, so a design that counts steps (Tower's blocks) needs the time.\n  const [cashedT, setCashedT] = useState<number | null>(null);\n  const [crashedAt, setCrashedAt] = useState<number | null>(null);\n  const [history, setHistory] = useState<{ id: number; value: number }[]>([]);\n  const [announcement, setAnnouncement] = useState(\"\");\n  // Counts milestones passed this round; each one pulses the readout.\n  const [pulse, setPulse] = useState(0);\n  // The auto target locked in when the round started.\n  const [roundAuto, setRoundAuto] = useState<number | null>(null);\n  // The flight lives in a ref so each frame can read and advance it. The\n  // crash point stays out of state until it is reached. `milestone` indexes\n  // the next multiplier to announce. `pressTime` is the last take-off,\n  // cash-out press or crash, in performance.now() ms. `held` is a live cash out waiting on\n  // the crash point before `onCashOut` hears of it.\n  const flight = useRef({\n    round: 0,\n    live: false,\n    t: 0,\n    rate: 1,\n    crash: 1,\n    auto: null as number | null,\n    milestone: 0,\n    pressTime: Number.NEGATIVE_INFINITY,\n    held: false,\n  });\n  const cashedRef = useRef<number | null>(null);\n  // A round is in progress from Start until it crashes or fails to start. A\n  // ref, not render state: two clicks in one frame both see the old phase,\n  // so a second start would ask for a second crash point (a second server\n  // round), and a click landing just after the crash frame would cash out a\n  // round that has already ended.\n  const busy = useRef(false);\n  // Drops a crash point that settles after unmount. Set in the effect body\n  // as well as cleared in its cleanup, so StrictMode's dev remount turns it\n  // back on.\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const [internalSound, setInternalSound] = useState(defaultSound);\n  const soundOn = sound ?? internalSound;\n  const player = useRef<GameSounds | null>(null);\n  const getPlayer = () => {\n    if (!player.current) player.current = createGameSounds();\n    return player.current;\n  };\n  useEffect(\n    () => () => {\n      player.current?.dispose();\n      player.current = null;\n    },\n    [],\n  );\n\n  const running = phase === \"running\";\n  const starting = phase === \"starting\";\n  const crashed = phase === \"crashed\";\n  // Unknown only in a live round the server hasn't ended yet. From the\n  // round's own mode, which a prop change mid-round doesn't move.\n  const roundLive = flight.current.live;\n  const crashKnown = !roundLive || crashAt?.round === round;\n  // A live round the server has ended while the display catches up.\n  const overtaken = running && roundLive && crashKnown;\n  const t = crashed && crashedAt !== null ? timeAt(crashedAt) : elapsed;\n  const multiplier = crashed && crashedAt !== null ? crashedAt : floor2(growth(t));\n\n  const cashOut = (value: number, time: number) => {\n    if (!busy.current || cashedRef.current !== null) return;\n    cashedRef.current = value;\n    setCashed(value);\n    setCashedT(time);\n    setAnnouncement(`Cashed out at ${formatMultiplier(value)}.`);\n    if (soundOn) getPlayer().cashOut();\n    // A live cash out stands only if the crash point turns out above it.\n    if (Number.isFinite(flight.current.crash)) onCashOut?.(value);\n    else flight.current.held = true;\n  };\n\n  // Takes off once the crash point is settled (Infinity: not yet revealed).\n  const fly = (id: number, crash: number) => {\n    flight.current = {\n      round: id,\n      live,\n      t: 0,\n      rate: 1,\n      crash,\n      auto,\n      milestone: 0,\n      // Kept from start(): an async crash point can take a while, and the\n      // first tap after it lands is a real cash out.\n      pressTime: flight.current.pressTime,\n      held: false,\n    };\n    setPhase(\"running\");\n    setAnnouncement(auto === null ? \"Round started.\" : `Round started. Auto cash out at ${formatMultiplier(auto)}.`);\n  };\n\n  const start = () => {\n    // From the ref, not render state: two clicks in one frame both see the\n    // old phase, and a second start would ask for a second crash point (a\n    // second server round).\n    if (busy.current) return;\n    busy.current = true;\n    rounds += 1;\n    const id = rounds;\n    // Before any state changes: if it throws, the round never began and the\n    // last one's result stays as it was.\n    try {\n      onStart?.(id);\n    } catch (error) {\n      busy.current = false;\n      throw error;\n    }\n    // The guard against a double tap on Start cashing out at once.\n    flight.current.pressTime = performance.now();\n    cashedRef.current = null;\n    setCashed(null);\n    setCashedT(null);\n    setCrashedAt(null);\n    setElapsed(0);\n    setPulse(0);\n    setRoundAuto(auto);\n    setRound(id);\n    if (soundOn) getPlayer().prepare();\n    if (live) {\n      fly(id, Number.POSITIVE_INFINITY);\n      return;\n    }\n    let point: number | Promise<number>;\n    try {\n      point = getCrashPoint ? getCrashPoint() : crashPoint();\n    } catch {\n      point = Promise.reject();\n    }\n    if (typeof point === \"number\") {\n      fly(id, settle(point));\n      return;\n    }\n    setPhase(\"starting\");\n    setAnnouncement(\"Starting round.\");\n    // Promise.resolve: a plain-JS caller may hand back something else.\n    Promise.resolve(point).then(\n      (value) => {\n        if (alive.current) fly(id, settle(value));\n      },\n      () => {\n        if (!alive.current) return;\n        busy.current = false;\n        setPhase(\"idle\");\n        setAnnouncement(\"The round couldn't start. Try again.\");\n      },\n    );\n  };\n\n  // Runs the rest of the flight quickly; reduced motion jumps to the crash.\n  // A live round still waiting on its crash point can't be run out.\n  const skip = () => {\n    const f = flight.current;\n    if (!Number.isFinite(f.crash)) return;\n    f.rate = reduce ? 1e9 : Math.max(1, (timeAt(f.crash) - f.t) / SKIP_TIME);\n  };\n\n  const onFrame = useStable((dt: number) => {\n    // The round already ended: a frame booked before a throwing onCrash.\n    if (!busy.current) return false;\n    const f = flight.current;\n    const wasIn = cashedRef.current === null;\n    // A live round's crash point lands whenever the server reveals it; one\n    // already behind the plane crashes at once, at that value. One ahead is\n    // a display running late: the server has crashed, so the flight runs\n    // out to it like a skip.\n    if (f.live && !Number.isFinite(f.crash) && crashAt?.round === f.round) {\n      f.crash = settle(crashAt.value);\n      f.rate = reduce ? 1e9 : Math.max(1, (timeAt(f.crash) - f.t) / SKIP_TIME);\n      // The server's crash came before the player's cash out: it never\n      // happened, so the round is lost after all.\n      if (cashedRef.current !== null && cashedRef.current > f.crash) {\n        cashedRef.current = null;\n        setCashed(null);\n        setCashedT(null);\n        f.held = false;\n      }\n    }\n    // The live round lost its crash point source mid-flight: nothing can\n    // end it any more, so it ends here. A held cash out was never confirmed\n    // by the server, so it doesn't stand.\n    if (f.live && !live && !Number.isFinite(f.crash)) {\n      f.crash = settle(growth(f.t));\n      if (f.held) {\n        f.held = false;\n        cashedRef.current = null;\n        setCashed(null);\n        setCashedT(null);\n      }\n    }\n    // A held live cash out stands now the crash point is known.\n    if (f.held && Number.isFinite(f.crash) && cashedRef.current !== null) {\n      f.held = false;\n      onCashOut?.(cashedRef.current);\n    }\n    const end = timeAt(f.crash);\n    f.t = Math.min(end, f.t + dt * f.rate, timeAt(CEILING));\n    const value = growth(f.t);\n    if (cashedRef.current === null) {\n      // A target at or below the crash point always pays, at the target. The\n      // crash check covers a target equal to the crash point.\n      if (f.auto !== null && f.auto <= f.crash && (value >= f.auto || f.t >= end)) cashOut(f.auto, timeAt(f.auto));\n      // Finite: an open live round that runs for hours overflows to Infinity.\n      // Not during a live run-out: the round is already decided.\n      else if (f.rate === 1 && f.t < end && Number.isFinite(value) && value >= milestone(f.milestone)) {\n        // A long frame can cross several; only the last is told.\n        while (value >= milestone(f.milestone + 1)) f.milestone++;\n        setAnnouncement(formatShort(milestone(f.milestone)));\n        setPulse((count) => count + 1);\n        f.milestone++;\n        if (soundOn) getPlayer().gem(f.milestone);\n      }\n    }\n    if (f.t < end) {\n      setElapsed(f.t);\n      return true;\n    }\n    busy.current = false;\n    f.pressTime = performance.now();\n    setCrashedAt(f.crash);\n    setPhase(\"crashed\");\n    setHistory((current) => [{ id: f.round, value: f.crash }, ...current].slice(0, HISTORY));\n    setAnnouncement(\n      cashedRef.current === null\n        ? `Crashed at ${formatMultiplier(f.crash)}. The round is lost.`\n        : // A cash out on this same frame (a target at the crash point, a\n          // long frame) would otherwise never be heard.\n          `${wasIn ? `Cashed out at ${formatMultiplier(cashedRef.current)}. ` : \"\"}Crashed at ${formatMultiplier(f.crash)}.`,\n    );\n    if (soundOn) getPlayer().mine();\n    onCrash?.(f.crash, cashedRef.current);\n    return false;\n  });\n\n  useEffect(() => {\n    if (phase !== \"running\") return;\n    let last = performance.now();\n    // performance.now(), not the frame timestamp, so both ends share one clock.\n    let id = requestAnimationFrame(function step() {\n      const now = performance.now();\n      const dt = Math.max(0, now - last) / 1000;\n      last = now;\n      // The next frame is booked first, so a consumer callback that throws\n      // can't stall a running round; the crash cancels it.\n      id = requestAnimationFrame(step);\n      if (!onFrame(dt)) cancelAnimationFrame(id);\n    });\n    return () => cancelAnimationFrame(id);\n  }, [phase, onFrame]);\n\n  // Stable, so the menu and sound button can skip the 60fps re-renders.\n  const setAuto = useStable((value: string) => {\n    const next = value === \"off\" ? null : Number(value);\n    if (autoProp === undefined) setInternalAuto(next);\n    onAutoCashOutChange?.(next);\n  });\n  // A custom target from the prop gets its own option, so the menu can show it.\n  const autoOptions = useMemo(\n    () => (auto === null || AUTO_STEPS.includes(auto) ? AUTO_STEPS : [...AUTO_STEPS, auto].sort((a, b) => a - b)),\n    [auto],\n  );\n\n  const toggleSound = useStable(() => {\n    const next = !soundOn;\n    if (next) getPlayer().prepare();\n    if (sound === undefined) setInternalSound(next);\n    onSoundChange?.(next);\n  });\n\n  /** Start, cash out or skip, whichever the round is ready for. */\n  const act = () => {\n    const now = performance.now();\n    // A double tap on Start mustn't cash out at 1.01×, nor a mashed cash out\n    // skip or start the next round. An auto cash out doesn't count as a\n    // press, so it can be skipped at once.\n    if (starting || now - flight.current.pressTime < PRESS_GUARD) return;\n    if (!running) start();\n    else if (cashedRef.current === null) {\n      // The server already ended this live round; the display is catching\n      // up. `overtaken` covers the frame before onFrame takes the reveal.\n      if (overtaken || (flight.current.live && Number.isFinite(flight.current.crash))) return;\n      flight.current.pressTime = now;\n      cashOut(floor2(growth(flight.current.t)), flight.current.t);\n    } else skip();\n  };\n  const actionLabel = starting\n    ? \"Starting…\"\n    : running\n      ? cashed === null\n        ? overtaken\n          ? \"Crashing…\"\n          : `Cash out ${formatMultiplier(multiplier)}`\n        : crashKnown\n          ? \"Skip to crash\"\n          : \"Waiting for crash\"\n      : crashed\n        ? \"Play again\"\n        : \"Start\";\n  /** The main action can't act right now: the crash point is still coming, or a live round is already over. */\n  const actionBusy = starting || (running && cashed !== null && !crashKnown) || (overtaken && cashed === null);\n\n  // Red is kept for a loss: a crash after a cash out is no bad news.\n  const lost = crashed && cashed === null;\n  // At the end of a round the readout becomes the result: won or lost, the\n  // number that counts, and one line of context.\n  const eyebrow = lost ? \"Crashed\" : crashed ? \"Cashed out\" : running ? \"In flight\" : starting ? \"Starting\" : \"Ready\";\n  // In flight the round's locked target, not a menu change made since.\n  const shownAuto = running ? roundAuto : auto;\n  const status = crashed\n    ? cashed !== null\n      ? // Within 10% of the crash is a close call.\n        multiplier / cashed < 1.1\n        ? `Crashed at ${formatMultiplier(multiplier)}, a close call`\n        : `It flew on to ${formatMultiplier(multiplier)}`\n      : roundAuto !== null\n        ? `Short of your ${formatShort(roundAuto)} target`\n        : \"You were still in\"\n    : shownAuto !== null\n      ? `Auto cash out at ${formatMultiplier(shownAuto)}`\n      : running\n        ? \"Cash out before it crashes\"\n        : \"Start a round\";\n\n  // What a design marks ahead of the flight: the auto target until it pays\n  // (the menu's choice while idle, the locked one in flight), and the last\n  // round's crash point as a faint trace. Both are null when there is none.\n  const target = cashed !== null || crashed ? null : running ? roundAuto : auto;\n  const ghost = (crashed ? history[1] : history[0])?.value ?? null;\n\n  return {\n    reduce,\n    /** The built-in fair crash point decides the round, so \"1 in m\" odds hold. */\n    fair: !live && !getCrashPoint,\n    target,\n    ghost,\n    /** The auto target the current or last round played with. */\n    roundAuto,\n    phase,\n    running,\n    starting,\n    crashed,\n    lost,\n    /** A skip is running the rest of the flight out; scenery that streams with t would only flicker. */\n    skipping: running && flight.current.rate > 1,\n    /** Seconds into the flight, frozen at the crash. */\n    t,\n    multiplier,\n    cashed,\n    /** Seconds into the flight at the cash out, or null. */\n    cashedT,\n    round,\n    pulse,\n    history,\n    auto,\n    autoOptions,\n    setAuto,\n    soundOn,\n    toggleSound,\n    act,\n    actionLabel,\n    actionBusy,\n    eyebrow,\n    status,\n    announcement,\n  };\n}\n\nexport type CrashGame = ReturnType<typeof useCrashGame>;\n\n/**\n * A callback with one identity for the component's life that runs the latest\n * render's code. Stands in for useEffectEvent, which needs React 19.2.\n */\nexport function useStable<A extends unknown[], R>(fn: (...args: A) => R) {\n  const ref = useRef(fn);\n  useLayoutEffect(() => {\n    ref.current = fn;\n  });\n  return useCallback((...args: A) => ref.current(...args), []);\n}\n\n/**\n * How hot a multiplier reads: cool under 2×, warm to 10×, hot beyond. The\n * readout and the history colour by it, so size reads without the digits.\n */\nexport function heat(multiplier: number) {\n  return multiplier >= 10 ? \"hot\" : multiplier >= 2 ? \"warm\" : \"cool\";\n}\n\nexport function floor2(value: number) {\n  return Math.floor(value * 100 + 1e-9) / 100;\n}\n\n// Truncated, never rounded up: a 10,960× payout reads 10.9K×, not 11K×.\nconst COMPACT = new Intl.NumberFormat(\"en\", { notation: \"compact\", maximumFractionDigits: 1, roundingMode: \"trunc\" });\n\n/**\n * Large values: compact up to trillions, then scientific (1.4e100), since\n * compact notation stops at T and would print 1e100 as a 100-digit string.\n */\nfunction formatLarge(multiplier: number) {\n  if (multiplier < 1e15) return COMPACT.format(multiplier);\n  // From toExponential, not log10: 1e100 / 10 ** 100 is 0.999….\n  const [mantissa, exponent] = multiplier.toExponential(6).split(\"e\");\n  return `${Math.floor(Number(mantissa) * 10) / 10}e${Number(exponent)}`;\n}\n\n/** A multiplier as Crash shows it: 2.50×, or shortened when large (12.5K×, 1.4e20×). */\nexport function formatMultiplier(multiplier: number) {\n  return `${multiplier < 10_000 ? multiplier.toFixed(2) : formatLarge(multiplier)}×`;\n}\n\n/** Scale and menu labels: 1.5×, 2×, 1.2K×. */\nexport function formatShort(multiplier: number) {\n  return `${multiplier < 1000 ? +multiplier.toFixed(2) : formatLarge(multiplier)}×`;\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\n/** Accelerating curve for things that fall (a dropped ball, a toppled block). Not for UI entrances. */\nexport const EASE_IN = [0.55, 0, 1, 0.45] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/action-swap-cascade.tsx","type":"util","content":"\"use client\";\n\nimport {\n  ActionSwapButton,\n  ActionSwapIcon,\n  ActionSwapText,\n  type ActionSwapButtonProps,\n  type ActionSwapIconProps,\n  type ActionSwapTextProps,\n} from \"./action-swap\";\n\nexport type {\n  ActionSwapButtonSize,\n  ActionSwapButtonVariant,\n  ActionSwapItem,\n} from \"./action-swap\";\n\nexport type ActionSwapCascadeButtonProps = Omit<ActionSwapButtonProps, \"animation\">;\nexport type ActionSwapCascadeTextProps = Omit<ActionSwapTextProps, \"animation\">;\nexport type ActionSwapCascadeIconProps = Omit<ActionSwapIconProps, \"animation\">;\n\nexport function ActionSwapCascadeButton(props: ActionSwapCascadeButtonProps) {\n  return <ActionSwapButton {...props} animation=\"cascade\" />;\n}\n\nexport function ActionSwapCascadeText(props: ActionSwapCascadeTextProps) {\n  return <ActionSwapText {...props} animation=\"cascade\" />;\n}\n\nexport function ActionSwapCascadeIcon(props: ActionSwapCascadeIconProps) {\n  return <ActionSwapIcon {...props} animation=\"cascade\" />;\n}\n"},{"path":"components/motion/button/index.tsx","type":"util","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { MetallicButtonProps } from \"./metallic\";\nexport { MetallicButton } from \"./metallic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\n"},{"path":"components/motion/number-ticker.tsx","type":"util","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /**\n   * Fraction digits to keep. The value is rounded to this many places before\n   * `format` runs (default 0, whole numbers).\n   */\n  decimals?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  decimals = 0,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const places = Math.max(0, Math.floor(decimals));\n    const factor = 10 ** places;\n    const rounded = Math.round(value * factor) / factor;\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(undefined, {\n            minimumFractionDigits: places,\n            maximumFractionDigits: places,\n          })\n        : rounded.toFixed(places);\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, decimals, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-baseline tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    // The invisible in-flow digit gives the slot a real text baseline so it\n    // lines up with surrounding text; clip-path clips the rolling column\n    // without moving that baseline the way overflow: hidden would.\n    <span\n      className={cn(\n        \"relative inline-block text-center [clip-path:inset(0)]\",\n        className,\n      )}\n      style={{\n        height: `${DIGIT_HEIGHT_EM}em`,\n        lineHeight: `${DIGIT_HEIGHT_EM}em`,\n        width: \"1ch\",\n      }}\n    >\n      <span className=\"invisible\">{digit}</span>\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/select.tsx","type":"util","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { type ListboxRow, useListbox } from \"@/lib/hooks/use-listbox\";\nimport { cn } from \"@/lib/utils\";\n\n// One progress value drives the surface, gap, corners and chevron. This short\n// spring suits the full reveal distance; each reader clamps its small internal\n// overshoot, so all visible endpoints settle together. Rows share a short\n// reveal so long lists have no late entries.\nconst UNFOLD_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.2,\n  bounce: 0.12,\n} as const;\n// Exits are faster than entrances: the panel folds back on a short ease.\nconst UNFOLD_EXIT = { duration: 0.2, ease: EASE_OUT } as const;\nconst RADIUS = 12;\n// How far the open panel sits from the trigger once it has pulled away.\nconst GAP = 8;\n// The surface starts as a thin lip flush against the trigger's edge.\nconst LIP = 8;\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst ITEM_VARIANTS: Variants = {\n  // A shared short reveal keeps late rows in long, scrolled lists ready with\n  // the surface. Per-index staggering can delay visible rows by over a second.\n  hidden: { opacity: 0, y: -4, transition: { duration: 0.08, ease: EASE_OUT } },\n  show: { opacity: 1, y: 0, transition: { duration: 0.12, ease: EASE_OUT } },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string, disabled: boolean) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  rows: ListboxRow[];\n  /** Closes the panel; focus inside it returns to the trigger. */\n  close: () => void;\n  reduce: boolean;\n  progress: MotionValue<number>;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n// The option keyboard focus is on, resolved during render by the content.\nconst ActiveOptionContext = createContext<string | undefined>(undefined);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<\n    Map<string, { label: string; disabled: boolean }>\n  >(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n  const progress = useMotionValue(open ? 1 : 0);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  // The panel turns inert as it closes, which would drop focus held inside it\n  // on the page body. Hand it back to the trigger before that happens.\n  const close = useCallback(() => {\n    const trigger = document.getElementById(`${baseId}-trigger`);\n    if (\n      rootRef.current?.contains(document.activeElement) &&\n      document.activeElement !== trigger\n    )\n      trigger?.focus();\n    setOpen(false);\n  }, [baseId, setOpen]);\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      close();\n    },\n    [controlled, onValueChange, close],\n  );\n\n  const register = useCallback((v: string, label: string, disabled: boolean) => {\n    setLabels((m) => {\n      const entry = m.get(v);\n      if (entry?.label === label && entry.disabled === disabled) return m;\n      return new Map(m).set(v, { label, disabled });\n    });\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // Reduced motion has no travel, so the progress only carries a short fade.\n  useEffect(() => {\n    const animation = animate(\n      progress,\n      open ? 1 : 0,\n      reduce ? { duration: 0.12 } : open ? UNFOLD_SPRING : UNFOLD_EXIT,\n    );\n    return () => animation.stop();\n  }, [open, progress, reduce]);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen, close]);\n\n  const rows = useMemo(\n    () =>\n      Array.from(labels, ([id, entry]) => ({\n        id,\n        label: entry.label,\n        disabled: entry.disabled,\n      })),\n    [labels],\n  );\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),\n      rows,\n      close,\n      reduce,\n      progress,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      rows,\n      close,\n      reduce,\n      progress,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // The edge facing the panel flattens while the panel is attached to it and\n  // rounds back as the panel pulls away — the two pinch apart — on the same\n  // curve the panel moves on. The far edge stays rounded.\n  const nearRadius = useTransform(\n    ctx.progress,\n    [0, 0.2, 0.4, 1],\n    [RADIUS, 0, 0, RADIUS],\n  );\n  const rotate = useTransform(ctx.progress, (p) =>\n    ctx.reduce ? (ctx.open ? 180 : 0) : Math.min(1, Math.max(0, p)) * 180,\n  );\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      onKeyDown={(event) => {\n        if (\n          !ctx.open &&\n          (event.key === \"ArrowDown\" || event.key === \"ArrowUp\")\n        ) {\n          event.preventDefault();\n          ctx.setOpen(true);\n        }\n      }}\n      style={{\n        borderTopLeftRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderTopRightRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderBottomLeftRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n        borderBottomRightRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        style={{ rotate }}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const panelRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const { open, progress, reduce, setPlacement } = ctx;\n  const { activeId, onKeyDown } = useListbox({\n    open,\n    rows: ctx.rows,\n    selected: ctx.value,\n    list: panelRef,\n  });\n\n  // Keep descendants laid out while hidden: display:none resets nested\n  // scrollers in Chromium. Collapse only the hidden outer footprint after\n  // exit so it cannot extend page scrolling. Opening restores the full box\n  // before measurement or focus; neither layout change is animated.\n  const [exiting, setExiting] = useState(false);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (!open) setExiting(true);\n  }\n  const present = open || exiting;\n  useMotionValueEvent(progress, \"animationComplete\", () => {\n    if (progress.get() === 0) setExiting(false);\n  });\n\n  useLayoutEffect(() => {\n    const node = panelRef.current;\n    if (!node || !present) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [present]);\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = panelRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  const isTop = ctx.placement === \"top\";\n\n  // Reveal a full-size panel from its near edge. No ancestor scales the list,\n  // so rows, borders and shadow keep their natural thickness. The clip and\n  // corner writes can paint; only the gap uses a transform. All read one\n  // progress value, with no animated layout properties.\n  const ready = height > 0 && !reduce;\n  const fromY = ready ? (isTop ? GAP : -GAP) : 0;\n  const detach = useTransform(progress, [0.4, 1], [0, 1]);\n  const y = useTransform(detach, (d) => lerp(fromY, 0, d));\n  const nearRadius = useTransform(detach, (d) => (ready ? RADIUS * d : RADIUS));\n  const borderRadius = useTransform(nearRadius, (near) =>\n    isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`,\n  );\n  const clipPath = useTransform([progress, nearRadius], ([p, near]: number[]) => {\n    if (!ready) return \"none\";\n    // Clamp overshoot so the clip never extends into the trigger or exposes\n    // overflowing children. At rest remove it so the native shadow is whole.\n    const reveal = Math.min(1, Math.max(0, p));\n    if (reveal === 1) return \"none\";\n    const hidden = Math.max(0, (height - LIP) * (1 - reveal));\n    const inset = isTop ? `${hidden}px 0 0 0` : `0 0 ${hidden}px 0`;\n    const corners = isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`;\n    return `inset(${inset} round ${corners})`;\n  });\n  // The surface materialises over its first frames rather than popping in as\n  // a lip; with reduced motion the fade is the whole entrance.\n  const opacity = useTransform(progress, reduce ? [0, 1] : [0, 0.12], [0, 1]);\n\n  return (\n    <motion.div\n      ref={panelRef}\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={(event) => {\n        // Tab leaves the list from the trigger, so focus moves on to the\n        // control after the select rather than into the closing panel.\n        if (event.key === \"Tab\") ctx.close();\n        else onKeyDown(event);\n      }}\n      style={{\n        visibility: present ? undefined : \"hidden\",\n        maxHeight: present ? undefined : 0,\n        borderWidth: present ? undefined : 0,\n        y,\n        clipPath,\n        borderRadius,\n        opacity,\n        pointerEvents: open ? \"auto\" : \"none\",\n        marginTop: isTop ? 0 : GAP,\n        marginBottom: isTop ? GAP : 0,\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 overflow-hidden border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <ActiveOptionContext.Provider value={activeId}>\n        <motion.div\n          initial={false}\n          animate={open ? \"show\" : \"hidden\"}\n          className=\"p-1\"\n        >\n          {children}\n        </motion.div>\n      </ActiveOptionContext.Provider>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const activeId = useContext(ActiveOptionContext);\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label, disabled);\n  }, [ctx.register, value, label, disabled]);\n  useLayoutEffect(() => () => ctx.unregister(value), [ctx.unregister, value]);\n\n  return (\n    <motion.div variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        // One tab stop: the active option, which focus follows while open.\n        tabIndex={activeId === value ? 0 : -1}\n        data-listbox-id={value}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\nimport { focusFromKeyboard, graceArea, inBox, inPolygon, watchModality } from \"./tooltip/grace\";\nimport { TooltipArrow, TooltipPositioner } from \"./tooltip/positioner\";\nimport {\n  type TooltipAlign,\n  type TooltipAnchor,\n  type TooltipPoint,\n  type TooltipSide,\n  useTooltipPointer,\n} from \"./tooltip/use-position\";\n\nexport type { TooltipAlign, TooltipSide };\n\n/* -------------------------------------------------------------------------- */\n/* Provider                                                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface TooltipTiming {\n  /** Delay before a hover opens a tooltip (ms). */\n  delay: number;\n  /** Delay before a tooltip closes once the pointer has left it (ms). */\n  closeDelay: number;\n  /** After one tooltip closes, others open instantly for this long (ms). */\n  skipDelay: number;\n}\n\nconst TimingContext = createContext<TooltipTiming>({ delay: 120, closeDelay: 0, skipDelay: 300 });\n\nexport interface TooltipProviderProps extends Partial<TooltipTiming> {\n  children: ReactNode;\n}\n\n/** Shared timing for every tooltip inside it. Optional: tooltips work without one. */\nexport function TooltipProvider({ children, ...timing }: TooltipProviderProps) {\n  const parent = useContext(TimingContext);\n  const { delay = parent.delay, closeDelay = parent.closeDelay, skipDelay = parent.skipDelay } =\n    timing;\n  const value = useMemo(() => ({ delay, closeDelay, skipDelay }), [delay, closeDelay, skipDelay]);\n  return <TimingContext.Provider value={value}>{children}</TimingContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                       */\n/* -------------------------------------------------------------------------- */\n\n// Moving along a toolbar should feel instant after the first label, and only\n// one label may show at a time: a keyboard-focused control and a hovered one\n// would otherwise both claim the screen.\nlet lastClosedAt = 0;\nlet current: { close: () => void } | null = null;\n\nfunction isEmpty(content: ReactNode) {\n  return content == null || content === false || content === \"\";\n}\n\ninterface TooltipContextValue {\n  open: boolean;\n  contentId: string;\n  labelId: string;\n  asLabel: boolean;\n  empty: boolean;\n  followCursor: boolean;\n  anchor: TooltipAnchor | null;\n  /** True when a TooltipTrigger owns the anchor; false for an external `anchorRef`. */\n  triggered: boolean;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  pointer: ReturnType<typeof useTooltipPointer>;\n  setTrigger: (element: TooltipAnchor | null) => void;\n  setEmpty: (empty: boolean) => void;\n  close: () => void;\n  onContentEnter: () => void;\n  onContentLeave: (event: ReactPointerEvent) => void;\n}\n\nconst TooltipContext = createContext<TooltipContextValue | null>(null);\n\nfunction useTooltipContext(part: string) {\n  const context = useContext(TooltipContext);\n  if (!context) throw new Error(`${part} must be used inside <Tooltip>`);\n  return context;\n}\n\nexport interface TooltipRootProps {\n  children?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Delay before showing on hover (ms). Keyboard focus shows immediately. */\n  delay?: number;\n  /** Delay before hiding once the pointer has left (ms). */\n  closeDelay?: number;\n  /** Never show, e.g. while the label it carries is already visible. */\n  disabled?: boolean;\n  /**\n   * Use the tooltip as the trigger's accessible name rather than its\n   * description. For icon-only controls whose only label is the tooltip.\n   */\n  asLabel?: boolean;\n  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n  followCursor?: boolean;\n  /** Existing anchor for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<TooltipAnchor | null>;\n  /** Id of the tooltip surface, for anchors that reference it themselves. */\n  id?: string;\n}\n\nfunction TooltipRoot({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  delay: delayProp,\n  closeDelay: closeDelayProp,\n  disabled = false,\n  asLabel = false,\n  followCursor = false,\n  anchorRef,\n  id,\n  initialEmpty = false,\n}: TooltipRootProps & { initialEmpty?: boolean }) {\n  const timing = useContext(TimingContext);\n  const delay = delayProp ?? timing.delay;\n  const closeDelay = closeDelayProp ?? timing.closeDelay;\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? uncontrolledOpen;\n  const [empty, setEmpty] = useState(initialEmpty);\n  const [trigger, setTrigger] = useState<TooltipAnchor | null>(null);\n  const [external, setExternal] = useState<TooltipAnchor | null>(null);\n  const anchor = anchorRef ? external : trigger;\n  const generatedId = useId();\n  const contentId = id ?? `${generatedId}-content`;\n  const labelId = `${generatedId}-label`;\n  const floatingRef = useRef<HTMLSpanElement | null>(null);\n  const anchorObject = useRef<TooltipAnchor | null>(null);\n  const pointer = useTooltipPointer(anchor, followCursor);\n\n  // An external ref can attach after this component renders; read it once it has.\n  useLayoutEffect(() => {\n    if (!anchorRef) return;\n    const element = anchorRef.current ?? null;\n    if (element !== external) setExternal(element);\n  });\n\n  // Everything the event listeners read, current without re-binding them.\n  const latest = useRef({ open, delay, closeDelay, disabled, empty, onOpenChange, controlled: false });\n  useLayoutEffect(() => {\n    anchorObject.current = anchor;\n    latest.current = {\n      open,\n      delay,\n      closeDelay,\n      disabled,\n      empty,\n      onOpenChange,\n      controlled: controlledOpen !== undefined,\n    };\n  });\n\n  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const grace = useRef<(() => void) | null>(null);\n  const self = useRef<{ close: () => void }>({ close: () => {} });\n\n  const clearTimers = useCallback(() => {\n    if (openTimer.current) clearTimeout(openTimer.current);\n    if (closeTimer.current) clearTimeout(closeTimer.current);\n    openTimer.current = null;\n    closeTimer.current = null;\n  }, []);\n\n  const stopGrace = useCallback(() => {\n    grace.current?.();\n    grace.current = null;\n  }, []);\n\n  // Only a real change reaches the consumer: a pointer leaving a closed\n  // tooltip is not news.\n  const commit = useCallback((next: boolean) => {\n    if (latest.current.open === next) return;\n    if (!latest.current.controlled) setUncontrolledOpen(next);\n    latest.current.onOpenChange?.(next);\n  }, []);\n\n  const openNow = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.disabled || latest.current.empty) return;\n    if (current && current !== self.current) current.close();\n    current = self.current;\n    commit(true);\n  }, [clearTimers, stopGrace, commit]);\n\n  const close = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.open) lastClosedAt = Date.now();\n    if (current === self.current) current = null;\n    commit(false);\n  }, [clearTimers, stopGrace, commit]);\n  useLayoutEffect(() => {\n    self.current.close = close;\n  }, [close]);\n\n  const requestOpen = useCallback(() => {\n    clearTimers();\n    if (latest.current.open || latest.current.disabled || latest.current.empty) return;\n    const warm = Date.now() - lastClosedAt < timing.skipDelay;\n    if (warm || latest.current.delay <= 0) openNow();\n    else openTimer.current = setTimeout(openNow, latest.current.delay);\n  }, [clearTimers, openNow, timing.skipDelay]);\n\n  const requestClose = useCallback(() => {\n    clearTimers();\n    if (latest.current.closeDelay > 0) {\n      closeTimer.current = setTimeout(close, latest.current.closeDelay);\n    } else close();\n  }, [clearTimers, close]);\n\n  // Hold the label open while the pointer crosses from one box toward the other.\n  const startGrace = useCallback(\n    (exit: TooltipPoint, from: Element, to: Element | null) => {\n      stopGrace();\n      if (!to) return requestClose();\n      const target = to.getBoundingClientRect();\n      const origin = from.getBoundingClientRect();\n      const corridor = graceArea(exit, target);\n      const onMove = (event: PointerEvent) => {\n        const point = { x: event.clientX, y: event.clientY };\n        if (inBox(point, target) || inBox(point, origin)) return;\n        if (!inPolygon(point, corridor)) requestClose();\n      };\n      document.addEventListener(\"pointermove\", onMove);\n      grace.current = () => document.removeEventListener(\"pointermove\", onMove);\n    },\n    [stopGrace, requestClose],\n  );\n\n  const hover = useHoverGesture();\n  const tap = useTapGesture<boolean>();\n\n  // Listeners go on the trigger itself rather than on cloned props, so a\n  // component that owns its own handlers keeps them, and no wrapper element\n  // sits between the control and its layout.\n  useEffect(() => {\n    if (anchorRef || !trigger) return;\n    watchModality();\n    // A mouse press dismisses the label and keeps it away until the pointer\n    // leaves; hovering a control you just clicked should not bring it back.\n    let suppressed = false;\n    // Focus that came from the keyboard holds the label open on its own.\n    let keyboardFocus = false;\n\n    const onEnter = (event: PointerEvent) => {\n      if (!hover.enter(event)) return;\n      stopGrace();\n      if (closeTimer.current) clearTimeout(closeTimer.current);\n      if (!suppressed) requestOpen();\n    };\n    const onLeave = (event: PointerEvent) => {\n      if (!hover.leave(event)) return;\n      suppressed = false;\n      if (keyboardFocus) return;\n      if (openTimer.current) {\n        clearTimers();\n        return;\n      }\n      if (latest.current.open && !followCursor) {\n        startGrace({ x: event.clientX, y: event.clientY }, trigger, floatingRef.current);\n      } else requestClose();\n    };\n    const onDown = (event: PointerEvent) => {\n      tap.start(event, latest.current.open);\n      if (event.pointerType === \"mouse\") {\n        suppressed = true;\n        close();\n      }\n    };\n    // A finger never hovers, and Safari does not focus a button on tap either,\n    // so the label is only reachable if the tap itself toggles it. The click\n    // carries no pointerType; the pointerdown before it says whether it was a\n    // tap, and keyboard activation has none at all.\n    const onClick = () => {\n      const gesture = tap.take();\n      if (!gesture || gesture.pointerType === \"mouse\") return;\n      if (gesture.state) close();\n      else openNow();\n    };\n    const onKey = () => tap.drop();\n    const onFocusIn = () => {\n      if (!focusFromKeyboard()) return;\n      keyboardFocus = true;\n      openNow();\n    };\n    const onFocusOut = (event: FocusEvent) => {\n      if (trigger.contains(event.relatedTarget as Node | null)) return;\n      keyboardFocus = false;\n      close();\n    };\n\n    // Both element kinds dispatch the same events; one type keeps the listeners typed.\n    const el = trigger as HTMLElement;\n    el.addEventListener(\"pointerenter\", onEnter);\n    el.addEventListener(\"pointerleave\", onLeave);\n    el.addEventListener(\"pointerdown\", onDown);\n    el.addEventListener(\"pointercancel\", tap.drop);\n    el.addEventListener(\"click\", onClick);\n    el.addEventListener(\"keydown\", onKey);\n    el.addEventListener(\"focusin\", onFocusIn);\n    el.addEventListener(\"focusout\", onFocusOut);\n    return () => {\n      el.removeEventListener(\"pointerenter\", onEnter);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      el.removeEventListener(\"pointerdown\", onDown);\n      el.removeEventListener(\"pointercancel\", tap.drop);\n      el.removeEventListener(\"click\", onClick);\n      el.removeEventListener(\"keydown\", onKey);\n      el.removeEventListener(\"focusin\", onFocusIn);\n      el.removeEventListener(\"focusout\", onFocusOut);\n    };\n  }, [\n    anchorRef,\n    trigger,\n    followCursor,\n    hover,\n    tap,\n    requestOpen,\n    requestClose,\n    openNow,\n    close,\n    clearTimers,\n    stopGrace,\n    startGrace,\n  ]);\n\n  // While open, a trigger that turns disabled loses focus without a blur event,\n  // and a window that loses focus never reports the pointer leaving.\n  useEffect(() => {\n    if (!open || anchorRef || !trigger) return;\n    const observer = new MutationObserver(() => {\n      if (trigger.matches(\":disabled, [aria-disabled='true']\")) close();\n    });\n    observer.observe(trigger, { attributes: true, attributeFilter: [\"disabled\", \"aria-disabled\"] });\n    window.addEventListener(\"blur\", close);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"blur\", close);\n    };\n  }, [open, anchorRef, trigger, close]);\n\n  // A chart drives its own open state and swaps content as it goes; only a\n  // trigger-owned label closes itself for having nothing to say.\n  useEffect(() => {\n    if (disabled || (empty && !anchorRef)) close();\n  }, [disabled, empty, anchorRef, close]);\n\n  useEffect(\n    () => () => {\n      clearTimers();\n      stopGrace();\n      if (current === self.current) current = null;\n    },\n    [clearTimers, stopGrace],\n  );\n\n  const insideTooltip = useCallback(\n    (target: Element) => Boolean(floatingRef.current?.contains(target)),\n    [],\n  );\n  // ...and closed by the next tap that lands somewhere else, or Escape. The\n  // label covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, close, anchorObject, { ignore: insideTooltip });\n\n  const onContentEnter = useCallback(() => {\n    stopGrace();\n    clearTimers();\n  }, [stopGrace, clearTimers]);\n\n  const onContentLeave = useCallback(\n    (event: ReactPointerEvent) => {\n      const floating = floatingRef.current;\n      if (!floating) return;\n      if (anchorRef) {\n        // Bridge the small gap back onto a chart; it reopens on its own terms.\n        clearTimers();\n        closeTimer.current = setTimeout(close, 100);\n        return;\n      }\n      startGrace({ x: event.clientX, y: event.clientY }, floating, anchorObject.current);\n    },\n    [anchorRef, clearTimers, close, startGrace],\n  );\n\n  const value = useMemo<TooltipContextValue>(\n    () => ({\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      triggered: !anchorRef,\n      floatingRef,\n      pointer,\n      setTrigger,\n      setEmpty,\n      close,\n      onContentEnter,\n      onContentLeave,\n    }),\n    [\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      anchorRef,\n      pointer,\n      close,\n      onContentEnter,\n      onContentLeave,\n    ],\n  );\n\n  return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger                                                                    */\n/* -------------------------------------------------------------------------- */\n\nfunction mergeIds(...ids: unknown[]) {\n  return ids.filter((value) => typeof value === \"string\" && value).join(\" \") || undefined;\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as { current: T | null }).current = value;\n}\n\nexport interface TooltipTriggerProps extends ComponentProps<\"button\"> {\n  /** Use the single child element as the trigger instead of rendering a button. */\n  asChild?: boolean;\n}\n\nexport function TooltipTrigger({ asChild = false, children, ref, ...props }: TooltipTriggerProps) {\n  const { open, contentId, labelId, asLabel, empty, setTrigger } =\n    useTooltipContext(\"TooltipTrigger\");\n  const child = asChild && isValidElement(children) ? children : null;\n  const childProps = (child?.props ?? props) as Record<string, unknown>;\n  const childRef = child ? (childProps.ref as Ref<TooltipAnchor> | undefined) : undefined;\n  const attached = useRef<TooltipAnchor | null>(null);\n\n  const setRef = useCallback(\n    (element: TooltipAnchor | null) => {\n      attached.current = element;\n      setTrigger(element);\n      assignRef(childRef ?? (ref as Ref<TooltipAnchor> | undefined), element);\n    },\n    [setTrigger, childRef, ref],\n  );\n\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"production\" && child && !attached.current) {\n      console.warn(\n        \"TooltipTrigger: the child did not attach a ref, so the tooltip has nothing to listen to. Render a DOM element or a component that passes `ref` through.\",\n      );\n    }\n  }, [child]);\n\n  const aria = asLabel\n    ? {\n        \"aria-labelledby\": empty\n          ? (childProps[\"aria-labelledby\"] as string | undefined)\n          : labelId,\n      }\n    : {\n        \"aria-describedby\": mergeIds(\n          childProps[\"aria-describedby\"],\n          open && !empty ? contentId : undefined,\n        ),\n      };\n\n  if (asChild) {\n    if (!child) return children;\n    return cloneElement(child as ReactElement<Record<string, unknown>>, { ref: setRef, ...aria });\n  }\n\n  return (\n    <button\n      type=\"button\"\n      data-state={open ? \"open\" : \"closed\"}\n      {...props}\n      ref={setRef as Ref<HTMLButtonElement>}\n      {...aria}\n    >\n      {children}\n    </button>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Content                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipContentProps {\n  children?: ReactNode;\n  side?: TooltipSide;\n  align?: TooltipAlign;\n  /** Gap between anchor and tooltip (px). */\n  sideOffset?: number;\n  /** Draw an arrow pointing at the anchor. */\n  arrow?: boolean;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: TooltipPoint;\n  className?: string;\n}\n\nexport function TooltipContent({\n  children,\n  side = \"top\",\n  align = \"center\",\n  sideOffset,\n  arrow = false,\n  anchorPoint,\n  className,\n}: TooltipContentProps) {\n  const {\n    open,\n    contentId,\n    labelId,\n    asLabel,\n    followCursor,\n    anchor,\n    floatingRef,\n    pointer,\n    setEmpty,\n    close,\n    onContentEnter,\n    onContentLeave,\n  } = useTooltipContext(\"TooltipContent\");\n  const empty = isEmpty(children);\n  const arrowRef = useRef<SVGSVGElement | null>(null);\n\n  useLayoutEffect(() => {\n    setEmpty(empty);\n  }, [empty, setEmpty]);\n\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <>\n      {/* The name has to exist while the label is closed, so it lives apart from the surface. */}\n      {asLabel && !empty ? (\n        <span id={labelId} hidden>\n          {children}\n        </span>\n      ) : null}\n      <AnimatePresence>\n        {open && !empty ? (\n          <TooltipPositioner\n            key=\"tooltip\"\n            anchor={anchor}\n            floatingRef={floatingRef}\n            arrowRef={arrow ? arrowRef : undefined}\n            anchorPoint={anchorPoint}\n            followCursor={followCursor}\n            side={side}\n            align={align}\n            sideOffset={sideOffset}\n            onDismiss={close}\n            pointer={pointer}\n          >\n            {(positioned, isPresent, placedSide) => (\n              <TooltipSurface\n                id={asLabel ? undefined : contentId}\n                aria-hidden={asLabel || undefined}\n                ready={positioned}\n                onPointerEnter={onContentEnter}\n                onPointerLeave={onContentLeave}\n                style={{ pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\" }}\n                className={cn(\n                  \"max-w-[min(20rem,calc(100vw-16px))] whitespace-normal break-words\",\n                  arrow && \"relative\",\n                  className,\n                )}\n              >\n                {children}\n                {arrow ? <TooltipArrow ref={arrowRef} side={placedSide} /> : null}\n              </TooltipSurface>\n            )}\n          </TooltipPositioner>\n        ) : null}\n      </AnimatePresence>\n    </>,\n    document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip (root, or one-line shorthand when given `content`)                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipProps\n  extends TooltipRootProps,\n    Omit<TooltipContentProps, \"children\" | \"className\"> {\n  /**\n   * Shorthand: the tooltip's content. With it, `children` is the trigger\n   * element; without it, compose `TooltipTrigger` and `TooltipContent`.\n   */\n  content?: ReactNode;\n  /** Shorthand only: classes for the tooltip surface. */\n  className?: string;\n}\n\nexport function Tooltip(props: TooltipProps) {\n  if (!(\"content\" in props)) return <TooltipRoot {...props} />;\n  const {\n    content,\n    children,\n    side,\n    align,\n    sideOffset,\n    arrow,\n    anchorPoint,\n    className,\n    ...root\n  } = props;\n  if (!root.anchorRef && !isValidElement(children)) return children ?? null;\n  return (\n    <TooltipRoot {...root} initialEmpty={isEmpty(content)}>\n      {isValidElement(children) ? <TooltipTrigger asChild>{children}</TooltipTrigger> : null}\n      <TooltipContent\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        arrow={arrow}\n        anchorPoint={anchorPoint}\n        className={className}\n      >\n        {content}\n      </TooltipContent>\n    </TooltipRoot>\n  );\n}\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/game-sounds.ts","type":"util","content":"// Synthesized game feedback (gem chimes that climb with a streak, a mine's\n// blast, a cash-out arpeggio) built from oscillators and noise at runtime, so\n// there are no audio files to ship.\n\nimport { getAudioContext, retainAudioContext } from \"@/lib/audio-context\";\n\nconst MASTER_GAIN = 0.12;\n// Pentatonic steps from G5, so any run of gems stays in key. Two octaves of\n// climb tops out at G7, bright but not shrill.\nconst BASE = 783.99;\nconst SCALE = [0, 2, 4, 7, 9];\n\n// The context is shared page-wide (lib/audio-context.ts). The master gain\n// belongs to it, so it is rebuilt if the context was closed and recreated.\nlet master: GainNode | null = null;\n\nfunction ensureContext() {\n  const ctx = getAudioContext();\n  if (ctx && master?.context !== ctx) {\n    master = ctx.createGain();\n    master.gain.value = MASTER_GAIN;\n    master.connect(ctx.destination);\n  }\n  return ctx;\n}\n\n/** A pitched tone with a fast attack and exponential decay. */\nfunction tone(\n  context: AudioContext,\n  frequency: number,\n  at: number,\n  { duration = 0.18, type = \"sine\", gain = 1 }: { duration?: number; type?: OscillatorType; gain?: number } = {},\n) {\n  if (!master) return;\n  const osc = context.createOscillator();\n  const env = context.createGain();\n  osc.type = type;\n  osc.frequency.setValueAtTime(frequency, at);\n  env.gain.setValueAtTime(0.0001, at);\n  env.gain.exponentialRampToValueAtTime(gain, at + 0.005);\n  env.gain.exponentialRampToValueAtTime(0.0001, at + duration);\n  osc.connect(env).connect(master);\n  osc.start(at);\n  osc.stop(at + duration + 0.02);\n  osc.onended = () => env.disconnect();\n}\n\nfunction note(step: number) {\n  const octave = Math.floor(step / SCALE.length);\n  const semitones = SCALE[step % SCALE.length] + octave * 12;\n  return BASE * 2 ** (semitones / 12);\n}\n\nexport interface GameSounds {\n  /** Unlock audio; call from a click or key handler. */\n  prepare: () => void;\n  /** A gem; `streak` counts gems in a row, from 1. */\n  gem: (streak: number) => void;\n  /** A mine. */\n  mine: () => void;\n  /** Cashing out. */\n  cashOut: () => void;\n  dispose: () => void;\n}\n\n/**\n * Lazily-initialized game sounds on the page's shared AudioContext. Nothing is created\n * until the first call. Safe during SSR and without Web Audio: every call\n * becomes a silent no-op.\n */\nexport function createGameSounds(): GameSounds {\n  const release = retainAudioContext();\n  let disposed = false;\n  const live = () => (disposed ? null : ensureContext());\n\n  return {\n    prepare() {\n      live();\n    },\n    gem(streak) {\n      const context = live();\n      if (!context) return;\n      const now = context.currentTime;\n      // Every fifth gem in a row leads in with a quick two-note run, so the\n      // chime waits for it. Notes can't start in the past.\n      const run = streak % 5 === 0;\n      const at = run ? now + 0.06 : now;\n      if (run) {\n        [-2, -1].forEach((offset, index) => {\n          tone(context, note(Math.max(0, streak - 1 + offset)), now + index * 0.03, { gain: 0.35, duration: 0.08 });\n        });\n      }\n      // Each gem in a row climbs the scale, capped two octaves up.\n      const frequency = note(Math.min(streak - 1, SCALE.length * 2));\n      // Glassy: a sine fundamental with a quiet triangle an octave up.\n      tone(context, frequency, at, { gain: 0.9 });\n      tone(context, frequency * 2, at, { type: \"triangle\", gain: 0.15, duration: 0.12 });\n      // From the third gem in a row, a sparkle a fifth above answers the chime.\n      if (streak >= 3) tone(context, frequency * 1.5, at + 0.05, { gain: 0.25, duration: 0.1 });\n    },\n    mine() {\n      const context = live();\n      if (!context || !master) return;\n      const now = context.currentTime;\n      // A burst of noise swept down through a low-pass filter…\n      const length = Math.ceil(context.sampleRate * 0.45);\n      const buffer = context.createBuffer(1, length, context.sampleRate);\n      const data = buffer.getChannelData(0);\n      for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2;\n      const noise = context.createBufferSource();\n      noise.buffer = buffer;\n      const filter = context.createBiquadFilter();\n      filter.type = \"lowpass\";\n      filter.frequency.setValueAtTime(1600, now);\n      filter.frequency.exponentialRampToValueAtTime(180, now + 0.4);\n      const env = context.createGain();\n      env.gain.setValueAtTime(1.1, now);\n      env.gain.exponentialRampToValueAtTime(0.0001, now + 0.45);\n      noise.connect(filter).connect(env).connect(master);\n      noise.start(now);\n      noise.onended = () => env.disconnect();\n      // …over a low thump that drops in pitch.\n      const thump = context.createOscillator();\n      const thumpEnv = context.createGain();\n      thump.type = \"sine\";\n      thump.frequency.setValueAtTime(110, now);\n      thump.frequency.exponentialRampToValueAtTime(42, now + 0.3);\n      thumpEnv.gain.setValueAtTime(1.4, now);\n      thumpEnv.gain.exponentialRampToValueAtTime(0.0001, now + 0.32);\n      thump.connect(thumpEnv).connect(master);\n      thump.start(now);\n      thump.stop(now + 0.34);\n      thump.onended = () => thumpEnv.disconnect();\n    },\n    cashOut() {\n      const context = live();\n      if (!context) return;\n      const now = context.currentTime;\n      // A rising major arpeggio, C5 E5 G5 C6, ending on a held shimmer.\n      [523.25, 659.25, 783.99, 1046.5].forEach((frequency, index) => {\n        const last = index === 3;\n        tone(context, frequency, now + index * 0.06, { type: \"triangle\", gain: 0.6, duration: last ? 0.45 : 0.14 });\n        if (last) tone(context, frequency * 2, now + index * 0.06, { gain: 0.15, duration: 0.5 });\n      });\n    },\n    dispose() {\n      if (disposed) return;\n      disposed = true;\n      release();\n    },\n  };\n}\n"},{"path":"components/motion/action-swap.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\n// The arriving layer springs in, so a second press mid-swap bends it toward\n// the new target instead of restarting. The leaving layer is a little quicker,\n// still long enough to cross the arrival so the swap reads as one morph.\nconst BLUR_TRANSITION = SPRING_SWAP;\nconst BLUR_EXIT_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\n// A light blur softens the crossing without smearing small glyphs.\nconst SWAP_BLUR = \"blur(2px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.9, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.96,\n      filter: SWAP_BLUR,\n      transition: BLUR_EXIT_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_EXIT_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n  ghost: \"text-muted-foreground hover:bg-muted/60 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\n        \"relative -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom\",\n        className,\n      )}\n      style={{\n        clipPath: \"inset(0 -999px)\",\n        WebkitClipPath: \"inset(0 -999px)\",\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-[0.08em] inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  // Own states: a label that swaps away and straight back\n                  // re-enters mid-exit, and only owned targets are restored.\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            // Truncation lives on the layer that holds the text — the layer\n            // moves as a whole, so clipping it never eats the roll.\n            className=\"absolute left-0 top-[0.08em] inline-block max-w-full truncate will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  style,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      // A label of a different length resizes the button: glide the width on\n      // the soft layout spring, and only when the value changes, so unrelated\n      // page reflow never animates the button.\n      layout\n      layoutDependency={activeItem.id}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={{ ...SPRING_PRESS, layout: layoutTransition }}\n      // Layout animations only correct a radius set inline.\n      style={{ borderRadius: 9999, ...style }}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {/* Position-only layout keeps the contents from stretching with the\n          button while its width glides. */}\n      {hasIcon ? (\n        <motion.span\n          layout=\"position\"\n          layoutDependency={activeItem.id}\n          transition={layoutTransition}\n          className=\"inline-flex shrink-0\"\n        >\n          <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n            {activeItem.icon ?? null}\n          </ActionSwapIcon>\n        </motion.span>\n      ) : null}\n      {!iconOnly ? (\n        <motion.span\n          layout=\"position\"\n          layoutDependency={activeItem.id}\n          transition={layoutTransition}\n          className=\"inline-flex min-w-0 max-w-full\"\n        >\n          <ActionSwapText value={activeItem.id} animation={animation}>\n            {activeItem.label}\n          </ActionSwapText>\n        </motion.span>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-muted/60\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/metallic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { forwardRef, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MetallicButtonProps extends Omit<\n  ButtonProps,\n  \"ripple\" | \"variant\"\n> {\n  /** Stops the traveling reflection while preserving the chrome rim. */\n  paused?: boolean;\n}\n\n// The rim and highlight drift separately so the material stays quiet and reflective.\nconst SILVER_DRIFT = {\n  duration: 8,\n  ease: EASE_IN_OUT,\n  repeat: Infinity,\n};\n\nconst CHROME_SHIMMER = {\n  duration: 2.4,\n  ease: EASE_IN_OUT,\n};\n\nexport const MetallicButton = forwardRef<\n  HTMLButtonElement,\n  MetallicButtonProps\n>(function MetallicButton(\n  {\n    size = \"md\",\n    paused = false,\n    className,\n    children,\n    onHoverStart,\n    onHoverEnd,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const still = paused || Boolean(reduce);\n  const [hovered, setHovered] = useState(false);\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size={size}\n      onHoverStart={(event, info) => {\n        setHovered(true);\n        onHoverStart?.(event, info);\n      }}\n      onHoverEnd={(event, info) => {\n        setHovered(false);\n        onHoverEnd?.(event, info);\n      }}\n      className={cn(\n        \"group relative isolate overflow-hidden border-0 bg-transparent text-foreground\",\n        \"hover:bg-transparent hover:text-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        \"shadow-[0_8px_22px_rgba(0,0,0,0.16)]\",\n        size === \"icon\" && \"rounded-full\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]\"\n        animate={still ? undefined : { x: [\"0%\", \"13%\", \"0%\"] }}\n        transition={still ? undefined : SILVER_DRIFT}\n      />\n\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen\"\n        animate={still ? undefined : { x: hovered ? \"310%\" : \"0%\" }}\n        transition={still ? undefined : CHROME_SHIMMER}\n      />\n\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40\"\n      />\n\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]\"\n      />\n\n      <span className=\"relative z-10 inline-flex items-center justify-center gap-2\">\n        {children}\n      </span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  // Own states: a label that swaps away and straight back\n                  // re-enters mid-exit, and only owned targets are restored.\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-listbox.ts","type":"util","content":"\"use client\";\n\nimport {\n  type KeyboardEvent,\n  type RefObject,\n  useEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\n\nexport interface ListboxRow {\n  id: string;\n  label: string;\n  disabled?: boolean;\n}\n\n// How long typed characters keep building one typeahead query.\nconst TYPEAHEAD_MS = 500;\n\n/**\n * Keyboard model for a popup listbox whose options are focusable buttons.\n *\n * The active option is resolved during render through `useRowCursor`, so the\n * roving `tabIndex` and the next key press always agree with what is on\n * screen. Opening starts on the selected option, or the first enabled one.\n * Arrow keys, Home and End move between enabled options, and printable\n * characters jump to the next option whose label starts with what was typed.\n * DOM focus follows the active option while the list is open; each option\n * carries its id in `data-listbox-id` so the list can find it.\n */\nexport function useListbox({\n  open,\n  rows,\n  selected,\n  list,\n}: {\n  open: boolean;\n  rows: readonly ListboxRow[];\n  selected: string | undefined;\n  list: RefObject<HTMLElement | null>;\n}) {\n  const enabled = useMemo(() => rows.filter((row) => !row.disabled), [rows]);\n  const { activeIndex, moveTo, moveActive } = useRowCursor(enabled, \"\");\n  useOnOpen(open, () =>\n    moveTo(enabled.some((row) => row.id === selected) ? (selected ?? null) : null),\n  );\n  const activeId = enabled[activeIndex]?.id;\n  const typed = useRef({ text: \"\", at: 0 });\n  // A list rendered open does not take focus from the page on mount; focus\n  // follows only once it has been opened, or once focus is already inside.\n  const openedOnMount = useRef(open);\n  useEffect(() => {\n    if (!open) openedOnMount.current = false;\n  }, [open]);\n\n  useEffect(() => {\n    if (!open || activeId === undefined) return;\n    if (\n      openedOnMount.current &&\n      !list.current?.contains(document.activeElement)\n    )\n      return;\n    const option = Array.from(\n      list.current?.querySelectorAll<HTMLElement>(\"[data-listbox-id]\") ?? [],\n    ).find((element) => element.dataset.listboxId === activeId);\n    option?.focus({ preventScroll: false });\n  }, [open, activeId, list]);\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    const { key } = event;\n    if (key === \"ArrowDown\" || key === \"ArrowUp\") {\n      event.preventDefault();\n      moveActive(key === \"ArrowDown\" ? 1 : -1);\n    } else if (key === \"Home\" || key === \"End\") {\n      event.preventDefault();\n      const row = key === \"Home\" ? enabled[0] : enabled[enabled.length - 1];\n      if (row) moveTo(row.id);\n    } else if (\n      key.length === 1 &&\n      key !== \" \" &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      const now = Date.now();\n      const char = key.toLowerCase();\n      const text =\n        now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + char : char;\n      typed.current = { text, at: now };\n      // A repeated single letter cycles through the options that start with it.\n      const from = text.length === 1 ? activeIndex + 1 : activeIndex;\n      for (let step = 0; step < enabled.length; step++) {\n        const row = enabled[(from + step) % enabled.length];\n        if (row.label.toLowerCase().startsWith(text)) {\n          moveTo(row.id);\n          break;\n        }\n      }\n    }\n  };\n\n  return { activeId, onKeyDown };\n}\n"},{"path":"components/motion/tooltip/grace.ts","type":"util","content":"type Point = { x: number; y: number };\ntype Box = { left: number; top: number; right: number; bottom: number };\n\n/**\n * The corridor a pointer may cross between the trigger and the open tooltip\n * without closing it: the convex hull of where it left and the box it is\n * heading for. A fixed timeout either cuts a slow diagonal move short or keeps\n * the label alive after the pointer has plainly gone elsewhere; this follows\n * the path itself.\n */\nexport function graceArea(exit: Point, target: Box): Point[] {\n  // A few pixels either side of the exit point give the corridor some width at\n  // its narrow end, so a hand that drifts sideways while it moves stays inside.\n  const pad = 5;\n  return hull([\n    { x: exit.x - pad, y: exit.y - pad },\n    { x: exit.x + pad, y: exit.y - pad },\n    { x: exit.x - pad, y: exit.y + pad },\n    { x: exit.x + pad, y: exit.y + pad },\n    { x: target.left, y: target.top },\n    { x: target.right, y: target.top },\n    { x: target.right, y: target.bottom },\n    { x: target.left, y: target.bottom },\n  ]);\n}\n\nexport function inBox(point: Point, box: Box) {\n  return (\n    point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom\n  );\n}\n\nexport function inPolygon(point: Point, polygon: Point[]) {\n  let inside = false;\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const a = polygon[i];\n    const b = polygon[j];\n    if (\n      a.y > point.y !== b.y > point.y &&\n      point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x\n    ) {\n      inside = !inside;\n    }\n  }\n  return inside;\n}\n\n// Monotone chain; the corridor is always convex, so a hull is all it needs.\nfunction hull(points: Point[]) {\n  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);\n  const cross = (o: Point, a: Point, b: Point) =>\n    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n  const lower: Point[] = [];\n  for (const p of sorted) {\n    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {\n      lower.pop();\n    }\n    lower.push(p);\n  }\n  const upper: Point[] = [];\n  for (let i = sorted.length - 1; i >= 0; i--) {\n    const p = sorted[i];\n    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {\n      upper.pop();\n    }\n    upper.push(p);\n  }\n  lower.pop();\n  upper.pop();\n  return lower.concat(upper);\n}\n\n/**\n * How the user last reached for the page. A focus that a mouse press caused is\n * not a request to read the label: the pointer is already on the control and\n * hovering showed it. A focus that followed a key — Tab, or a roving arrow key\n * moving focus in script — is the only way a keyboard user sees it at all.\n */\nlet modality: \"keyboard\" | \"pointer\" = \"keyboard\";\nlet listening = false;\n\nexport function watchModality() {\n  if (listening || typeof window === \"undefined\") return;\n  listening = true;\n  window.addEventListener(\"keydown\", () => (modality = \"keyboard\"), true);\n  window.addEventListener(\"pointerdown\", () => (modality = \"pointer\"), true);\n}\n\nexport function focusFromKeyboard() {\n  return modality === \"keyboard\";\n}\n"},{"path":"components/motion/tooltip/positioner.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, type Ref, useCallback, useState } from \"react\";\nimport { type TooltipSide, useTooltipPosition } from \"./use-position\";\n\ntype PositionProps = Parameters<typeof useTooltipPosition>[0];\n\n/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */\nexport function TooltipPositioner({\n  children,\n  ...position\n}: Omit<PositionProps, \"open\" | \"onPosition\"> & {\n  children: (ready: boolean, present: boolean, side: TooltipSide) => ReactNode;\n}) {\n  const present = useIsPresent();\n  const [ready, setReady] = useState(false);\n  const [side, setSide] = useState<TooltipSide>(position.side);\n  const onPosition = useCallback((placed: TooltipSide) => {\n    setReady(true);\n    setSide(placed);\n  }, []);\n  useTooltipPosition({ ...position, open: present, onPosition });\n  return (\n    <span\n      ref={position.floatingRef}\n      inert={!present}\n      aria-hidden={!present || undefined}\n      className=\"pointer-events-none fixed left-0 top-0 z-[9999] w-max\"\n      style={{ visibility: \"hidden\", maxWidth: \"calc(100vw - 16px)\" }}\n    >\n      {children(ready, present, side)}\n    </span>\n  );\n}\n\n// Each arrow is a 12px box whose base sits on the surface's inner border edge,\n// so its fill covers the border beneath it and only the two outer edges stroke.\nconst ARROW: Record<TooltipSide, { path: string; style: CSSProperties }> = {\n  top: { path: \"M0 6L6 12L12 6\", style: { top: \"calc(100% - 6px)\" } },\n  bottom: { path: \"M0 6L6 0L12 6\", style: { bottom: \"calc(100% - 6px)\" } },\n  left: { path: \"M6 0L12 6L6 12\", style: { left: \"calc(100% - 6px)\" } },\n  right: { path: \"M6 0L0 6L6 12\", style: { right: \"calc(100% - 6px)\" } },\n};\n\nexport function TooltipArrow({ side, ref }: { side: TooltipSide; ref: Ref<SVGSVGElement> }) {\n  const { path, style } = ARROW[side];\n  return (\n    <svg\n      ref={ref}\n      aria-hidden=\"true\"\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      className=\"pointer-events-none absolute overflow-visible\"\n      style={style}\n    >\n      <path d={`${path}Z`} style={{ fill: \"var(--tooltip-arrow-fill, transparent)\" }} />\n      <path\n        d={path}\n        fill=\"none\"\n        strokeWidth=\"1\"\n        style={{ stroke: \"var(--tooltip-arrow-stroke, transparent)\" }}\n      />\n    </svg>\n  );\n}\n"},{"path":"components/motion/tooltip/use-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  arrow as arrowMiddleware,\n  autoUpdate,\n  computePosition,\n  flip,\n  hide,\n  offset,\n  type Placement,\n  shift,\n  type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { type RefObject, useCallback, useLayoutEffect, useRef } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipAlign = \"start\" | \"center\" | \"end\";\nexport type TooltipPoint = { x: number; y: number };\nexport type TooltipAnchor = HTMLElement | SVGElement;\n\nconst ORIGIN: Record<TooltipSide, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n  open,\n  anchor,\n  floatingRef,\n  arrowRef,\n  anchorPoint,\n  followCursor,\n  side,\n  align,\n  sideOffset,\n  onDismiss,\n  onPosition,\n  pointer,\n}: {\n  open: boolean;\n  anchor: TooltipAnchor | null;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  arrowRef?: RefObject<SVGSVGElement | null>;\n  anchorPoint?: TooltipPoint;\n  followCursor: boolean;\n  side: TooltipSide;\n  align: TooltipAlign;\n  sideOffset?: number;\n  onDismiss: () => void;\n  onPosition: (side: TooltipSide) => void;\n  pointer: ReturnType<typeof useTooltipPointer>;\n}) {\n  const { cursor, onMove } = pointer;\n  const preferred: Placement = align === \"center\" ? side : `${side}-${align}`;\n  const cursorSide = useRef<Placement | null>(null);\n  useLayoutEffect(() => {\n    cursorSide.current = open ? preferred : null;\n  }, [open, preferred]);\n  const frame = useRef<number | null>(null);\n  const version = useRef(0);\n  const update = useRef<() => void>(() => {});\n  const schedule = useCallback(() => {\n    // Invalidate older async calculations as soon as new geometry is requested.\n    version.current++;\n    if (frame.current !== null) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = null;\n      update.current();\n    });\n  }, []);\n  useLayoutEffect(() => {\n    onMove.current = schedule;\n    return () => {\n      onMove.current = null;\n    };\n  }, [onMove, schedule]);\n  const pointX = anchorPoint?.x;\n  const pointY = anchorPoint?.y;\n\n  // Latest committed inputs are read without recreating observers for every period/content update.\n  useLayoutEffect(() => {\n    update.current = () => {\n      const floating = floatingRef.current;\n      if (!open || !anchor || !floating) return;\n      const revision = ++version.current;\n      const currentCursor = followCursor ? cursor.current : null;\n      const virtual = Boolean(currentCursor) || pointX !== undefined || pointY !== undefined;\n      const reference: Element | VirtualElement = virtual\n        ? {\n            contextElement: anchor,\n            getBoundingClientRect: () => {\n              const rect = anchor.getBoundingClientRect();\n              const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);\n              const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);\n              return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };\n            },\n          }\n        : anchor;\n      const arrowElement = arrowRef?.current ?? null;\n      void computePosition(reference, floating, {\n        strategy: \"fixed\",\n        placement: currentCursor ? (cursorSide.current ?? preferred) : preferred,\n        middleware: [\n          offset(sideOffset ?? (currentCursor ? 12 : 8)),\n          flip({ padding: 8 }),\n          shift({ padding: 8 }),\n          ...(arrowElement ? [arrowMiddleware({ element: arrowElement, padding: 10 })] : []),\n          // A label left floating over whatever scrolled into its trigger's place\n          // describes nothing there. Points inside a chart move with the chart.\n          ...(virtual ? [] : [hide({ strategy: \"referenceHidden\" })]),\n        ],\n      }).then(({ x, y, placement, middlewareData }) => {\n        if (version.current !== revision || !floating.isConnected) return;\n        // Hold the chosen side for this hover session. Crossing a flip threshold\n        // repeatedly must not bounce the surface above and below the pointer.\n        if (currentCursor) cursorSide.current = placement;\n        else cursorSide.current = null;\n        const placedSide = placement.split(\"-\")[0] as TooltipSide;\n        const dpr = window.devicePixelRatio || 1;\n        floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;\n        floating.style.setProperty(\"--tooltip-origin\", ORIGIN[placedSide]);\n        // A box with no size has no clipped state to report, only a missing layout.\n        const box = anchor.getBoundingClientRect();\n        const clipped =\n          Boolean(middlewareData.hide?.referenceHidden) && (box.width > 0 || box.height > 0);\n        floating.style.visibility = clipped ? \"hidden\" : \"visible\";\n        floating.dataset.placement = placement;\n        const surface = arrowElement?.parentElement;\n        if (arrowElement && surface) {\n          // Measured against the positioner, drawn inside the surface's border.\n          const { x: ax, y: ay } = middlewareData.arrow ?? {};\n          if (ax != null) arrowElement.style.left = `${ax - surface.clientLeft}px`;\n          if (ay != null) arrowElement.style.top = `${ay - surface.clientTop}px`;\n          // Paint with whatever the surface resolved to, so a restyled tooltip\n          // keeps a matching arrow without a second set of classes.\n          const style = getComputedStyle(surface);\n          arrowElement.style.setProperty(\"--tooltip-arrow-fill\", style.backgroundColor);\n          arrowElement.style.setProperty(\"--tooltip-arrow-stroke\", style.borderTopColor);\n        }\n        onPosition(placedSide);\n      });\n    };\n    if (open) schedule();\n  }, [\n    open,\n    anchor,\n    floatingRef,\n    arrowRef,\n    followCursor,\n    pointX,\n    pointY,\n    preferred,\n    sideOffset,\n    schedule,\n    onPosition,\n    cursor,\n  ]);\n\n  useLayoutEffect(() => {\n    const floating = floatingRef.current;\n    if (!open || !anchor || !floating) return;\n    const stop = autoUpdate(anchor, floating, schedule);\n    const onScroll = () => {\n      // A stationary pointer no longer describes the same chart point after scrolling.\n      if (followCursor && cursor.current) onDismiss();\n    };\n    window.addEventListener(\"scroll\", onScroll, true);\n    return () => {\n      stop();\n      window.removeEventListener(\"scroll\", onScroll, true);\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n      frame.current = null;\n    };\n  }, [open, anchor, floatingRef, followCursor, onDismiss, schedule, cursor]);\n\n  useLayoutEffect(\n    () => () => {\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n}\n\n/** Pointer lifetime belongs to the anchor, including the opening delay. */\nexport function useTooltipPointer(anchor: TooltipAnchor | null, followCursor: boolean) {\n  const cursor = useRef<TooltipPoint | null>(null);\n  const onMove = useRef<(() => void) | null>(null);\n  const pointerFocus = useRef(false);\n  useLayoutEffect(() => {\n    if (!anchor || !followCursor) return;\n    const point = (event: PointerEvent) => {\n      if (event.type === \"pointermove\" && event.pointerType === \"touch\" && !event.buttons) return;\n      cursor.current = { x: event.clientX, y: event.clientY };\n      if (event.type === \"pointerdown\") pointerFocus.current = true;\n      onMove.current?.();\n    };\n    const keyboard = () => {\n      cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const focus = () => {\n      if (!pointerFocus.current) cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const leave = () => {\n      cursor.current = null;\n    };\n    anchor.addEventListener(\"pointerenter\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointermove\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerdown\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerleave\", leave);\n    anchor.addEventListener(\"pointercancel\", leave);\n    anchor.addEventListener(\"keydown\", keyboard);\n    anchor.addEventListener(\"focusin\", focus);\n    return () => {\n      cursor.current = null;\n      anchor.removeEventListener(\"pointerenter\", point as EventListener);\n      anchor.removeEventListener(\"pointermove\", point as EventListener);\n      anchor.removeEventListener(\"pointerdown\", point as EventListener);\n      anchor.removeEventListener(\"pointerleave\", leave);\n      anchor.removeEventListener(\"pointercancel\", leave);\n      anchor.removeEventListener(\"keydown\", keyboard);\n      anchor.removeEventListener(\"focusin\", focus);\n    };\n  }, [anchor, followCursor]);\n\n  return { cursor, onMove };\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** Presentation only: the unanimated parent owns measurement and positioning. */\nexport function TooltipSurface({\n  children,\n  side: _side = \"top\",\n  className,\n  ref,\n  ready = true,\n  style,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  /** Start the entrance only after the positioning layer has been measured. */\n  ready?: boolean;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      initial={closed}\n      animate={{\n        ...(ready ? { opacity: 1, scale: 1 } : closed),\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }}\n      exit={{ ...closed, transition: { duration: 0.12, ease: EASE_OUT } }}\n      style={{ transformOrigin: \"var(--tooltip-origin, center)\", ...style }}\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/audio-context.ts","type":"util","content":"// One AudioContext for every synthesized sound on the page (lib/tick-sound.ts,\n// lib/game-sounds.ts). Browsers cap concurrent contexts, and a page can show a\n// date made of several WheelPickers next to a game, so they all share this\n// one. It is created on first use and closed once every player is disposed.\n\ntype AudioContextCtor = typeof AudioContext;\n\nlet context: AudioContext | null = null;\nlet users = 0;\n\n/**\n * Registers a sound player. Call the returned function when the player is\n * disposed; the context closes after the last one.\n */\nexport function retainAudioContext() {\n  users++;\n  let released = false;\n  return () => {\n    if (released) return;\n    released = true;\n    users--;\n    if (users === 0 && context) {\n      void context.close();\n      context = null;\n    }\n  };\n}\n\n/**\n * The shared context, created on first call and resumed if suspended. Null\n * during SSR and in browsers without Web Audio.\n */\nexport function getAudioContext(): AudioContext | null {\n  if (!context) {\n    if (typeof window === \"undefined\") return null;\n    const Ctor =\n      window.AudioContext ??\n      (window as unknown as { webkitAudioContext?: AudioContextCtor }).webkitAudioContext;\n    if (!Ctor) return null;\n    context = new Ctor();\n  }\n  // Browsers may refuse outside a user gesture. Players call this from\n  // pointer and key handlers first, so a later call finds it running.\n  if (context.state === \"suspended\") void context.resume().catch(() => undefined);\n  return context;\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/hooks/use-on-open.ts","type":"util","content":"\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"},{"path":"lib/hooks/use-row-cursor.ts","type":"util","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/previews/casino/crash.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { Crash, formatMultiplier } from \"@/components/motion/crash\";\n\nexport function CrashPreview() {\n  const [best, setBest] = useState(0);\n\n  return (\n    <div className=\"flex w-full max-w-md flex-col items-center gap-3\">\n      <p className=\"text-sm text-muted-foreground tabular-nums\">\n        Best cash out <span className=\"font-medium text-foreground\">{formatMultiplier(best)}</span>\n      </p>\n      <Crash onCashOut={(multiplier) => setBest((value) => Math.max(value, multiplier))} />\n    </div>\n  );\n}\n"}]}