{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mines","type":"registry:block","title":"Mines","description":"A mines board: pick tiles to find gems, each one raising the multiplier, and cash out before you hit a mine. Each gem you find is numbered in pick order, an odds ring shows the chance the next pick is safe, and the round's end reveals every tile in a ripple from your last pick before a ticket-stub result card rises with the outcome, stats and a miniature of your run. Distinct synthesized sounds for gems, streaks, mines and cash outs; keyboard playable, reduced-motion safe.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/mines.tsx","type":"registry:component","target":"@components/motion/mines.tsx","content":"\"use client\";\n// beui.dev/components/blocks/mines\n\nimport { Gem, Volume2, VolumeX } from \"lucide-react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { ActionSwapCascadeIcon } from \"@/components/motion/action-swap-cascade\";\nimport {\n  AdaptiveStepper,\n  AdaptiveStepperDecrement,\n  AdaptiveStepperIncrement,\n  AdaptiveStepperValue,\n} from \"@/components/motion/adaptive-stepper\";\nimport { Button } from \"@/components/motion/button\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { createGameSounds, type GameSounds } from \"@/lib/game-sounds\";\nimport { PresenceGate, type PresenceGateRenderProps } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MinesTile = \"gem\" | \"mine\";\n\nexport interface MinesProps {\n  /** Tiles per side, rounded, at least 2. Default 5 (a 5 × 5 board). Changing it ends the round. */\n  size?: number;\n  /** Mines on the board (controlled), rounded and kept between 1 and tiles − 1. Takes effect from the next round. */\n  mines?: number;\n  /** Initial mine count when uncontrolled. Default 3. */\n  defaultMines?: number;\n  /** Called when the mine count changes between rounds. */\n  onMinesChange?: (mines: number) => void;\n  /** Called for every tile picked during a round; a mine reports multiplier 0 and ends it. */\n  onReveal?: (tile: number, result: MinesTile, multiplier: number) => void;\n  /** Called when the player cashes out, or clears every gem. */\n  onCashOut?: (multiplier: number, gems: number) => void;\n  /** Play synthesized sounds (controlled): gem chimes, a mine blast and a cash-out arpeggio. */\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  /** Show the result card when a round ends. Default true. */\n  showResult?: boolean;\n  /** Accessible name for the game. Default \"Mines\". */\n  \"aria-label\"?: string;\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"playing\" | \"busted\" | \"cashed\";\n\n/**\n * Fair payout after `gems` safe picks on a board of `tiles` with `mines`:\n * the inverse of the chance of picking that many gems in a row. `mines` is\n * kept between 0 and tiles − 1 and `gems` between 0 and the safe tiles, so\n * the result is always finite.\n */\nexport function minesMultiplier(tiles: number, mines: number, gems: number) {\n  const safeMines = Math.max(0, Math.min(Math.round(mines), tiles - 1));\n  const safeGems = Math.max(0, Math.min(Math.round(gems), tiles - safeMines));\n  let multiplier = 1;\n  for (let i = 0; i < safeGems; i++) multiplier *= (tiles - i) / (tiles - safeMines - i);\n  return multiplier;\n}\n\n// Revealed icons arrive from a quarter size, transparent and blurred, on a\n// spring with no bounce. A round's closing reveal ripples outward from the\n// last pick, one ring of tiles every 25ms.\nconst ICON_SPRING = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst RIPPLE_STEP = 0.025;\n// A mine shakes once, quickly: 300ms, settling to rest.\nconst SHAKE = { duration: 0.3, ease: EASE_OUT } as const;\n// A found gem's tile springs back from a slight squash.\nconst GEM_POP = { type: \"spring\", duration: 0.35, bounce: 0.4 } as const;\n// The cash-out pulse runs through the picked gems 40ms apart over 350ms, in\n// seconds. The result opens once the last tile's closing animation is done.\nconst PULSE_STEP = 0.04;\nconst PULSE_TIME = 0.35;\n// Exits are shorter than entrances.\nconst EXIT = { duration: 0.12, ease: EASE_OUT } as const;\n\n/**\n * A mines board: pick tiles to find gems, each raising the multiplier, and\n * cash out before you hit a mine. The round's end reveals every tile.\n */\nexport function Mines({\n  size: sizeProp = 5,\n  mines: minesProp,\n  defaultMines = 3,\n  onMinesChange,\n  onReveal,\n  onCashOut,\n  sound,\n  defaultSound = false,\n  onSoundChange,\n  showResult = true,\n  \"aria-label\": ariaLabel = \"Mines\",\n  className,\n}: MinesProps) {\n  const reduce = useReducedMotion() ?? false;\n  const size = Math.max(2, Math.round(sizeProp) || 2);\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"production\" && size !== sizeProp)\n      console.warn(`Mines: size must be a whole number of at least 2; using ${size}.`);\n  }, [size, sizeProp]);\n  const tiles = size * size;\n  const [internalMines, setInternalMines] = useState(defaultMines);\n  const chosenMines = Math.min(Math.max(1, Math.round(minesProp ?? internalMines) || 1), tiles - 1);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [mineSet, setMineSet] = useState<ReadonlySet<number>>(new Set());\n  const [picked, setPicked] = useState<number[]>([]);\n  const [cursor, setCursor] = useState(0);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [resultOpen, setResultOpen] = useState(false);\n  const sectionRef = useRef<HTMLElement>(null);\n  const mainButtonRef = useRef<HTMLButtonElement>(null);\n  const tileRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [, animate] = useAnimate();\n\n  // Only sets state, so the render-time size reset below can call it.\n  const resetBoard = () => {\n    setPhase(\"idle\");\n    setPicked([]);\n    setMineSet(new Set());\n    setResultOpen(false);\n  };\n\n  // A new size renumbers every tile, so the mines placed for the old board no\n  // longer match it. End the round in the same render.\n  const [boardSize, setBoardSize] = useState(size);\n  if (boardSize !== size) {\n    setBoardSize(size);\n    resetBoard();\n    setCursor(0);\n  }\n\n  // Once a round starts, its payout follows the mines actually on the board,\n  // even if a controlled `mines` changes before the next round.\n  const mines = phase === \"idle\" ? chosenMines : mineSet.size;\n  const gems = picked.filter((tile) => !mineSet.has(tile)).length;\n  const multiplier = minesMultiplier(tiles, mines, gems);\n  const next = minesMultiplier(tiles, mines, gems + 1);\n  const over = phase === \"busted\" || phase === \"cashed\";\n  // Chance the next pick is a gem.\n  const safe = (tiles - mines - gems) / (tiles - gems);\n  const lastPick = picked.at(-1) ?? Math.floor(tiles / 2);\n  // Turning showResult off hides an open ticket too.\n  const resultShown = resultOpen && showResult && over;\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 setMines = (value: number) => {\n    // A finished board shows the old count's mines; clear it for the new one.\n    if (over) resetBoard();\n    if (minesProp === undefined) setInternalMines(value);\n    onMinesChange?.(value);\n  };\n\n  const closeResult = () => {\n    setResultOpen(false);\n    mainButtonRef.current?.focus();\n  };\n\n  const start = () => {\n    resetBoard();\n    // Mines are placed when the round starts, never on the server.\n    const order = Array.from({ length: tiles }, (_, i) => i);\n    for (let i = order.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1));\n      [order[i], order[j]] = [order[j], order[i]];\n    }\n    setMineSet(new Set(order.slice(0, chosenMines)));\n    setPhase(\"playing\");\n    if (!reduce) {\n      tileRefs.current.forEach((tile, index) => {\n        if (!tile) return;\n        const wave = Math.floor(index / size) + (index % size);\n        animate(tile, { scale: [0.94, 1], opacity: [0.5, 1] }, { duration: 0.2, delay: wave * 0.025, ease: EASE_OUT });\n      });\n    }\n    setAnnouncement(`Round started with ${chosenMines} ${chosenMines === 1 ? \"mine\" : \"mines\"}.`);\n    if (soundOn) getPlayer().prepare();\n  };\n\n  const cashOut = (finalGems = gems) => {\n    if (phase !== \"playing\" || finalGems === 0) return;\n    const value = minesMultiplier(tiles, mines, finalGems);\n    setPhase(\"cashed\");\n    setAnnouncement(`Cashed out at ${formatMultiplier(value)}.`);\n    if (soundOn) getPlayer().cashOut();\n    onCashOut?.(value, finalGems);\n  };\n\n  const pick = (tile: number) => {\n    if (phase !== \"playing\" || picked.includes(tile)) return;\n    setPicked((current) => [...current, tile]);\n    if (mineSet.has(tile)) {\n      setPhase(\"busted\");\n      setAnnouncement(\"Mine. The round is over.\");\n      if (soundOn) getPlayer().mine();\n      onReveal?.(tile, \"mine\", 0);\n      return;\n    }\n    const found = gems + 1;\n    const node = tileRefs.current[tile];\n    if (node && !reduce) animate(node, { scale: [0.92, 1] }, GEM_POP);\n    const value = minesMultiplier(tiles, mines, found);\n    if (soundOn) getPlayer().gem(found);\n    onReveal?.(tile, \"gem\", value);\n    if (found === tiles - mines) {\n      cashOut(found);\n      return;\n    }\n    setAnnouncement(`Gem. ${formatMultiplier(value)}.`);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, tile: number) => {\n    const row = Math.floor(tile / size);\n    const col = tile % size;\n    const target = {\n      ArrowRight: row * size + Math.min(size - 1, col + 1),\n      ArrowLeft: row * size + Math.max(0, col - 1),\n      ArrowDown: Math.min(size - 1, row + 1) * size + col,\n      ArrowUp: Math.max(0, row - 1) * size + col,\n      Home: row * size,\n      End: row * size + size - 1,\n    }[event.key];\n    if (target === undefined) return;\n    event.preventDefault();\n    setCursor(target);\n    tileRefs.current[target]?.focus();\n  };\n\n  // When the board's closing animation ends, in seconds: unpicked tiles\n  // reveal in a ripple from the last pick, and picked gems pulse on a cash out.\n  let closingEnd = 0;\n  if (over) {\n    for (let tile = 0; tile < tiles; tile++) {\n      const order = picked.indexOf(tile);\n      const end =\n        order === -1\n          ? ringFrom(lastPick, tile, size) * RIPPLE_STEP + ICON_SPRING.duration\n          : phase === \"cashed\" && !reduce\n            ? order * PULSE_STEP + PULSE_TIME\n            : 0;\n      closingEnd = Math.max(closingEnd, end);\n    }\n  }\n\n  // The ticket rises once the closing animation has played. A timer, not an\n  // animation callback, so a cut-short animation can't keep it shut; a new\n  // round or a reset clears it.\n  useEffect(() => {\n    if (!over || !showResult) return;\n    const id = setTimeout(() => {\n      // The main button already says Play again, so a player still on the\n      // board lands there. Focus elsewhere on the page stays put.\n      if (sectionRef.current?.contains(document.activeElement)) mainButtonRef.current?.focus({ preventScroll: true });\n      setResultOpen(true);\n    }, closingEnd * 1000);\n    return () => clearTimeout(id);\n  }, [over, showResult, closingEnd]);\n\n  const toggleSound = () => {\n    const nextSound = !soundOn;\n    if (nextSound) getPlayer().prepare();\n    if (sound === undefined) setInternalSound(nextSound);\n    onSoundChange?.(nextSound);\n  };\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label={ariaLabel}\n      className={cn(\"flex w-full max-w-md flex-col gap-4\", className)}\n      onKeyDown={(event) => {\n        if (event.key !== \"Escape\" || !resultShown) return;\n        // Escape is used up here; an enclosing dialog must not close too.\n        event.stopPropagation();\n        closeResult();\n      }}\n    >\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <AdaptiveStepper\n          value={chosenMines}\n          onValueChange={setMines}\n          min={1}\n          max={tiles - 1}\n          disabled={phase === \"playing\"}\n          formatValueText={(value) => `${value} ${value === 1 ? \"mine\" : \"mines\"}`}\n          aria-label=\"Mine count\"\n        >\n          <AdaptiveStepperDecrement />\n          <AdaptiveStepperValue>\n            {(value) => (\n              <span className=\"inline-flex items-center gap-1.5\">\n                <MineIcon className=\"size-4 text-muted-foreground\" />\n                {value}\n              </span>\n            )}\n          </AdaptiveStepperValue>\n          <AdaptiveStepperIncrement />\n        </AdaptiveStepper>\n\n        <div className=\"text-right\">\n          <div className=\"text-2xl font-semibold text-foreground\">\n            {phase === \"busted\" ? (\n              // The multiplier is lost: struck through, as on the ticket. A plain\n              // span, since a line-through skips the ticker's inline-block digits.\n              <span className=\"text-muted-foreground tabular-nums line-through decoration-2\">\n                {formatMultiplier(multiplier)}\n              </span>\n            ) : (\n              <NumberTicker value={multiplier} decimals={2} format={compact} suffix=\"×\" startOnView={false} duration={0.35} />\n            )}\n          </div>\n          <p className=\"flex items-center justify-end gap-1.5 text-xs text-muted-foreground tabular-nums\">\n            {over ? (\n              phase === \"busted\" ? \"Multiplier lost\" : \"Multiplier\"\n            ) : (\n              <>\n                <OddsRing safe={safe} reduce={reduce} />\n                {phase === \"playing\"\n                  ? `${Math.round(safe * 100)}% safe · next ${formatMultiplier(next)}`\n                  : `First pick ${Math.round(safe * 100)}% safe`}\n              </>\n            )}\n          </p>\n        </div>\n      </div>\n\n      {/* Board panel: 12px tiles + 12px padding give the 24px outer radius. */}\n      <div className=\"relative rounded-3xl bg-muted/40 p-3\">\n        {/* Under the result card the tiles leave the tab order. */}\n        <fieldset\n          inert={resultShown}\n          className=\"m-0 grid min-w-0 gap-2 border-0 p-0\"\n          style={{ gridTemplateColumns: `repeat(${size}, minmax(0, 1fr))` }}\n        >\n          <legend className=\"sr-only\">Tiles</legend>\n          {Array.from({ length: tiles }, (_, tile) => {\n            const isPicked = picked.includes(tile);\n            const isMine = mineSet.has(tile);\n            const shown = isPicked || over;\n            const content: MinesTile | null = shown ? (isMine ? \"mine\" : \"gem\") : null;\n            const hit = isPicked && isMine;\n            const row = Math.floor(tile / size);\n            const col = tile % size;\n            const ripple = ringFrom(lastPick, tile, size);\n            const order = picked.indexOf(tile);\n            const celebrate = phase === \"cashed\" && isPicked && !reduce;\n            const interactive = phase === \"playing\" && !isPicked;\n            return (\n              <motion.button\n                // biome-ignore lint/suspicious/noArrayIndexKey: tiles are positional\n                key={tile}\n                ref={(node) => {\n                  tileRefs.current[tile] = node;\n                }}\n                type=\"button\"\n                tabIndex={tile === cursor ? 0 : -1}\n                aria-disabled={!interactive}\n                aria-label={`Row ${row + 1}, column ${col + 1}, ${tileState(content, isPicked, order)}`}\n                onFocus={() => setCursor(tile)}\n                onClick={() => pick(tile)}\n                onKeyDown={(event) => onKeyDown(event, tile)}\n                animate={hit && !reduce ? { x: [0, -5, 5, -3, 3, 0] } : { x: 0 }}\n                transition={SHAKE}\n                className={cn(\n                  \"relative grid aspect-square place-items-center rounded-xl border outline-none\",\n                  // The mine's shockwave paints over its neighbours.\n                  hit && \"z-10\",\n                  \"transition-[translate,scale,background-color,border-color] duration-150 ease-out\",\n                  \"focus-visible:ring-2 focus-visible:ring-ring\",\n                  interactive\n                    ? \"cursor-pointer border-border bg-card hover:-translate-y-0.5 hover:border-(--color-border-strong) active:scale-[0.96]\"\n                    : \"cursor-default border-transparent\",\n                  !interactive && !shown && \"bg-card/60\",\n                  content === \"gem\" && (isPicked ? \"bg-primary/15\" : \"bg-card/40\"),\n                  content === \"mine\" && (hit ? \"bg-rose-500/20\" : \"bg-card/40\"),\n                )}\n              >\n                {hit && !reduce ? (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute inset-0 rounded-xl border-2 border-rose-500\"\n                    initial={{ scale: 1, opacity: 0.7 }}\n                    animate={{ scale: 1.7, opacity: 0 }}\n                    transition={{ duration: 0.45, ease: EASE_OUT }}\n                  />\n                ) : null}\n                {isPicked && !isMine ? (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    transition={{ duration: 0.15, ease: EASE_OUT, delay: 0.1 }}\n                    className=\"absolute top-1.5 left-2 text-[10px] font-medium text-primary/70 tabular-nums\"\n                  >\n                    {order + 1}\n                  </motion.span>\n                ) : null}\n                <AnimatePresence initial={false}>\n                  {content ? (\n                    <motion.span\n                      key={content}\n                      aria-hidden=\"true\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                      animate={\n                        celebrate\n                          ? { opacity: 1, scale: [1, 1.2, 1], filter: \"blur(0px)\" }\n                          : { opacity: isPicked ? 1 : 0.4, scale: 1, filter: \"blur(0px)\" }\n                      }\n                      exit={reduce ? { opacity: 0, transition: EXIT } : { opacity: 0, scale: 0.9, transition: EXIT }}\n                      transition={\n                        celebrate\n                          ? { duration: PULSE_TIME, ease: EASE_OUT, delay: order * PULSE_STEP }\n                          : { ...ICON_SPRING, delay: isPicked ? 0 : ripple * RIPPLE_STEP }\n                      }\n                      className={cn(\n                        \"grid place-items-center\",\n                        content === \"gem\" ? \"text-primary\" : \"text-rose-500 dark:text-rose-400\",\n                      )}\n                    >\n                      {content === \"gem\" ? (\n                        <Gem className=\"size-[45%] min-h-5 min-w-5\" strokeWidth={2} />\n                      ) : (\n                        <MineIcon className=\"size-[45%] min-h-5 min-w-5\" />\n                      )}\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n              </motion.button>\n            );\n          })}\n        </fieldset>\n\n        <AnimatePresence>\n          {resultShown ? (\n            <PresenceGate key=\"result\">\n              {({ gate }) => (\n                <ResultCard\n                  gate={gate}\n                  outcome={phase === \"cashed\" ? \"cashed\" : \"busted\"}\n                  multiplier={multiplier}\n                  gems={gems}\n                  safeTiles={tiles - mines}\n                  mines={mines}\n                  size={size}\n                  picked={picked}\n                  mineSet={mineSet}\n                  reduce={reduce}\n                  onClose={closeResult}\n                />\n              )}\n            </PresenceGate>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        {/* aria-disabled, not disabled: Start turns into this button, and a\n            disabled button would drop the keyboard focus that pressed it. */}\n        <Button\n          ref={mainButtonRef}\n          className=\"flex-1 aria-disabled:cursor-not-allowed aria-disabled:opacity-50\"\n          pressScale={0.96}\n          aria-disabled={(phase === \"playing\" && gems === 0) || undefined}\n          onClick={() => (phase === \"playing\" ? cashOut() : start())}\n        >\n          {phase === \"playing\" ? `Cash out ${formatMultiplier(multiplier)}` : over ? \"Play again\" : \"Start\"}\n        </Button>\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=\"secondary\"\n            onClick={toggleSound}\n            aria-label=\"Sound\"\n            aria-pressed={soundOn}\n            pressScale={0.96}\n            className={cn(\"w-10 px-0\", soundOn ? \"text-foreground\" : \"text-muted-foreground\")}\n          >\n            <ActionSwapCascadeIcon value={soundOn ? \"on\" : \"off\"} className=\"size-4\">\n              {soundOn ? <Volume2 aria-hidden=\"true\" className=\"size-4\" /> : <VolumeX aria-hidden=\"true\" className=\"size-4\" />}\n            </ActionSwapCascadeIcon>\n          </Button>\n        </Tooltip>\n      </div>\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </section>\n  );\n}\n\n// The ticket's tear line sits this far down; the side notches are cut there.\nconst TEAR = 104;\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// 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\ninterface ResultProps {\n  gate: PresenceGateRenderProps[\"gate\"];\n  outcome: \"cashed\" | \"busted\";\n  multiplier: number;\n  gems: number;\n  safeTiles: number;\n  mines: number;\n  size: number;\n  picked: number[];\n  mineSet: ReadonlySet<number>;\n  reduce: boolean;\n  onClose: () => void;\n}\n\n/**\n * The end-of-round summary over a dimmed board. It has no buttons: the main\n * action below the board already says Play again, and tapping anywhere on\n * the board (or Escape) puts the card away to study the board.\n */\nfunction ResultCard(props: ResultProps) {\n  const { gate, reduce, onClose } = props;\n  return (\n    <>\n      <motion.button\n        type=\"button\"\n        aria-label=\"Hide result and show the board\"\n        onClick={onClose}\n        {...gate}\n        className=\"absolute inset-0 z-20 cursor-default rounded-3xl bg-background/55 outline-none backdrop-blur-[2px] focus-visible:ring-2 focus-visible:ring-ring\"\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\n        inert={gate.inert}\n        className=\"pointer-events-none absolute inset-0 z-20 grid place-items-center p-4\"\n      >\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=\"w-full max-w-[17rem]\"\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 {...props} />\n        </motion.div>\n      </div>\n    </>\n  );\n}\n\n// Inside the ticket, chunks arrive in reading order: outcome, multiplier,\n// stats, then the run replays on the mini map in pick order.\nconst STAGE = { duration: 0.25, ease: EASE_OUT } as const;\nconst REPLAY_START = 0.3;\nconst REPLAY_STEP = 0.05;\n// Mini map cells pop in with a small overshoot: each is 8px, so a bounce\n// reads as a cell landing rather than a wobble.\nconst CELL_SPRING = { type: \"spring\", duration: 0.3, bounce: 0.3 } as const;\n\nfunction Stage({ 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={{ ...STAGE, delay: reduce ? 0 : delay }}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction MiniMap({ size, picked, mineSet, reduce }: Pick<ResultProps, \"size\" | \"picked\" | \"mineSet\" | \"reduce\">) {\n  const hit = picked.at(-1);\n  // 8px cells 3px apart, kept within 88px so big boards leave the stats room.\n  const gap = size > 10 ? 1 : 3;\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"grid shrink-0\"\n      style={{ gridTemplateColumns: `repeat(${size}, minmax(0, 1fr))`, gap, width: Math.min(88, size * 8 + (size - 1) * gap) }}\n    >\n      {Array.from({ length: size * size }, (_, tile) => {\n        const isMine = mineSet.has(tile);\n        const isPicked = picked.includes(tile);\n        const order = picked.indexOf(tile);\n        return (\n          <motion.span\n            // biome-ignore lint/suspicious/noArrayIndexKey: tiles are positional\n            key={tile}\n            initial={isPicked && !reduce ? { opacity: 0, scale: 0.4 } : false}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={{ ...CELL_SPRING, delay: REPLAY_START + order * REPLAY_STEP }}\n            className={cn(\n              \"aspect-square rounded-[2px]\",\n              isPicked && !isMine && \"bg-primary\",\n              isPicked && isMine && tile === hit && \"bg-rose-500\",\n              !isPicked && isMine && \"ring-1 ring-rose-500/60 ring-inset\",\n              !isPicked && !isMine && \"bg-foreground/10\",\n            )}\n          />\n        );\n      })}\n    </div>\n  );\n}\n\nconst CARD_SHADOW = \"shadow-[0_1px_2px_rgb(0_0_0/0.12),0_16px_40px_-12px_rgb(0_0_0/0.45)]\";\n\n/** A ticket stub, notched at a dashed tear line. */\nfunction Ticket({ outcome, multiplier, gems, safeTiles, mines, size, picked, mineSet, reduce }: ResultProps) {\n  const cashed = outcome === \"cashed\";\n  return (\n    <div\n      className={cn(\"@container w-full rounded-2xl bg-card text-foreground\", CARD_SHADOW)}\n      style={{ WebkitMaskImage: NOTCH_MASK, WebkitMaskComposite: \"source-in\", maskImage: NOTCH_MASK, maskComposite: \"intersect\" }}\n    >\n      <div\n        className={cn(\"flex flex-col items-center justify-center gap-1 rounded-t-2xl px-5 text-center\", cashed ? \"bg-primary/10\" : \"bg-rose-500/10\")}\n        style={{ height: TEAR }}\n      >\n        <Stage delay={0.05} reduce={reduce}>\n          <p className={cn(\"text-xs font-medium tracking-wide uppercase\", cashed ? \"text-primary\" : \"text-rose-600 dark:text-rose-400\")}>\n            {cashed ? \"Cashed out\" : \"Mine hit\"}\n          </p>\n        </Stage>\n        <Stage delay={0.1} reduce={reduce}>\n          <p className={cn(\"text-3xl font-semibold\", !cashed && \"text-muted-foreground line-through decoration-2\")}>\n            {cashed ? (\n              <NumberTicker value={multiplier} decimals={2} format={compact} suffix=\"×\" startOnView={false} duration={0.6} stagger={0.05} />\n            ) : (\n              <span className=\"tabular-nums\">{formatMultiplier(multiplier)}</span>\n            )}\n          </p>\n        </Stage>\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        <Stage delay={0.18} reduce={reduce} className=\"flex-1\">\n          <dl className=\"grid gap-1.5 text-xs\">\n            <Row label=\"Gems\" value={`${gems} of ${safeTiles}`} />\n            <Row label=\"Mines\" value={mines} />\n            <Row label={cashed ? \"Odds beaten\" : \"Lost on pick\"} value={cashed ? `1 in ${compact(multiplier)}` : picked.length} />\n          </dl>\n        </Stage>\n        {/* A narrow ticket keeps the stats on one line each and drops the map. */}\n        <Stage delay={0.24} reduce={reduce} className=\"@max-[15rem]:hidden\">\n          <MiniMap size={size} picked={picked} mineSet={mineSet} reduce={reduce} />\n        </Stage>\n      </div>\n    </div>\n  );\n}\n\nfunction Row({ label, value }: { label: string; value: string | number }) {\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// The classic minesweeper mine: a solid ball (so it never reads as a sun),\n// four thick spikes on the axes and four shorter ones on the diagonals.\nconst SPIKES = Array.from({ length: 8 }, (_, i) => {\n  const angle = (i * Math.PI) / 4;\n  const main = i % 2 === 0;\n  const [cos, sin] = [Math.cos(angle), Math.sin(angle)];\n  const reach = main ? 10.5 : 9;\n  return { main, x1: 12 + cos * 6, y1: 12 + sin * 6, x2: 12 + cos * reach, y2: 12 + sin * reach };\n});\n\n/** A mine on Lucide's 24px grid, sized to pair with the gem. */\nfunction MineIcon({ className }: { className?: string }) {\n  // Decorative: tiles and the stepper name the mine in text.\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" strokeLinecap=\"round\" className={className}>\n      {SPIKES.map((spike) => (\n        <line\n          key={`${spike.x2.toFixed(2)}-${spike.y2.toFixed(2)}`}\n          x1={spike.x1}\n          y1={spike.y1}\n          x2={spike.x2}\n          y2={spike.y2}\n          strokeWidth={spike.main ? 2.5 : 2}\n        />\n      ))}\n      <circle cx={12} cy={12} r={6.5} stroke=\"none\" />\n      <circle cx={9.75} cy={9.75} r={1.6} fill=\"white\" fillOpacity={0.75} stroke=\"none\" />\n    </svg>\n  );\n}\n\n/** A small ring that fills with the chance the next pick is safe. */\nfunction OddsRing({ safe, reduce }: { safe: number; reduce: boolean }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 14 14\" className=\"size-3.5 -rotate-90\">\n      <circle cx={7} cy={7} r={5.5} fill=\"none\" strokeWidth={2} className=\"stroke-foreground/10\" />\n      <motion.circle\n        cx={7}\n        cy={7}\n        r={5.5}\n        fill=\"none\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        className={safe < 0.5 ? \"stroke-rose-500 dark:stroke-rose-400\" : \"stroke-primary\"}\n        initial={false}\n        animate={{ pathLength: safe }}\n        transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}\n      />\n    </svg>\n  );\n}\n\n/** What a tile shows, and for revealed tiles whether the player picked it. */\nfunction tileState(content: MinesTile | null, isPicked: boolean, order: number) {\n  if (!content) return \"hidden\";\n  if (!isPicked) return `${content}, not picked`;\n  return content === \"mine\" ? \"mine, hit\" : `gem, pick ${order + 1}`;\n}\n\n/** Rings between two tiles: 0 for the same tile, 1 for its neighbours. */\nfunction ringFrom(from: number, tile: number, size: number) {\n  return Math.max(\n    Math.abs(Math.floor(tile / size) - Math.floor(from / size)),\n    Math.abs((tile % size) - (from % size)),\n  );\n}\n\n// Payouts grow without limit (a cleared 5 × 5 board with 12 mines pays over\n// five million), so big ones are shortened to fit: 5.2M, then 1.3E29.\nconst SHORT = new Intl.NumberFormat(\"en\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst SCIENTIFIC = new Intl.NumberFormat(\"en\", { notation: \"scientific\", maximumFractionDigits: 1 });\n\nfunction compact(multiplier: number) {\n  if (multiplier < 10_000) return multiplier.toFixed(2);\n  return (multiplier < 1e15 ? SHORT : SCIENTIFIC).format(multiplier);\n}\n\n/** A multiplier as Mines shows it: 2.50×, or shortened when large (5.2M×). */\nexport function formatMultiplier(multiplier: number) {\n  return `${compact(multiplier)}×`;\n}\n"},{"path":"components/motion/action-swap-cascade.tsx","type":"registry:component","target":"@components/motion/action-swap-cascade.tsx","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/adaptive-stepper.tsx","type":"registry:component","target":"@components/motion/adaptive-stepper.tsx","content":"\"use client\";\n\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  createContext,\n  type MouseEvent,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  EASE_OUT,\n  SPRING_PRESS,\n} from \"@/lib/ease\";\nimport {\n  Liquid,\n  LiquidItem,\n  type LiquidTransition,\n} from \"@/components/motion/liquid\";\nimport { cn } from \"@/lib/utils\";\n\n// The deliberately elastic separation curve from the liquid email reference.\nconst STEPPER_LIQUID_TRANSITION = {\n  duration: 600,\n  ease: [0.22, 1.3, 0.71, 1],\n} as const satisfies LiquidTransition;\n\ntype StepDirection = -1 | 0 | 1;\n\ntype AdaptiveStepperContextValue = {\n  value: number;\n  valueText: string;\n  direction: StepDirection;\n  atMin: boolean;\n  atMax: boolean;\n  disabled: boolean;\n  reduce: boolean;\n  decrement: (restoreFocus: boolean) => void;\n  increment: (restoreFocus: boolean) => void;\n  decrementRef: React.MutableRefObject<HTMLButtonElement | null>;\n  incrementRef: React.MutableRefObject<HTMLButtonElement | null>;\n};\n\nconst AdaptiveStepperContext = createContext<AdaptiveStepperContextValue | null>(\n  null,\n);\n\nfunction useAdaptiveStepperContext(component: string) {\n  const context = useContext(AdaptiveStepperContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <AdaptiveStepper>`);\n  }\n  return context;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction cleanNumber(value: number) {\n  return Number(value.toFixed(10));\n}\n\nfunction nextStep(\n  value: number,\n  direction: -1 | 1,\n  min: number,\n  max: number,\n  step: number,\n) {\n  if (direction === 1) {\n    const nextIndex = Math.floor((value - min) / step + 1e-10) + 1;\n    return cleanNumber(Math.min(max, min + nextIndex * step));\n  }\n\n  const previousIndex = Math.ceil((value - min) / step - 1e-10) - 1;\n  return cleanNumber(Math.max(min, min + previousIndex * step));\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\") {\n        (ref as React.MutableRefObject<T | null>).current = node;\n      }\n    }\n  };\n}\n\nexport interface AdaptiveStepperProps {\n  children: ReactNode;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n  name?: string;\n  formatValueText?: (value: number) => string;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\nexport function AdaptiveStepper({\n  children,\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 10,\n  step = 1,\n  disabled = false,\n  name,\n  formatValueText,\n  className,\n  \"aria-label\": ariaLabel = \"Quantity\",\n}: AdaptiveStepperProps) {\n  const reduce = useReducedMotion() ?? false;\n  const labelId = useId();\n  const decrementRef = useRef<HTMLButtonElement>(null);\n  const incrementRef = useRef<HTMLButtonElement>(null);\n  const lower = Number.isFinite(min) ? min : 0;\n  const suppliedMax = Number.isFinite(max) ? max : lower;\n  const upper = suppliedMax > lower ? suppliedMax : lower;\n  const stride = Number.isFinite(step) && step > 0 ? step : 1;\n  const [internalValue, setInternalValue] = useState(() =>\n    clamp(Number.isFinite(defaultValue) ? defaultValue : lower, lower, upper),\n  );\n  const controlled = controlledValue !== undefined;\n  const suppliedValue = controlled ? controlledValue : internalValue;\n  const currentValue = clamp(\n    Number.isFinite(suppliedValue) ? suppliedValue : lower,\n    lower,\n    upper,\n  );\n  const previousValueRef = useRef(currentValue);\n  const currentValueRef = useRef(currentValue);\n  const direction: StepDirection =\n    currentValue === previousValueRef.current\n      ? 0\n      : currentValue > previousValueRef.current\n        ? 1\n        : -1;\n\n  useLayoutEffect(() => {\n    previousValueRef.current = currentValue;\n    currentValueRef.current = currentValue;\n  }, [currentValue]);\n\n  const commit = useCallback(\n    (nextValue: number, restoreFocus: boolean) => {\n      const next = clamp(cleanNumber(nextValue), lower, upper);\n      if (next === currentValue) return;\n      if (!controlled) setInternalValue(next);\n      onValueChange?.(next);\n\n      if (!restoreFocus) return;\n      requestAnimationFrame(() => {\n        if (next === upper && currentValueRef.current === upper) {\n          decrementRef.current?.focus();\n        } else if (next === lower && currentValueRef.current === lower) {\n          incrementRef.current?.focus();\n        }\n      });\n    },\n    [controlled, currentValue, lower, onValueChange, upper],\n  );\n\n  const decrement = useCallback(\n    (restoreFocus: boolean) => {\n      if (disabled || currentValue <= lower) return;\n      commit(nextStep(currentValue, -1, lower, upper, stride), restoreFocus);\n    },\n    [commit, currentValue, disabled, lower, stride, upper],\n  );\n\n  const increment = useCallback(\n    (restoreFocus: boolean) => {\n      if (disabled || currentValue >= upper) return;\n      commit(nextStep(currentValue, 1, lower, upper, stride), restoreFocus);\n    },\n    [commit, currentValue, disabled, lower, stride, upper],\n  );\n\n  const valueText = formatValueText?.(currentValue) ?? String(currentValue);\n  const context = useMemo<AdaptiveStepperContextValue>(\n    () => ({\n      value: currentValue,\n      valueText,\n      direction,\n      atMin: currentValue <= lower,\n      atMax: currentValue >= upper,\n      disabled,\n      reduce,\n      decrement,\n      increment,\n      decrementRef,\n      incrementRef,\n    }),\n    [\n      currentValue,\n      decrement,\n      direction,\n      disabled,\n      increment,\n      lower,\n      reduce,\n      upper,\n      valueText,\n    ],\n  );\n\n  return (\n    <AdaptiveStepperContext.Provider value={context}>\n      <fieldset\n        disabled={disabled}\n        className={cn(\n          \"relative isolate m-0 inline-block h-12 w-[13.5rem] border-0 p-0\",\n          className,\n        )}\n      >\n        <legend id={labelId} className=\"sr-only\">\n          {ariaLabel}. Current value: {valueText}\n        </legend>\n        <Liquid\n          blur={8}\n          contrast={22}\n          fill=\"var(--background)\"\n          className=\"size-full\"\n        >\n          {children}\n        </Liquid>\n        {name ? <input type=\"hidden\" name={name} value={currentValue} /> : null}\n      </fieldset>\n    </AdaptiveStepperContext.Provider>\n  );\n}\n\nexport interface AdaptiveStepperActionProps\n  extends Omit<HTMLMotionProps<\"button\">, \"children\" | \"onClick\"> {\n  children?: ReactNode;\n  onClick?: (event: MouseEvent<HTMLButtonElement>) => void;\n  ref?: Ref<HTMLButtonElement>;\n}\n\nfunction StepperAction({\n  direction,\n  children,\n  className,\n  onClick,\n  ref,\n  style,\n  tabIndex,\n  \"aria-label\": ariaLabel,\n  ...props\n}: AdaptiveStepperActionProps & { direction: -1 | 1 }) {\n  const context = useAdaptiveStepperContext(\"AdaptiveStepper action\");\n  const hidden = direction === -1 ? context.atMin : context.atMax;\n  const actionRef =\n    direction === -1 ? context.decrementRef : context.incrementRef;\n  const label =\n    ariaLabel ?? (direction === -1 ? \"Decrease value\" : \"Increase value\");\n  const action = direction === -1 ? context.decrement : context.increment;\n  const x =\n    direction === -1\n      ? hidden\n        ? 32\n        : 0\n      : hidden\n        ? 136\n        : 168;\n\n  return (\n    <LiquidItem\n      x={x}\n      y={0}\n      width={48}\n      height={48}\n      radius={24}\n      transition={STEPPER_LIQUID_TRANSITION}\n    >\n      <motion.button\n        {...props}\n        ref={mergeRefs(ref, actionRef)}\n        type=\"button\"\n        aria-label={label}\n        aria-hidden={hidden || undefined}\n        tabIndex={hidden ? -1 : tabIndex}\n        disabled={context.disabled || hidden}\n        whileTap={\n          context.reduce || context.disabled || hidden\n            ? undefined\n            : { scale: 0.94 }\n        }\n        transition={context.reduce ? { duration: 0 } : SPRING_PRESS}\n        style={style}\n        className={cn(\n          \"grid size-full place-items-center rounded-full border border-transparent bg-transparent bg-clip-padding text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none\",\n          hidden && \"hover:bg-transparent\",\n          context.disabled && \"opacity-50\",\n          className,\n        )}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) action(event.detail === 0);\n        }}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          initial={{\n            opacity: hidden ? 0 : 1,\n            filter: hidden ? \"blur(2px)\" : \"blur(0px)\",\n          }}\n          animate={{\n            opacity: hidden ? 0 : 1,\n            filter: hidden ? \"blur(2px)\" : \"blur(0px)\",\n          }}\n          transition={{ duration: context.reduce ? 0 : 0.15, ease: EASE_OUT }}\n        >\n          {children ??\n            (direction === -1 ? (\n              <Minus className=\"size-5\" strokeWidth={2.5} />\n            ) : (\n              <Plus className=\"size-5\" strokeWidth={2.5} />\n            ))}\n        </motion.span>\n      </motion.button>\n    </LiquidItem>\n  );\n}\n\nexport function AdaptiveStepperDecrement(props: AdaptiveStepperActionProps) {\n  return <StepperAction {...props} direction={-1} />;\n}\n\nexport interface AdaptiveStepperValueProps\n  extends Omit<HTMLMotionProps<\"output\">, \"children\"> {\n  children?: ReactNode | ((value: number) => ReactNode);\n}\n\nexport function AdaptiveStepperValue({\n  children,\n  className,\n  style,\n  ...props\n}: AdaptiveStepperValueProps) {\n  const context = useAdaptiveStepperContext(\"AdaptiveStepperValue\");\n  const geometry =\n    context.atMin && context.atMax\n      ? { x: 0, width: 216 }\n      : context.atMin\n        ? { x: 0, width: 152 }\n        : context.atMax\n          ? { x: 64, width: 152 }\n          : { x: 64, width: 88 };\n  const displayValue =\n    typeof children === \"function\" ? children(context.value) : children;\n  const renderedValue = displayValue ?? context.value;\n  const canRoll =\n    typeof renderedValue === \"number\" || typeof renderedValue === \"string\";\n  const distance = context.reduce || !canRoll ? 0 : context.direction * 32;\n  const enterFrom = `translateY(${distance}%)`;\n  const exitTo = `translateY(${-distance}%)`;\n\n  return (\n    <LiquidItem\n      x={geometry.x}\n      y={0}\n      width={geometry.width}\n      height={48}\n      radius={24}\n      transition={STEPPER_LIQUID_TRANSITION}\n    >\n      <motion.output\n        {...props}\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        style={style}\n        className={cn(\n          \"flex size-full min-w-0 items-center justify-center overflow-hidden rounded-full border border-transparent bg-transparent bg-clip-padding px-4 text-lg font-semibold tabular-nums text-foreground\",\n          className,\n        )}\n      >\n        <span className=\"sr-only\">{context.valueText}</span>\n        <span\n          aria-hidden=\"true\"\n          className=\"relative grid min-h-[1.1em] min-w-[1ch] place-items-center overflow-hidden leading-none\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={context.value}\n              initial={{\n                opacity: context.reduce ? 1 : 0.35,\n                filter: context.reduce ? \"blur(0px)\" : \"blur(2px)\",\n                transform: enterFrom,\n              }}\n              animate={{\n                opacity: 1,\n                filter: \"blur(0px)\",\n                transform: \"translateY(0%)\",\n              }}\n              exit={{\n                opacity: context.reduce ? 1 : 0,\n                filter: context.reduce ? \"blur(0px)\" : \"blur(2px)\",\n                transform: exitTo,\n                transition: {\n                  duration: context.reduce ? 0 : 0.12,\n                  ease: EASE_OUT,\n                },\n              }}\n              transition={{\n                duration: context.reduce ? 0 : 0.18,\n                ease: EASE_OUT,\n              }}\n              className=\"col-start-1 row-start-1 will-change-[transform,filter,opacity]\"\n            >\n              {renderedValue}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.output>\n    </LiquidItem>\n  );\n}\n\nexport function AdaptiveStepperIncrement(props: AdaptiveStepperActionProps) {\n  return <StepperAction {...props} direction={1} />;\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","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":"registry:component","target":"@components/motion/number-ticker.tsx","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/tooltip.tsx","type":"registry:component","target":"@components/motion/tooltip.tsx","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/ease.ts","type":"registry:lib","target":"@lib/ease.ts","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;\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/game-sounds.ts","type":"registry:lib","target":"@lib/game-sounds.ts","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":"lib/presence-gate.tsx","type":"registry:lib","target":"@lib/presence-gate.tsx","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/utils.ts","type":"registry:lib","target":"@lib/utils.ts","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.tsx","type":"registry:component","target":"@components/motion/action-swap.tsx","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/liquid.tsx","type":"registry:component","target":"@components/motion/liquid.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  forwardRef,\n  type HTMLAttributes,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype LiquidContextValue = {\n  getRoot: () => HTMLDivElement | null;\n  getPortal: () => SVGGElement | null;\n};\n\nconst LiquidContext = createContext<LiquidContextValue | null>(null);\n\nfunction useLiquidContext() {\n  const context = useContext(LiquidContext);\n  if (!context) throw new Error(\"LiquidItem must be used within <Liquid>\");\n  return context;\n}\n\nexport type LiquidEase = readonly [number, number, number, number];\n\nexport type LiquidTransition = {\n  duration?: number;\n  ease?: LiquidEase;\n};\n\nexport interface LiquidProps extends HTMLAttributes<HTMLDivElement> {\n  blur?: number;\n  contrast?: number;\n  fill?: string;\n  edgeColor?: string;\n  edgeOpacity?: number;\n  edgeWidth?: number;\n  filterPadding?: number;\n}\n\nexport const Liquid = forwardRef<HTMLDivElement, LiquidProps>(function Liquid(\n  {\n    blur = 6,\n    contrast = 18,\n    fill = \"var(--background)\",\n    edgeColor = \"var(--foreground)\",\n    edgeOpacity = 0.08,\n    edgeWidth = 1,\n    filterPadding = 24,\n    className,\n    style,\n    children,\n    ...props\n  },\n  forwardedRef: Ref<HTMLDivElement>,\n) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const portalRef = useRef<SVGGElement>(null);\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  const filterId = `liquid-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const intercept = Math.round((0.5 - contrast * (5 / 12)) * 100) / 100;\n  const padding = Math.ceil(blur * 3 + filterPadding);\n\n  const setRootRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n      if (typeof forwardedRef === \"function\") forwardedRef(node);\n      else if (forwardedRef) forwardedRef.current = node;\n    },\n    [forwardedRef],\n  );\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n\n    const measure = () => {\n      const next = {\n        width: root.offsetWidth,\n        height: root.offsetHeight,\n      };\n      setSize((current) =>\n        current.width === next.width && current.height === next.height\n          ? current\n          : next,\n      );\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, []);\n\n  const context = useMemo<LiquidContextValue>(\n    () => ({\n      getRoot: () => rootRef.current,\n      getPortal: () => portalRef.current,\n    }),\n    [],\n  );\n\n  return (\n    <div\n      {...props}\n      ref={setRootRef}\n      className={cn(\"relative isolate\", className)}\n      style={style}\n    >\n      <svg\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        className=\"pointer-events-none absolute inset-0 z-0 size-full overflow-visible\"\n      >\n        <defs>\n          <filter\n            id={filterId}\n            x={-padding}\n            y={-padding}\n            width={size.width + padding * 2}\n            height={size.height + padding * 2}\n            filterUnits=\"userSpaceOnUse\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              stdDeviation={blur}\n              result=\"blur\"\n            />\n            <feColorMatrix\n              in=\"blur\"\n              type=\"matrix\"\n              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${contrast} ${intercept}`}\n              result=\"goo\"\n            />\n            <feComposite\n              in=\"SourceGraphic\"\n              in2=\"goo\"\n              operator=\"atop\"\n              result=\"shape\"\n            />\n            {edgeWidth > 0 ? (\n              <>\n                <feColorMatrix\n                  in=\"shape\"\n                  type=\"matrix\"\n                  values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 60 -29.5\"\n                  result=\"solid-shape\"\n                />\n                <feMorphology\n                  in=\"solid-shape\"\n                  operator=\"erode\"\n                  radius={edgeWidth}\n                  result=\"inset-shape\"\n                />\n                <feComposite\n                  in=\"solid-shape\"\n                  in2=\"inset-shape\"\n                  operator=\"out\"\n                  result=\"edge-mask\"\n                />\n                <feFlood\n                  floodColor={edgeColor}\n                  floodOpacity={edgeOpacity}\n                  result=\"edge-color\"\n                />\n                <feComposite\n                  in=\"edge-color\"\n                  in2=\"edge-mask\"\n                  operator=\"in\"\n                  result=\"edge\"\n                />\n                <feMerge>\n                  <feMergeNode in=\"shape\" />\n                  <feMergeNode in=\"edge\" />\n                </feMerge>\n              </>\n            ) : null}\n          </filter>\n        </defs>\n        <g ref={portalRef} fill={fill} filter={`url(#${filterId})`} />\n      </svg>\n      <LiquidContext.Provider value={context}>\n        {children}\n      </LiquidContext.Provider>\n    </div>\n  );\n});\n\ntype LiquidBox = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  radius: number;\n};\n\nexport interface LiquidItemProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  children: ReactNode;\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  radius?: number;\n  transition?: LiquidTransition;\n}\n\nfunction mix(from: number, to: number, progress: number) {\n  return from + (to - from) * progress;\n}\n\nfunction cubicBezier([x1, y1, x2, y2]: LiquidEase) {\n  return (progress: number) => {\n    if (progress <= 0) return 0;\n    if (progress >= 1) return 1;\n\n    let lower = 0;\n    let upper = 1;\n    for (let index = 0; index < 20; index++) {\n      const time = (lower + upper) / 2;\n      const inverse = 1 - time;\n      const x =\n        3 * inverse * inverse * time * x1 +\n        3 * inverse * time * time * x2 +\n        time ** 3;\n      if (x < progress) lower = time;\n      else upper = time;\n    }\n\n    const time = (lower + upper) / 2;\n    const inverse = 1 - time;\n    return (\n      3 * inverse * inverse * time * y1 +\n      3 * inverse * time * time * y2 +\n      time ** 3\n    );\n  };\n}\n\nexport function LiquidItem({\n  children,\n  x,\n  y,\n  width,\n  height,\n  radius = Math.min(width, height) / 2,\n  transition,\n  className,\n  style,\n  ...props\n}: LiquidItemProps) {\n  const context = useLiquidContext();\n  const reduce = useReducedMotion() ?? false;\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const [blob, setBlob] = useState<SVGRectElement | null>(null);\n  const currentRef = useRef<LiquidBox | null>(null);\n  const duration = reduce ? 0 : (transition?.duration ?? 280);\n  const ease = transition?.ease ?? EASE_OUT;\n  const [x1, y1, x2, y2] = ease;\n\n  useLayoutEffect(() => {\n    const portal = context.getPortal();\n    if (!portal) return;\n\n    const blob = document.createElementNS(\n      \"http://www.w3.org/2000/svg\",\n      \"rect\",\n    );\n    blob.setAttribute(\"x\", \"0\");\n    blob.setAttribute(\"y\", \"0\");\n    blob.style.transformBox = \"fill-box\";\n    blob.style.transformOrigin = \"center\";\n    blob.style.willChange = \"transform\";\n    portal.append(blob);\n    setBlob(blob);\n\n    return () => {\n      blob.remove();\n    };\n  }, [context]);\n\n  useLayoutEffect(() => {\n    const wrapper = wrapperRef.current;\n    if (!wrapper || !blob || !context.getRoot()) return;\n\n    const target = { x, y, width, height, radius };\n    const write = (box: LiquidBox) => {\n      // Keep the interactive surface and its filtered silhouette on the same\n      // frame so neither can visually outrun the other during a morph.\n      const transform = `translate(${box.x}px, ${box.y}px)`;\n      wrapper.style.transform = transform;\n      wrapper.style.width = `${box.width}px`;\n      wrapper.style.height = `${box.height}px`;\n      blob.style.transform = transform;\n      blob.setAttribute(\"width\", String(box.width));\n      blob.setAttribute(\"height\", String(box.height));\n      blob.setAttribute(\"rx\", String(box.radius));\n    };\n\n    const from = currentRef.current;\n    if (!from || duration === 0) {\n      currentRef.current = target;\n      write(target);\n      return;\n    }\n\n    const easing = cubicBezier([x1, y1, x2, y2]);\n    const startedAt = performance.now();\n    let frame = 0;\n\n    const tick = (now: number) => {\n      const progress = Math.min(1, (now - startedAt) / duration);\n      const eased = easing(progress);\n      const current = {\n        x: mix(from.x, target.x, eased),\n        y: mix(from.y, target.y, eased),\n        width: mix(from.width, target.width, eased),\n        height: mix(from.height, target.height, eased),\n        radius: mix(from.radius, target.radius, eased),\n      };\n      currentRef.current = current;\n      write(current);\n      if (progress < 1) frame = requestAnimationFrame(tick);\n    };\n\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [blob, context, duration, height, radius, width, x, x1, x2, y, y1, y2]);\n\n  return (\n    <div\n      {...props}\n      ref={wrapperRef}\n      className={cn(\"absolute left-0 top-0 z-10\", className)}\n      style={{ ...style, willChange: \"transform, width, height\" }}\n    >\n      {children}\n    </div>\n  );\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","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":"registry:component","target":"@components/motion/button/magnetic.tsx","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":"registry:component","target":"@components/motion/button/metallic.tsx","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":"registry:component","target":"@components/motion/button/stateful.tsx","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":"components/motion/tooltip/grace.ts","type":"registry:component","target":"@components/motion/tooltip/grace.ts","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":"registry:component","target":"@components/motion/tooltip/positioner.tsx","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":"registry:component","target":"@components/motion/tooltip/use-position.ts","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":"registry:component","target":"@components/motion/tooltip-surface.tsx","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":"registry:hook","target":"@lib/hooks/use-dismiss.ts","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":"registry:hook","target":"@lib/hooks/use-hover-gesture.ts","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":"registry:hook","target":"@lib/hooks/use-tap-gesture.ts","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":"registry:lib","target":"@lib/audio-context.ts","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":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","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":"registry:component","target":"@components/motion/magnetic.tsx","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/touch.ts","type":"registry:lib","target":"@lib/touch.ts","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"}]}