{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"slide-game","type":"registry:block","title":"Slide","description":"A slide game: set a bet and a target, then a reel of colour-coded multiplier cards races past two tabs at the center of the stage, turning in 3D as they pass, slows through the last few and clicks the round's result into place between them. Cards that would beat your target carry a pip, the tabs turn primary on a win and red on a miss, and recent rounds collect in a history strip. Server-decided results, optional tick and win sounds, reduced-motion safe.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/slide-game.tsx","type":"registry:component","target":"@components/motion/slide-game.tsx","content":"\"use client\";\n// beui.dev/casino/slide-game\n\nimport { History as HistoryIcon } from \"lucide-react\";\nimport { animate, type MotionValue, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from \"motion/react\";\nimport { memo, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/motion/select\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { createGameSounds } from \"@/lib/game-sounds\";\nimport { createTickPlayer } from \"@/lib/tick-sound\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  floor2,\n  formatMultiplier,\n  formatShort,\n  GameEyebrow,\n  GameHistory,\n  type GameHistoryEntry,\n  heat,\n  PRESS_GUARD,\n  SoundToggle,\n  useStable,\n} from \"./casino/game-parts\";\n\nexport { formatMultiplier } from \"./casino/game-parts\";\n\nexport interface SlideGameProps {\n  /** Target multiplier (controlled). A result at or above it wins. Applies from the next round. Values below 1.01× play as 1.01×. */\n  target?: number;\n  /** Initial target when uncontrolled. Default 2. */\n  defaultTarget?: number;\n  /** Called when the Target menu changes between rounds. */\n  onTargetChange?: (target: number) => void;\n  /** Targets offered in the menu. Default 1.5×, 2×, 3×, 5×, 10×, 25×, 100×. Values below 1.01× are left out. */\n  targets?: number[];\n  /** Bet amount (controlled), used for the profit readout and passed to `onResult`. */\n  bet?: number;\n  /** Initial bet when uncontrolled. Default 1. */\n  defaultBet?: number;\n  /** Called when the bet changes. */\n  onBetChange?: (bet: number) => void;\n  /** Currency symbol shown before the bet and the profit. Default \"$\". */\n  currency?: string;\n  /** Formats the profit. Default the currency symbol and two decimals ($1.50). */\n  formatAmount?: (amount: number) => string;\n  /**\n   * Decides each round's result, called once when the round starts. Return a\n   * number, or a Promise for one (a server round, a provably fair hash): the\n   * button shows Starting… until it settles, and a rejection cancels the\n   * round. Values below 1 or not finite play as 1. Default `slideResult()`.\n   * The win chance shown assumes its 1% edge.\n   */\n  getResult?: () => number | Promise<number>;\n  /**\n   * Called when the reel stops, with the result, the round's target and its bet. The round wins when result >= target.\n   * Not called for a round still sliding when the game unmounts; settle those from your own record of `getResult`.\n   */\n  onResult?: (result: number, target: number, bet: number) => void;\n  /** Slide length in seconds. Default 4. */\n  duration?: number;\n  /** Play synthesized sounds (controlled): a tick per card into the center and a chime on a win. */\n  sound?: boolean;\n  /** Initial sound state when uncontrolled. Default false. */\n  defaultSound?: boolean;\n  /** Called when the sound button is toggled. */\n  onSoundChange?: (sound: boolean) => void;\n  /** Accessible name for the game. Default \"Slide\". */\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/**\n * A result from one uniform draw in [0, 1): the reel reaches `m` or more with\n * probability (1 − houseEdge) / m, floored to two decimals and capped at\n * 1,000,000×. Pass your own `random` for a seeded or provably fair round.\n */\nexport function slideResult(random: () => number = Math.random, houseEdge = 0.01) {\n  const edge = Math.min(Math.max(houseEdge, 0), 1);\n  return Math.min(1_000_000, Math.max(1, Math.floor((100 * (1 - edge)) / (1 - random())) / 100));\n}\n\nconst TARGETS = [1.5, 2, 3, 5, 10, 25, 100];\n// Results never land below 1×, so a lower target would always win and pay nothing.\nconst MIN_TARGET = 1.01;\n// The ½ and 2× buttons stop here, so the field never fills with exponent notation.\nconst MAX_BET = 1_000_000_000;\nconst HISTORY = 12;\n// Round numbers, unique on the page.\nlet rounds = 0;\n\ntype Phase = \"idle\" | \"starting\" | \"sliding\" | \"settled\";\ntype Round = { id: number; result: number; target: number; bet: number };\n\n// Grouped, two decimals, never exponent notation: $1,234,567.50.\nconst AMOUNT = new Intl.NumberFormat(\"en\", { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n\nconst settleValue = (value: number) => (Number.isFinite(value) ? Math.max(1, floor2(value)) : 1);\nconst parseBet = (text: string) => {\n  const value = Number.parseFloat(text);\n  return Number.isFinite(value) && value > 0 ? value : 0;\n};\n\n/**\n * A slide game: set a bet and a target, then a reel of multiplier cards races\n * past two tabs at the center of the stage, tilting in 3D as they pass, slows\n * through the last few and clicks the result into place between them. At or\n * above the target wins. The result comes from `getResult`, so a server can\n * decide it and the reel only plays it back.\n */\nexport function SlideGame({\n  target: targetProp,\n  defaultTarget = 2,\n  onTargetChange,\n  targets = TARGETS,\n  bet: betProp,\n  defaultBet = 1,\n  onBetChange,\n  currency = \"$\",\n  formatAmount,\n  getResult = slideResult,\n  onResult,\n  duration = 4,\n  sound,\n  defaultSound = false,\n  onSoundChange,\n  \"aria-label\": ariaLabel = \"Slide\",\n  className,\n}: SlideGameProps) {\n  const reduce = useReducedMotion() ?? false;\n  const betId = useId();\n  const [internalTarget, setInternalTarget] = useState(defaultTarget);\n  const rawTarget = targetProp ?? internalTarget;\n  const target = Number.isFinite(rawTarget) ? Math.max(MIN_TARGET, rawTarget) : MIN_TARGET;\n  // The field keeps what the player typed (\"1.\" mid-edit); a controlled bet\n  // that disagrees with it replaces it in the same render.\n  const [draft, setDraft] = useState(() => String(betProp ?? defaultBet));\n  // The string check stops the loop for a bet the field can't hold (-1, NaN).\n  if (betProp !== undefined && betProp !== parseBet(draft) && String(betProp) !== draft) setDraft(String(betProp));\n  const bet = betProp ?? parseBet(draft);\n  const [internalSound, setInternalSound] = useState(defaultSound);\n  const soundOn = sound ?? internalSound;\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [round, setRound] = useState<Round | null>(null);\n  const [history, setHistory] = useState<GameHistoryEntry[]>([]);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const pending = useRef(0);\n  const endedAt = useRef(Number.NEGATIVE_INFINITY);\n  const lastTick = useRef(0);\n  const sounds = useRef<{ tick: ReturnType<typeof createTickPlayer>; game: ReturnType<typeof createGameSounds> } | null>(\n    null,\n  );\n  const latest = useRef({ soundOn, onResult });\n  useLayoutEffect(() => {\n    latest.current = { soundOn, onResult };\n  });\n\n  useEffect(() => {\n    const created = { tick: createTickPlayer(), game: createGameSounds() };\n    sounds.current = created;\n    return () => {\n      created.tick.dispose();\n      created.game.dispose();\n      sounds.current = null;\n      pending.current = 0;\n    };\n  }, []);\n\n  const busy = phase === \"starting\" || phase === \"sliding\";\n  // An empty or zero bet can't start a round.\n  const noBet = bet <= 0;\n  const blocked = busy || noBet;\n  const valid = targets.filter((step) => step >= MIN_TARGET);\n  const options = valid.includes(target) ? valid : [...valid, target].sort((a, b) => a - b);\n  const settled = phase === \"settled\" && round !== null;\n  const won = settled ? round.result >= round.target : undefined;\n\n  const play = () => {\n    if (blocked || performance.now() - endedAt.current < PRESS_GUARD) return;\n    // Audio unlocks inside the click.\n    if (soundOn) {\n      sounds.current?.tick.prepare();\n      sounds.current?.game.prepare();\n    }\n    const id = ++rounds;\n    const roundTarget = target;\n    const roundBet = bet;\n    const before = phase;\n    pending.current = id;\n    setPhase(\"starting\");\n    setAnnouncement(\"\");\n    Promise.resolve()\n      .then(getResult)\n      .then(\n        (value) => {\n          if (pending.current !== id) return;\n          setRound({ id, result: settleValue(value), target: roundTarget, bet: roundBet });\n          setPhase(\"sliding\");\n          setAnnouncement(`Sliding. Target ${formatShort(roundTarget)}.`);\n        },\n        () => {\n          if (pending.current !== id) return;\n          setPhase(before);\n          setAnnouncement(\"Couldn't start the round. Try again.\");\n        },\n      );\n  };\n\n  const settle = (ended: Round) => {\n    const win = ended.result >= ended.target;\n    endedAt.current = performance.now();\n    setPhase(\"settled\");\n    setHistory((items) => [{ id: ended.id, value: ended.result }, ...items].slice(0, HISTORY));\n    setAnnouncement(\n      `Landed on ${formatMultiplier(ended.result)}. ${win ? \"Win\" : \"Loss\"}, target ${formatShort(ended.target)}.`,\n    );\n    if (win && latest.current.soundOn) sounds.current?.game.cashOut();\n    latest.current.onResult?.(ended.result, ended.target, ended.bet);\n  };\n\n  // A tick per card reaching the center, at most one per 40ms: the opening rush\n  // reads as a whirr and the last few cards click one by one.\n  const cross = () => {\n    if (!latest.current.soundOn) return;\n    const now = performance.now();\n    if (now - lastTick.current < 40) return;\n    lastTick.current = now;\n    sounds.current?.tick.play();\n  };\n\n  const changeBet = (text: string) => {\n    setDraft(text);\n    onBetChange?.(parseBet(text));\n  };\n  const scaleBet = (factor: number) => {\n    const next = Math.min(MAX_BET, Math.max(0.01, Math.round(bet * factor * 100) / 100));\n    setDraft(next.toFixed(2));\n    onBetChange?.(next);\n  };\n  const changeTarget = (value: string) => {\n    const next = Number(value);\n    if (targetProp === undefined) setInternalTarget(next);\n    onTargetChange?.(next);\n  };\n  // Stable, so the memoized SoundToggle skips bet keystrokes.\n  const toggleSound = useStable(() => {\n    const next = !soundOn;\n    if (next) {\n      sounds.current?.tick.prepare();\n      sounds.current?.game.prepare();\n    }\n    if (sound === undefined) setInternalSound(next);\n    onSoundChange?.(next);\n  });\n\n  const roundTarget = formatShort(round?.target ?? target);\n  // Matches slideResult's default 1% edge.\n  const odds = 99 / target;\n  const chance = odds < 0.01 ? \"<0.01%\" : `${odds.toFixed(2)}%`;\n  const profit = bet * (target - 1);\n\n  return (\n    <section aria-label={ariaLabel} className={cn(\"@container w-full max-w-3xl\", className)}>\n      {/* 24px card corners = 12px panel and stage corners + 12px padding. Under\n          36rem the stage stacks over the controls; at 36rem it keeps ~350px beside them. */}\n      <div className=\"flex flex-col-reverse gap-3 rounded-3xl border border-border p-3 @min-[36rem]:flex-row\">\n        <div className=\"flex shrink-0 flex-col gap-3 @min-[36rem]:w-56\">\n          <div className=\"grid grid-cols-2 gap-3 @min-[36rem]:grid-cols-1\">\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <label htmlFor={betId} className=\"text-xs font-medium text-muted-foreground\">\n                Bet amount\n              </label>\n              <div\n                className={cn(\n                  \"flex h-10 items-center gap-0.5 rounded-xl border bg-background pr-1 pl-3 transition-colors focus-within:ring-2 focus-within:ring-foreground/20\",\n                  noBet ? \"border-rose-500/60\" : \"border-border hover:border-(--color-border-strong)\",\n                )}\n              >\n                {currency ? (\n                  <span aria-hidden=\"true\" className=\"pr-0.5 text-sm font-medium text-muted-foreground\">\n                    {currency}\n                  </span>\n                ) : null}\n                <input\n                  id={betId}\n                  aria-invalid={noBet || undefined}\n                  inputMode=\"decimal\"\n                  autoComplete=\"off\"\n                  value={draft}\n                  onChange={(event) => changeBet(event.target.value)}\n                  disabled={busy}\n                  className=\"w-full min-w-0 bg-transparent text-sm font-medium tabular-nums text-foreground outline-none disabled:opacity-50\"\n                />\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  pressScale={0.96}\n                  className=\"h-8 rounded-lg px-2.5 tabular-nums\"\n                  onClick={() => scaleBet(0.5)}\n                  disabled={busy}\n                  aria-label=\"Halve bet\"\n                >\n                  ½\n                </Button>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  pressScale={0.96}\n                  className=\"h-8 rounded-lg px-2.5 tabular-nums\"\n                  onClick={() => scaleBet(2)}\n                  disabled={busy}\n                  aria-label=\"Double bet\"\n                >\n                  2×\n                </Button>\n              </div>\n            </div>\n            <div className=\"flex min-w-0 flex-col gap-1.5\">\n              <span aria-hidden=\"true\" className=\"text-xs font-medium text-muted-foreground\">\n                Target\n              </span>\n              <Select value={String(target)} onValueChange={changeTarget} disabled={busy}>\n                <SelectTrigger className=\"h-10 rounded-xl tabular-nums\">\n                  <span className=\"sr-only\">Target </span>\n                  {/* Options register after mount; the placeholder covers the server render. */}\n                  <SelectValue placeholder={formatShort(target)} />\n                </SelectTrigger>\n                <SelectContent>\n                  {options.map((step) => (\n                    <SelectItem key={step} value={String(step)}>\n                      {formatShort(step)}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n          </div>\n\n          <dl className=\"grid grid-cols-2 gap-3 rounded-xl bg-muted/40 px-3 py-2.5 text-xs @min-[36rem]:grid-cols-1 @min-[36rem]:gap-2\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <dt className=\"text-muted-foreground\">Win chance</dt>\n              <dd className=\"font-medium tabular-nums\">{chance}</dd>\n            </div>\n            <div className=\"flex items-center justify-between gap-2\">\n              <dt className=\"text-muted-foreground\">Profit on win</dt>\n              <dd className=\"font-medium tabular-nums\">\n                {formatAmount ? formatAmount(profit) : `${currency}${AMOUNT.format(profit)}`}\n              </dd>\n            </div>\n          </dl>\n\n          <Button\n            className={cn(\n              \"h-12 w-full rounded-xl text-base tabular-nums aria-disabled:opacity-60 aria-disabled:hover:bg-primary @min-[36rem]:mt-auto\",\n              busy ? \"aria-disabled:cursor-wait\" : \"aria-disabled:cursor-not-allowed\",\n            )}\n            // aria-disabled, not disabled: the button keeps keyboard focus through the round.\n            aria-disabled={blocked || undefined}\n            aria-busy={busy || undefined}\n            // No press or hover response while it can't act. Passed only then:\n            // Button spreads props last, so an explicit undefined would drop its hover.\n            pressScale={blocked ? 1 : 0.96}\n            {...(blocked ? { whileHover: {} } : {})}\n            onClick={play}\n            onKeyDown={(event) => {\n              // A held Enter repeats clicks; it would start rounds on its own.\n              if (event.repeat && event.key === \"Enter\") event.preventDefault();\n            }}\n          >\n            {phase === \"starting\"\n              ? \"Starting…\"\n              : phase === \"sliding\"\n                ? \"Sliding…\"\n                : noBet\n                  ? \"Enter a bet\"\n                  : settled\n                    ? \"Slide again\"\n                    : \"Slide\"}\n          </Button>\n        </div>\n\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <div className=\"flex h-8 items-center gap-2 pl-1\">\n            <HistoryIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" strokeWidth={1.75} />\n            <GameHistory history={history} reduce={reduce} tier={face} aria-label=\"Recent slides\" className=\"min-w-0 flex-1\" />\n            <SoundToggle on={soundOn} onToggle={toggleSound} />\n          </div>\n\n          {/* The stage is the reel alone, so the edge tabs can hang from its edges. */}\n          <Reel\n            round={round}\n            // Pips mark the sliding round's target, and the next one otherwise\n            // (while starting, `round` is still the last one).\n            goal={phase === \"sliding\" && round ? round.target : target}\n            duration={duration}\n            reduce={reduce}\n            busy={busy}\n            settled={settled}\n            won={won}\n            onCross={cross}\n            onSettle={settle}\n          />\n\n          <div className=\"flex h-8 items-center justify-center\">\n            <GameEyebrow\n              label={\n                phase === \"starting\"\n                  ? \"Starting\"\n                  : phase === \"sliding\"\n                    ? `Sliding · needs ${roundTarget}`\n                    : won === true\n                      ? `Won · beat ${roundTarget}`\n                      : won === false\n                        ? `Missed · needed ${roundTarget}`\n                        : `${chance} chance of ${formatShort(target)} · marked cards win`\n              }\n              state={busy ? \"live\" : won === true ? \"won\" : won === false ? \"lost\" : \"idle\"}\n              reduce={reduce}\n            />\n          </div>\n        </div>\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </section>\n  );\n}\n\nconst CARD_WIDTH = 80;\nconst CARD_HEIGHT = 108;\nconst GAP = 10;\nconst PITCH = CARD_WIDTH + GAP;\n// Cover-flow geometry, by distance from the center in cards: the center card\n// sits 12% larger and flat, neighbours turn up to 50° toward it and fade.\nconst CENTER_SCALE = 1.12;\nconst SCALE_STEP = 0.1;\nconst MIN_SCALE = 0.8;\nconst TURN_STEP = 30;\nconst MAX_TURN = 50;\nconst FADE_STEP = 0.16;\nconst MIN_FADE = 0.3;\n// Cards kept on each side of the center when a new round starts, so the strip\n// on screen never changes; wide enough for any card width.\nconst CONTEXT = 6;\n// Filler cards that race past before the result, and after it.\nconst SPIN_TILES = 48;\nconst TAIL = 6;\n// The slide runs out anywhere inside the result card, not dead center, so the\n// last cards creep past the tabs and the stop reads as physical. Then the\n// card clicks into the center on a short spring, like a detent.\nconst LANDING_SPREAD = 0.7;\nconst DETENT = { type: \"spring\", duration: 0.35, bounce: 0.2 } as const;\n// A game reveal, not UI chrome: it starts at full speed and spends most of its\n// time creeping through the last few cards, where the suspense is. The 300ms\n// UI budget does not apply to it.\nconst SLIDE_EASE = [0.12, 0.7, 0.16, 1] as const;\n// Edge tabs: a notch hanging from the stage's top and bottom edges at the\n// center, a 4px cap with a rounded point. They sit clear of the cards and\n// only change colour with the round.\nconst TAB_HEIGHT = 15;\nconst TAB_CLEARANCE = 12;\n// The landed card swells once as it locks.\nconst LOCK = { duration: 0.4, ease: EASE_OUT } as const;\n\ntype Tile = { id: string; multiplier: number };\ntype ReelState = {\n  tiles: Tile[];\n  landed: number;\n  round: Round | null;\n  from: number;\n  /** Where the slide runs out, inside the result card. */\n  to: number;\n  /** The result card centered between the tabs. */\n  rest: number;\n};\n\n/** Seeded by the round, so the server and client agree on every card. */\nfunction seeded(seed: string) {\n  let state = 2166136261;\n  for (const char of seed) state = Math.imul(state ^ char.charCodeAt(0), 16777619);\n  return () => {\n    state = (state + 0x6d2b79f5) | 0;\n    let t = Math.imul(state ^ (state >>> 15), 1 | state);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** Decorative cards on the game's own curve, capped so they fit a card. */\nfunction filler(rand: () => number, count: number, prefix: string): Tile[] {\n  return Array.from({ length: count }, (_, i) => ({\n    id: `${prefix}-${i}`,\n    multiplier: Math.min(1000, slideResult(rand)),\n  }));\n}\n\n/** Reel offset that puts card `index` at the center. */\nconst positionOf = (index: number, offset = 0) => -(index * PITCH + CARD_WIDTH / 2 + offset);\n\nconst INITIAL: ReelState = (() => {\n  const rand = seeded(\"slide\");\n  // The center starts on 1.00×, so the stage reads as a fresh game, not a result.\n  const tiles = [...filler(rand, CONTEXT, \"a\"), { id: \"start\", multiplier: 1 }, ...filler(rand, CONTEXT, \"b\")];\n  const at = positionOf(CONTEXT);\n  return { tiles, landed: CONTEXT, round: null, from: at, to: at, rest: at };\n})();\n\n// Card faces by value: slate under 2×, primary to 10×, amber to 100×, then\n// fuchsia; the history pills follow the same tiers. Cards are physical objects, so they keep their colours in both\n// themes.\nconst FACE = {\n  cool: \"bg-zinc-700 text-white\",\n  warm: \"bg-primary text-primary-foreground\",\n  hot: \"bg-amber-400 text-amber-950\",\n  jackpot: \"bg-fuchsia-600 text-white\",\n} as const;\nconst face = (multiplier: number) => (multiplier >= 100 ? \"jackpot\" : heat(multiplier));\n\n/**\n * The strip of cards under the fixed edge tabs. A new round keeps the cards on\n * screen (mid-slide too), appends filler and the result, then slides to it.\n * Cards that would beat `goal` carry a pip, so wins read as they fly past.\n */\nfunction Reel({\n  round,\n  goal,\n  duration,\n  reduce,\n  busy,\n  settled,\n  won,\n  onCross,\n  onSettle,\n}: {\n  round: Round | null;\n  goal: number;\n  duration: number;\n  reduce: boolean;\n  busy: boolean;\n  settled: boolean;\n  won: boolean | undefined;\n  onCross: () => void;\n  onSettle: (round: Round) => void;\n}) {\n  const [reel, setReel] = useState(INITIAL);\n  const x = useMotionValue(INITIAL.rest);\n  const finished = useRef(0);\n  const under = useRef(INITIAL.landed);\n  const latest = useRef({ reel, onCross, onSettle, duration, reduce });\n  useLayoutEffect(() => {\n    latest.current = { reel, onCross, onSettle, duration, reduce };\n  });\n\n  useLayoutEffect(() => {\n    if (!round || round.id === reel.round?.id) return;\n    const current = Math.round((-x.get() - CARD_WIDTH / 2) / PITCH);\n    const start = Math.max(0, current - CONTEXT);\n    const kept = reel.tiles.slice(start, current + CONTEXT + 1);\n    // Seeded by the result too, so each page load's first round differs.\n    const rand = seeded(`${round.id}:${round.result}`);\n    const landed = kept.length + SPIN_TILES;\n    setReel({\n      tiles: [\n        ...kept,\n        ...filler(rand, SPIN_TILES, `${round.id}-a`),\n        { id: `${round.id}-result`, multiplier: round.result },\n        ...filler(rand, TAIL, `${round.id}-b`),\n      ],\n      landed,\n      round,\n      from: x.get() + start * PITCH,\n      to: positionOf(landed, (rand() - 0.5) * CARD_WIDTH * LANDING_SPREAD),\n      rest: positionOf(landed),\n    });\n  }, [round, reel, x]);\n\n  useLayoutEffect(() => {\n    const played = reel.round;\n    if (!played) return;\n    // Settles once per round, even when Strict Mode replays this effect.\n    const finish = () => {\n      if (finished.current === played.id) return;\n      finished.current = played.id;\n      latest.current.onSettle(played);\n    };\n    // The rebuilt strip renumbers the card under the tabs; sync the index\n    // first so the jump doesn't tick.\n    under.current = Math.round((-reel.from - CARD_WIDTH / 2) / PITCH);\n    x.jump(reel.from);\n    if (latest.current.reduce) {\n      x.jump(reel.rest);\n      finish();\n      return;\n    }\n    let controls = animate(x, reel.to, {\n      duration: latest.current.duration,\n      ease: SLIDE_EASE,\n      onComplete: () => {\n        controls = animate(x, reel.rest, { ...DETENT, onComplete: finish });\n      },\n    });\n    return () => controls.stop();\n  }, [reel, x]);\n\n  const tone = won === true ? \"text-primary\" : won === false ? \"text-rose-500\" : busy ? \"text-primary\" : \"text-foreground/50\";\n\n  // A tick each time a new card reaches the center.\n  useMotionValueEvent(x, \"change\", (value) => {\n    const count = latest.current.reel.tiles.length;\n    const index = Math.min(count - 1, Math.max(0, Math.round((-value - CARD_WIDTH / 2) / PITCH)));\n    if (index === under.current) return;\n    under.current = index;\n    latest.current.onCross();\n  });\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"relative flex-1 overflow-hidden rounded-xl bg-muted/40 ring-1 ring-border/50 ring-inset\"\n      style={{ minHeight: CARD_HEIGHT * CENTER_SCALE + 2 * (TAB_HEIGHT + TAB_CLEARANCE) }}\n    >\n      {/* Cards fade out at the sides; the stage and its tabs don't. */}\n      <div\n        className=\"absolute inset-0 [mask-image:linear-gradient(to_right,transparent,black_15%,black_85%,transparent)]\"\n        style={{ perspective: 900 }}\n      >\n        <motion.div\n          className=\"absolute inset-y-0 left-1/2 flex items-center\"\n          style={{ x, gap: GAP, transformStyle: \"preserve-3d\" }}\n        >\n          {reel.tiles.map((tile, index) => (\n            <Card\n              key={tile.id}\n              multiplier={tile.multiplier}\n              center={index * PITCH + CARD_WIDTH / 2}\n              x={x}\n              wins={tile.multiplier >= goal}\n              landed={settled && index === reel.landed}\n              dim={settled && index !== reel.landed}\n              reduce={reduce}\n            />\n          ))}\n        </motion.div>\n      </div>\n      {/* After the strip, so they paint over cards turning out of the 3D plane. */}\n      <EdgeTab side=\"top\" tone={tone} />\n      <EdgeTab side=\"bottom\" tone={tone} />\n    </div>\n  );\n}\n\n/**\n * One card. Its turn, size and fade follow its distance from the center, read straight off the reel's offset.\n * Memoized: the reel holds dozens of cards and every bet keystroke re-renders the stage.\n */\nconst Card = memo(function Card({\n  multiplier,\n  center,\n  x,\n  wins,\n  landed,\n  dim,\n  reduce,\n}: {\n  multiplier: number;\n  /** The card's center along the strip, in px. */\n  center: number;\n  x: MotionValue<number>;\n  wins: boolean;\n  landed: boolean;\n  dim: boolean;\n  reduce: boolean;\n}) {\n  // A new round renumbers kept cards; the ref keeps the transforms current.\n  const at = useRef(center);\n  useLayoutEffect(() => {\n    at.current = center;\n  });\n  const distance = useTransform(x, (value) => (value + at.current) / PITCH);\n  const scale = useTransform(distance, (d) => Math.max(MIN_SCALE, CENTER_SCALE - SCALE_STEP * Math.abs(d)));\n  const rotateY = useTransform(distance, (d) => Math.max(-MAX_TURN, Math.min(MAX_TURN, d * TURN_STEP)));\n  const opacity = useTransform(distance, (d) => Math.max(MIN_FADE, 1 - FADE_STEP * Math.abs(d)));\n  const [whole, decimals] = formatMultiplier(multiplier).replace(\"×\", \"\").split(\".\");\n\n  return (\n    <motion.div className=\"shrink-0\" style={{ width: CARD_WIDTH, height: CARD_HEIGHT, scale, rotateY, opacity }}>\n      <motion.div\n        className={cn(\n          \"relative flex size-full flex-col items-center justify-center rounded-xl tabular-nums\",\n          // A flat face: a hairline, one lit top edge and a soft drop give it depth.\n          \"shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),inset_0_1px_0_rgb(255_255_255/0.3),0_12px_24px_-10px_rgb(0_0_0/0.6)]\",\n          \"transition-opacity duration-200 ease-out\",\n          FACE[face(multiplier)],\n          dim && \"opacity-55\",\n        )}\n        animate={landed && !reduce ? { scale: [1, 1.08, 1] } : { scale: 1 }}\n        transition={LOCK}\n      >\n        {/* Four or more whole digits (1000×, 12.5K×) drop a size to stay inside the card. */}\n        <span className={cn(\"leading-none font-bold tracking-tight\", whole.length >= 4 ? \"text-base\" : \"text-xl\")}>\n          {whole}\n          {decimals ? <span className=\"text-[0.7em]\">.{decimals}</span> : null}\n          <span className=\"ml-0.5 text-[0.7em] opacity-70\">×</span>\n        </span>\n        {wins ? <span className=\"absolute bottom-2.5 h-1 w-5 rounded-full bg-current opacity-60\" /> : null}\n      </motion.div>\n    </motion.div>\n  );\n});\n\n/** A notch on the stage edge, pointing at the center card. */\nfunction EdgeTab({ side, tone }: { side: \"top\" | \"bottom\"; tone: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 36 15\"\n      className={cn(\n        \"absolute left-1/2 w-9 -translate-x-1/2 fill-current transition-colors duration-150 ease-out\",\n        side === \"top\" ? \"top-0\" : \"bottom-0 rotate-180\",\n        tone,\n      )}\n      style={{ height: TAB_HEIGHT }}\n    >\n      <path d=\"M0 0h36v2a2 2 0 0 1-2 2h-9.5l-5.2 9.4a1.5 1.5 0 0 1-2.6 0L11.5 4H2a2 2 0 0 1-2-2Z\" />\n    </svg>\n  );\n}\n"},{"path":"components/motion/casino/game-parts.tsx","type":"registry:component","target":"@components/motion/casino/game-parts.tsx","content":"\"use client\";\n\n// Pieces the casino games share, so each one only draws its own stage: the\n// multiplier format and heat colours, the recent-rounds strip, the sound\n// toggle and the small state label above a readout.\n\nimport { Check, Volume2, VolumeX, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { memo, useCallback, useLayoutEffect, useRef } from \"react\";\nimport { ActionSwapCascadeIcon } from \"@/components/motion/action-swap-cascade\";\nimport { Button } from \"@/components/motion/button\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// Presses this soon after a round starts or ends are the same press repeated\n// (a double tap, a held key), not the next action (ms).\nexport const PRESS_GUARD = 400;\n\n// Status lines and markers swap on a spring with no bounce.\nexport const SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Exits are shorter than entrances.\nexport const EXIT = { duration: 0.12, ease: EASE_OUT } as const;\n// A loss shakes the readout once, quickly.\nexport const SHAKE = { duration: 0.3, ease: EASE_OUT } as const;\n// A milestone or a win swells the readout and lets it settle with a little give.\nexport const PULSE = { type: \"spring\", duration: 0.35, bounce: 0.3 } as const;\n\n/**\n * Primary for small text: a shade darker on light surfaces and lighter on\n * dark ones, so 10-12px labels keep 4.5:1. Large text and marks use plain\n * primary.\n */\nexport const PRIMARY_TEXT =\n  \"text-[color:color-mix(in_oklab,var(--color-primary)_75%,black)] dark:text-[color:color-mix(in_oklab,var(--color-primary)_80%,white)]\";\n\n/**\n * How hot a multiplier reads: cool under 2×, warm to 10×, hot beyond.\n * Readouts, history and tiles colour by it, so size reads without the digits.\n */\nexport function heat(multiplier: number) {\n  return multiplier >= 10 ? \"hot\" : multiplier >= 2 ? \"warm\" : \"cool\";\n}\n\n// Readout and pill colours per heat: neutral, then primary, then amber.\nexport const HEAT_TEXT = {\n  cool: \"text-foreground\",\n  warm: \"text-primary\",\n  hot: \"text-amber-700 dark:text-amber-400\",\n} as const;\nexport const HEAT_PILL = {\n  cool: \"bg-foreground/5 text-muted-foreground\",\n  warm: `bg-primary/10 ${PRIMARY_TEXT}`,\n  hot: \"bg-amber-500/15 text-amber-700 dark:text-amber-400\",\n  // Opt-in through GameHistory's `tier`, for games that mark 100× and up.\n  jackpot: \"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-400\",\n} as const;\n\n// Truncated, never rounded up: a 10,960× payout reads 10.9K×, not 11K×.\nconst COMPACT = new Intl.NumberFormat(\"en\", { notation: \"compact\", maximumFractionDigits: 1, roundingMode: \"trunc\" });\n\n/**\n * Large values: compact up to trillions, then scientific (1.4e100), since\n * compact notation stops at T and would print 1e100 as a 100-digit string.\n */\nfunction formatLarge(multiplier: number) {\n  if (multiplier < 1e15) return COMPACT.format(multiplier);\n  // From toExponential, not log10: 1e100 / 10 ** 100 is 0.999….\n  const [mantissa, exponent] = multiplier.toExponential(6).split(\"e\");\n  return `${Math.floor(Number(mantissa) * 10) / 10}e${Number(exponent)}`;\n}\n\n/**\n * A callback with one identity for the component's life that runs the latest\n * render's code. Stands in for useEffectEvent, which needs React 19.2.\n */\nexport function useStable<A extends unknown[], R>(fn: (...args: A) => R) {\n  const ref = useRef(fn);\n  useLayoutEffect(() => {\n    ref.current = fn;\n  });\n  return useCallback((...args: A) => ref.current(...args), []);\n}\n\n/** Floors to two decimals, the precision every game settles at. The 1e-9 keeps 2.3 from flooring to 2.29. */\nexport function floor2(value: number) {\n  return Math.floor(value * 100 + 1e-9) / 100;\n}\n\n/** A multiplier's number without the sign: 2.50, or shortened when large (12.5K, 1.4e20). */\nexport function formatOdds(multiplier: number) {\n  return multiplier < 10_000 ? multiplier.toFixed(2) : formatLarge(multiplier);\n}\n\n/** A multiplier as the games show it: 2.50×, or shortened when large (12.5K×, 1.4e20×). */\nexport function formatMultiplier(multiplier: number) {\n  return `${formatOdds(multiplier)}×`;\n}\n\n/** Scale and menu labels: 1.5×, 2×, 1.2K×. */\nexport function formatShort(multiplier: number) {\n  return `${multiplier < 1000 ? +multiplier.toFixed(2) : formatLarge(multiplier)}×`;\n}\n\n/** The sound toggle: a quiet 32px icon button in a game card's header. */\nexport const SoundToggle = memo(function SoundToggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {\n  return (\n    // aria-label names the button; the tooltip shows the same label on hover\n    // and focus. Tooltip's asLabel would hydrate differently from the server.\n    <Tooltip content=\"Sound\" side=\"top\">\n      <Button\n        variant=\"ghost\"\n        onClick={onToggle}\n        aria-label=\"Sound\"\n        aria-pressed={on}\n        pressScale={0.96}\n        className={cn(\"size-8 shrink-0 rounded-xl px-0\", on && \"text-foreground\")}\n      >\n        <ActionSwapCascadeIcon value={on ? \"on\" : \"off\"} className=\"size-4\">\n          {on ? <Volume2 aria-hidden=\"true\" className=\"size-4\" /> : <VolumeX aria-hidden=\"true\" className=\"size-4\" />}\n        </ActionSwapCascadeIcon>\n      </Button>\n    </Tooltip>\n  );\n});\n\nexport interface GameHistoryEntry {\n  id: number;\n  value: number;\n}\n\n/** Recent rounds, newest first, sliding in from the left and coloured by heat. */\nexport const GameHistory = memo(function GameHistory({\n  history,\n  reduce,\n  tier = heat,\n  \"aria-label\": ariaLabel,\n  className,\n}: {\n  history: GameHistoryEntry[];\n  reduce: boolean;\n  /** Pill colour per value. Default `heat`; return \"jackpot\" to mark a top tier. */\n  tier?: (value: number) => keyof typeof HEAT_PILL;\n  \"aria-label\": string;\n  className?: string;\n}) {\n  return (\n    <ul\n      aria-label={ariaLabel}\n      className={cn(\n        \"flex items-center gap-1.5 overflow-hidden [mask-image:linear-gradient(to_right,black_85%,transparent)]\",\n        className,\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {history.length === 0 ? (\n          <motion.li key=\"empty\" className=\"text-xs text-muted-foreground\" initial={false} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: EXIT }}>\n            Recent rounds show here\n          </motion.li>\n        ) : (\n          history.map((entry) => (\n            <motion.li\n              key={entry.id}\n              layout={!reduce}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, x: -8 }}\n              animate={{ opacity: 1, scale: 1, x: 0 }}\n              exit={{ opacity: 0, transition: EXIT }}\n              transition={reduce ? { duration: 0.15 } : SPRING_LAYOUT}\n              className={cn(\n                \"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums\",\n                HEAT_PILL[tier(entry.value)],\n              )}\n            >\n              {formatMultiplier(entry.value)}\n            </motion.li>\n          ))\n        )}\n      </AnimatePresence>\n    </ul>\n  );\n});\n\n/**\n * The small uppercase state label over a readout, with a live dot while a\n * round plays and a check or cross once it ends.\n */\nexport function GameEyebrow({\n  label,\n  state,\n  reduce,\n  className,\n}: {\n  label: string;\n  /** `live` shows the dot; `won` and `lost` show a check or cross and tint the label. */\n  state: \"idle\" | \"live\" | \"won\" | \"lost\";\n  reduce: boolean;\n  className?: string;\n}) {\n  const icon = state === \"idle\" ? null : state;\n  return (\n    <p\n      className={cn(\n        \"flex h-4 items-center gap-1.5 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors duration-150\",\n        state === \"lost\" ? \"text-rose-700 dark:text-rose-400\" : state === \"won\" ? PRIMARY_TEXT : \"text-muted-foreground\",\n        className,\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {icon ? (\n          <motion.span\n            key={icon}\n            aria-hidden=\"true\"\n            className=\"grid size-3.5 place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: EXIT }}\n            transition={SWAP}\n          >\n            {icon === \"live\" ? (\n              <span className=\"size-1.5 rounded-full bg-primary\" />\n            ) : icon === \"won\" ? (\n              <Check className=\"size-3.5\" strokeWidth={3} />\n            ) : (\n              <X className=\"size-3.5\" strokeWidth={3} />\n            )}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n      {label}\n    </p>\n  );\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/select.tsx","type":"registry:component","target":"@components/motion/select.tsx","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { type ListboxRow, useListbox } from \"@/lib/hooks/use-listbox\";\nimport { cn } from \"@/lib/utils\";\n\n// One progress value drives the surface, gap, corners and chevron. This short\n// spring suits the full reveal distance; each reader clamps its small internal\n// overshoot, so all visible endpoints settle together. Rows share a short\n// reveal so long lists have no late entries.\nconst UNFOLD_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.2,\n  bounce: 0.12,\n} as const;\n// Exits are faster than entrances: the panel folds back on a short ease.\nconst UNFOLD_EXIT = { duration: 0.2, ease: EASE_OUT } as const;\nconst RADIUS = 12;\n// How far the open panel sits from the trigger once it has pulled away.\nconst GAP = 8;\n// The surface starts as a thin lip flush against the trigger's edge.\nconst LIP = 8;\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst ITEM_VARIANTS: Variants = {\n  // A shared short reveal keeps late rows in long, scrolled lists ready with\n  // the surface. Per-index staggering can delay visible rows by over a second.\n  hidden: { opacity: 0, y: -4, transition: { duration: 0.08, ease: EASE_OUT } },\n  show: { opacity: 1, y: 0, transition: { duration: 0.12, ease: EASE_OUT } },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string, disabled: boolean) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  rows: ListboxRow[];\n  /** Closes the panel; focus inside it returns to the trigger. */\n  close: () => void;\n  reduce: boolean;\n  progress: MotionValue<number>;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n// The option keyboard focus is on, resolved during render by the content.\nconst ActiveOptionContext = createContext<string | undefined>(undefined);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<\n    Map<string, { label: string; disabled: boolean }>\n  >(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n  const progress = useMotionValue(open ? 1 : 0);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  // The panel turns inert as it closes, which would drop focus held inside it\n  // on the page body. Hand it back to the trigger before that happens.\n  const close = useCallback(() => {\n    const trigger = document.getElementById(`${baseId}-trigger`);\n    if (\n      rootRef.current?.contains(document.activeElement) &&\n      document.activeElement !== trigger\n    )\n      trigger?.focus();\n    setOpen(false);\n  }, [baseId, setOpen]);\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      close();\n    },\n    [controlled, onValueChange, close],\n  );\n\n  const register = useCallback((v: string, label: string, disabled: boolean) => {\n    setLabels((m) => {\n      const entry = m.get(v);\n      if (entry?.label === label && entry.disabled === disabled) return m;\n      return new Map(m).set(v, { label, disabled });\n    });\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // Reduced motion has no travel, so the progress only carries a short fade.\n  useEffect(() => {\n    const animation = animate(\n      progress,\n      open ? 1 : 0,\n      reduce ? { duration: 0.12 } : open ? UNFOLD_SPRING : UNFOLD_EXIT,\n    );\n    return () => animation.stop();\n  }, [open, progress, reduce]);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen, close]);\n\n  const rows = useMemo(\n    () =>\n      Array.from(labels, ([id, entry]) => ({\n        id,\n        label: entry.label,\n        disabled: entry.disabled,\n      })),\n    [labels],\n  );\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),\n      rows,\n      close,\n      reduce,\n      progress,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      rows,\n      close,\n      reduce,\n      progress,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // The edge facing the panel flattens while the panel is attached to it and\n  // rounds back as the panel pulls away — the two pinch apart — on the same\n  // curve the panel moves on. The far edge stays rounded.\n  const nearRadius = useTransform(\n    ctx.progress,\n    [0, 0.2, 0.4, 1],\n    [RADIUS, 0, 0, RADIUS],\n  );\n  const rotate = useTransform(ctx.progress, (p) =>\n    ctx.reduce ? (ctx.open ? 180 : 0) : Math.min(1, Math.max(0, p)) * 180,\n  );\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      onKeyDown={(event) => {\n        if (\n          !ctx.open &&\n          (event.key === \"ArrowDown\" || event.key === \"ArrowUp\")\n        ) {\n          event.preventDefault();\n          ctx.setOpen(true);\n        }\n      }}\n      style={{\n        borderTopLeftRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderTopRightRadius: ctx.reduce || !isTop ? RADIUS : nearRadius,\n        borderBottomLeftRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n        borderBottomRightRadius: ctx.reduce || isTop ? RADIUS : nearRadius,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        style={{ rotate }}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const panelRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const { open, progress, reduce, setPlacement } = ctx;\n  const { activeId, onKeyDown } = useListbox({\n    open,\n    rows: ctx.rows,\n    selected: ctx.value,\n    list: panelRef,\n  });\n\n  // Keep descendants laid out while hidden: display:none resets nested\n  // scrollers in Chromium. Collapse only the hidden outer footprint after\n  // exit so it cannot extend page scrolling. Opening restores the full box\n  // before measurement or focus; neither layout change is animated.\n  const [exiting, setExiting] = useState(false);\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (!open) setExiting(true);\n  }\n  const present = open || exiting;\n  useMotionValueEvent(progress, \"animationComplete\", () => {\n    if (progress.get() === 0) setExiting(false);\n  });\n\n  useLayoutEffect(() => {\n    const node = panelRef.current;\n    if (!node || !present) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [present]);\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = panelRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  const isTop = ctx.placement === \"top\";\n\n  // Reveal a full-size panel from its near edge. No ancestor scales the list,\n  // so rows, borders and shadow keep their natural thickness. The clip and\n  // corner writes can paint; only the gap uses a transform. All read one\n  // progress value, with no animated layout properties.\n  const ready = height > 0 && !reduce;\n  const fromY = ready ? (isTop ? GAP : -GAP) : 0;\n  const detach = useTransform(progress, [0.4, 1], [0, 1]);\n  const y = useTransform(detach, (d) => lerp(fromY, 0, d));\n  const nearRadius = useTransform(detach, (d) => (ready ? RADIUS * d : RADIUS));\n  const borderRadius = useTransform(nearRadius, (near) =>\n    isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`,\n  );\n  const clipPath = useTransform([progress, nearRadius], ([p, near]: number[]) => {\n    if (!ready) return \"none\";\n    // Clamp overshoot so the clip never extends into the trigger or exposes\n    // overflowing children. At rest remove it so the native shadow is whole.\n    const reveal = Math.min(1, Math.max(0, p));\n    if (reveal === 1) return \"none\";\n    const hidden = Math.max(0, (height - LIP) * (1 - reveal));\n    const inset = isTop ? `${hidden}px 0 0 0` : `0 0 ${hidden}px 0`;\n    const corners = isTop\n      ? `${RADIUS}px ${RADIUS}px ${near}px ${near}px`\n      : `${near}px ${near}px ${RADIUS}px ${RADIUS}px`;\n    return `inset(${inset} round ${corners})`;\n  });\n  // The surface materialises over its first frames rather than popping in as\n  // a lip; with reduced motion the fade is the whole entrance.\n  const opacity = useTransform(progress, reduce ? [0, 1] : [0, 0.12], [0, 1]);\n\n  return (\n    <motion.div\n      ref={panelRef}\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={(event) => {\n        // Tab leaves the list from the trigger, so focus moves on to the\n        // control after the select rather than into the closing panel.\n        if (event.key === \"Tab\") ctx.close();\n        else onKeyDown(event);\n      }}\n      style={{\n        visibility: present ? undefined : \"hidden\",\n        maxHeight: present ? undefined : 0,\n        borderWidth: present ? undefined : 0,\n        y,\n        clipPath,\n        borderRadius,\n        opacity,\n        pointerEvents: open ? \"auto\" : \"none\",\n        marginTop: isTop ? 0 : GAP,\n        marginBottom: isTop ? GAP : 0,\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 overflow-hidden border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <ActiveOptionContext.Provider value={activeId}>\n        <motion.div\n          initial={false}\n          animate={open ? \"show\" : \"hidden\"}\n          className=\"p-1\"\n        >\n          {children}\n        </motion.div>\n      </ActiveOptionContext.Provider>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const activeId = useContext(ActiveOptionContext);\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label, disabled);\n  }, [ctx.register, value, label, disabled]);\n  useLayoutEffect(() => () => ctx.unregister(value), [ctx.unregister, value]);\n\n  return (\n    <motion.div variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        // One tab stop: the active option, which focus follows while open.\n        tabIndex={activeId === value ? 0 : -1}\n        data-listbox-id={value}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.div>\n  );\n}\n"},{"path":"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;\n/** Accelerating curve for things that fall (a dropped ball, a toppled block). Not for UI entrances. */\nexport const EASE_IN = [0.55, 0, 1, 0.45] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/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/tick-sound.ts","type":"registry:lib","target":"@lib/tick-sound.ts","content":"// Synthesizes a short, dry mechanical detent for gesture feedback (e.g. the\n// WheelPicker row-cross click) entirely at runtime. Several inharmonic modes\n// make it read as a tiny physical impact instead of an oscillator beep, while\n// the high-passed transient gives it definition through phone speakers.\n\nimport { getAudioContext, retainAudioContext } from \"@/lib/audio-context\";\n\nconst DURATION = 0.016; // seconds, short enough to stay distinct during a fling\nconst MASTER_GAIN = 0.055;\n\nconst MODES = [\n  { frequency: 980, decay: 0.0038, gain: 0.5, phase: 0 },\n  { frequency: 1820, decay: 0.0024, gain: 0.3, phase: 0.65 },\n  { frequency: 3160, decay: 0.00115, gain: 0.12, phase: 1.3 },\n] as const;\n\n// The context is shared page-wide (lib/audio-context.ts). The gain and the\n// click buffer belong to it, so they are rebuilt if it was closed and\n// recreated.\nlet output: { gain: GainNode; buffer: AudioBuffer } | null = null;\n\nfunction buildClickBuffer(context: AudioContext): AudioBuffer {\n  const sampleRate = context.sampleRate;\n  const length = Math.ceil(DURATION * sampleRate);\n  const buffer = context.createBuffer(1, length, sampleRate);\n  const data = buffer.getChannelData(0);\n\n  let peak = 0;\n  let previousNoise = 0;\n  for (let i = 0; i < length; i++) {\n    const t = i / sampleRate;\n    const attack = Math.min(1, t / 0.00012);\n    const fadeOut = Math.min(1, (DURATION - t) / 0.001);\n    let resonance = 0;\n    for (const mode of MODES) {\n      resonance +=\n        Math.sin(2 * Math.PI * mode.frequency * t + mode.phase) *\n        Math.exp(-t / mode.decay) *\n        mode.gain;\n    }\n\n    // Differencing consecutive noise samples removes the low, papery part of\n    // white noise and leaves only a tiny impact at the front of the sound.\n    const noise = Math.random() * 2 - 1;\n    const transient =\n      (noise - previousNoise) * Math.exp(-t / 0.00038) * 0.035;\n    previousNoise = noise;\n\n    data[i] = (resonance + transient) * attack * fadeOut;\n    peak = Math.max(peak, Math.abs(data[i]));\n  }\n  if (peak > 0) {\n    const norm = 0.9 / peak;\n    for (let i = 0; i < length; i++) data[i] *= norm;\n  }\n\n  return buffer;\n}\n\nfunction prepareOutput() {\n  const ctx = getAudioContext();\n  if (!ctx) return null;\n  if (output?.gain.context !== ctx) {\n    const gain = ctx.createGain();\n    gain.gain.value = MASTER_GAIN;\n    gain.connect(ctx.destination);\n    output = { gain, buffer: buildClickBuffer(ctx) };\n  }\n  return { ctx, ...output };\n}\n\ninterface TickPlayer {\n  prepare: () => void;\n  /** `rate` below 1 plays a lower, longer tick; above 1 a higher, shorter one. */\n  play: (rate?: number) => void;\n  dispose: () => void;\n}\n\n/**\n * Lazily-initialized tick player. No `AudioContext` is created until the first\n * `prepare()` or `play()` call anywhere on the page. Every player shares the\n * same context and buffer; the context closes once the last player anywhere\n * on the page is disposed. Safe during SSR or in browsers without Web Audio:\n * `prepare()` and `play()` become silent no-ops.\n */\nexport function createTickPlayer(): TickPlayer {\n  const release = retainAudioContext();\n  let disposed = false;\n  let activeSource: AudioBufferSourceNode | null = null;\n\n  return {\n    prepare() {\n      if (disposed) return;\n      prepareOutput();\n    },\n    play(rate = 1) {\n      if (disposed) return;\n      const ready = prepareOutput();\n      if (!ready) return;\n      const { ctx, gain, buffer } = ready;\n      // A fast fling can cross rows more quickly than the tail of a tick.\n      // Cutting that tail keeps the feedback dry instead of stacking into a\n      // louder metallic ring.\n      activeSource?.stop();\n      const source = ctx.createBufferSource();\n      source.buffer = buffer;\n      source.playbackRate.value = rate;\n      source.connect(gain);\n      source.onended = () => {\n        source.disconnect();\n        if (activeSource === source) activeSource = null;\n      };\n      activeSource = source;\n      source.start();\n    },\n    dispose() {\n      if (disposed) return;\n      disposed = true;\n      activeSource?.stop();\n      activeSource = null;\n      release();\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-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/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":"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":"lib/hooks/use-listbox.ts","type":"registry:hook","target":"@lib/hooks/use-listbox.ts","content":"\"use client\";\n\nimport {\n  type KeyboardEvent,\n  type RefObject,\n  useEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\n\nexport interface ListboxRow {\n  id: string;\n  label: string;\n  disabled?: boolean;\n}\n\n// How long typed characters keep building one typeahead query.\nconst TYPEAHEAD_MS = 500;\n\n/**\n * Keyboard model for a popup listbox whose options are focusable buttons.\n *\n * The active option is resolved during render through `useRowCursor`, so the\n * roving `tabIndex` and the next key press always agree with what is on\n * screen. Opening starts on the selected option, or the first enabled one.\n * Arrow keys, Home and End move between enabled options, and printable\n * characters jump to the next option whose label starts with what was typed.\n * DOM focus follows the active option while the list is open; each option\n * carries its id in `data-listbox-id` so the list can find it.\n */\nexport function useListbox({\n  open,\n  rows,\n  selected,\n  list,\n}: {\n  open: boolean;\n  rows: readonly ListboxRow[];\n  selected: string | undefined;\n  list: RefObject<HTMLElement | null>;\n}) {\n  const enabled = useMemo(() => rows.filter((row) => !row.disabled), [rows]);\n  const { activeIndex, moveTo, moveActive } = useRowCursor(enabled, \"\");\n  useOnOpen(open, () =>\n    moveTo(enabled.some((row) => row.id === selected) ? (selected ?? null) : null),\n  );\n  const activeId = enabled[activeIndex]?.id;\n  const typed = useRef({ text: \"\", at: 0 });\n  // A list rendered open does not take focus from the page on mount; focus\n  // follows only once it has been opened, or once focus is already inside.\n  const openedOnMount = useRef(open);\n  useEffect(() => {\n    if (!open) openedOnMount.current = false;\n  }, [open]);\n\n  useEffect(() => {\n    if (!open || activeId === undefined) return;\n    if (\n      openedOnMount.current &&\n      !list.current?.contains(document.activeElement)\n    )\n      return;\n    const option = Array.from(\n      list.current?.querySelectorAll<HTMLElement>(\"[data-listbox-id]\") ?? [],\n    ).find((element) => element.dataset.listboxId === activeId);\n    option?.focus({ preventScroll: false });\n  }, [open, activeId, list]);\n\n  const onKeyDown = (event: KeyboardEvent) => {\n    const { key } = event;\n    if (key === \"ArrowDown\" || key === \"ArrowUp\") {\n      event.preventDefault();\n      moveActive(key === \"ArrowDown\" ? 1 : -1);\n    } else if (key === \"Home\" || key === \"End\") {\n      event.preventDefault();\n      const row = key === \"Home\" ? enabled[0] : enabled[enabled.length - 1];\n      if (row) moveTo(row.id);\n    } else if (\n      key.length === 1 &&\n      key !== \" \" &&\n      !event.ctrlKey &&\n      !event.metaKey &&\n      !event.altKey\n    ) {\n      const now = Date.now();\n      const char = key.toLowerCase();\n      const text =\n        now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + char : char;\n      typed.current = { text, at: now };\n      // A repeated single letter cycles through the options that start with it.\n      const from = text.length === 1 ? activeIndex + 1 : activeIndex;\n      for (let step = 0; step < enabled.length; step++) {\n        const row = enabled[(from + step) % enabled.length];\n        if (row.label.toLowerCase().startsWith(text)) {\n          moveTo(row.id);\n          break;\n        }\n      }\n    }\n  };\n\n  return { activeId, onKeyDown };\n}\n"},{"path":"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":"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/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/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/hooks/use-on-open.ts","type":"registry:hook","target":"@lib/hooks/use-on-open.ts","content":"\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"},{"path":"lib/hooks/use-row-cursor.ts","type":"registry:hook","target":"@lib/hooks/use-row-cursor.ts","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"},{"path":"lib/touch.ts","type":"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"}]}