{"slug":"prediction-market","name":"Prediction Market","description":"Animated market listing cards with outcome CTAs, probabilities and bookmarks, plus a trade ticket with buy/sell modes and rolling amount entry.","category":"blocks","source_url":"https://beui.dev/r/prediction-market/raw","detail_url":"https://beui.dev/r/prediction-market","raw_url":"https://beui.dev/r/prediction-market/raw","page_url":"https://beui.dev/components/blocks/prediction-market","markdown_url":"https://beui.dev/components/blocks/prediction-market.md","published_at":"2026-06-18","updated_at":"2026-09-14","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["./action-swap","./base","./button/stateful","./number-ticker","./tabs","./tooltip","@/components/motion/prediction-market","@/components/motion/tooltip-surface","@/lib/ease","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-capable","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-tap-gesture","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/prediction-market.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/prediction-market\n\nimport { Banknote, ChevronDown } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { StatefulButton, type ButtonState } from \"./button/stateful\";\nimport { NumberTicker } from \"./number-ticker\";\nimport { Tabs, TabsList, TabsTrigger } from \"./tabs\";\n\nexport type PredictionMarketMode = \"buy\" | \"sell\";\n\nexport type PredictionMarketOutcome = {\n  id: string;\n  label: string;\n  price: number;\n};\n\nexport type PredictionMarketOrderValue = {\n  mode: PredictionMarketMode;\n  outcomeId: string;\n  amount: string;\n};\n\nexport type PredictionMarketQuote = {\n  valid: boolean;\n  amount: number;\n  price: number;\n  shares: number;\n  payout: number;\n  error?: string;\n};\n\nexport type PredictionMarketClassNames = {\n  root?: string;\n  header?: string;\n  tabs?: string;\n  outcomes?: string;\n  amount?: string;\n  chips?: string;\n  footer?: string;\n  action?: string;\n};\n\nexport interface PredictionMarketProps {\n  outcomes?: PredictionMarketOutcome[];\n  value?: PredictionMarketOrderValue;\n  defaultValue?: Partial<PredictionMarketOrderValue>;\n  onValueChange?: (value: PredictionMarketOrderValue) => void;\n  onTrade?: (\n    order: PredictionMarketOrderValue,\n    quote: PredictionMarketQuote,\n  ) => void;\n  onSignIn?: () => void;\n  authenticated?: boolean;\n  orderTypeLabel?: string;\n  balance?: number;\n  positions?: Record<string, number>;\n  quickAmounts?: number[];\n  minTrade?: number;\n  className?: string;\n  classNames?: PredictionMarketClassNames;\n}\n\nconst DEFAULT_OUTCOMES: PredictionMarketOutcome[] = [\n  { id: \"up\", label: \"Up\", price: 0.09 },\n  { id: \"down\", label: \"Down\", price: 0.91 },\n];\n\nconst MODES: { id: PredictionMarketMode; label: string }[] = [\n  { id: \"buy\", label: \"Buy\" },\n  { id: \"sell\", label: \"Sell\" },\n];\n\nconst DEFAULT_QUICK_AMOUNTS = [10, 50, 100, 500];\nconst DIGIT_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const;\ntype AmountInputStyle = CSSProperties & { \"--amount-chars\": string };\n\nfunction useControllableOrder({\n  value,\n  defaultValue,\n  outcomes,\n  onValueChange,\n}: {\n  value?: PredictionMarketOrderValue;\n  defaultValue?: Partial<PredictionMarketOrderValue>;\n  outcomes: PredictionMarketOutcome[];\n  onValueChange?: (value: PredictionMarketOrderValue) => void;\n}) {\n  const initialValue: PredictionMarketOrderValue = {\n    mode: defaultValue?.mode ?? \"buy\",\n    outcomeId: defaultValue?.outcomeId ?? outcomes[0]?.id ?? \"\",\n    amount: defaultValue?.amount ?? \"\",\n  };\n\n  const [internalValue, setInternalValue] = useState(initialValue);\n  const controlled = value !== undefined;\n  const order = value ?? internalValue;\n\n  const setOrder = useCallback(\n    (next: PredictionMarketOrderValue) => {\n      if (!controlled) {\n        setInternalValue(next);\n      }\n\n      onValueChange?.(next);\n    },\n    [controlled, onValueChange],\n  );\n\n  return [order, setOrder] as const;\n}\n\nfunction sanitizeAmount(value: string) {\n  const normalized = value.replace(/[^\\d.]/g, \"\");\n  const [whole, ...decimalParts] = normalized.split(\".\");\n  const decimal = decimalParts.join(\"\");\n  if (decimalParts.length === 0) return whole;\n  return `${whole}.${decimal.slice(0, 2)}`;\n}\n\nfunction parseAmount(value: string) {\n  return Number(value) || 0;\n}\n\nfunction formatCurrency(value: number, maximumFractionDigits = 2) {\n  return new Intl.NumberFormat(\"en-US\", {\n    style: \"currency\",\n    currency: \"USD\",\n    maximumFractionDigits,\n  }).format(value);\n}\n\nfunction formatCompactCurrency(value: number) {\n  return value >= 100\n    ? formatCurrency(value, 0)\n    : formatCurrency(value, value % 1 === 0 ? 0 : 2);\n}\n\nfunction formatCents(value: number) {\n  const cents = value * 100;\n  const precision = Number.isInteger(cents) ? 0 : 1;\n  return `${cents.toFixed(precision)}¢`;\n}\n\nfunction buildQuote({\n  order,\n  outcome,\n  balance,\n  position,\n  minTrade,\n}: {\n  order: PredictionMarketOrderValue;\n  outcome: PredictionMarketOutcome;\n  balance: number;\n  position: number;\n  minTrade: number;\n}): PredictionMarketQuote {\n  const amount = parseAmount(order.amount);\n  const price = Math.max(0.01, Math.min(0.99, outcome.price));\n  const shares = order.mode === \"buy\" ? amount / price : amount;\n  const payout = order.mode === \"buy\" ? shares : amount * price;\n\n  if (amount <= 0) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares: 0,\n      payout: 0,\n      error: \"Enter an amount\",\n    };\n  }\n\n  if (order.mode === \"buy\" && amount < minTrade) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: `Minimum ${formatCompactCurrency(minTrade)}`,\n    };\n  }\n\n  if (order.mode === \"buy\" && amount > balance) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: \"Insufficient balance\",\n    };\n  }\n\n  if (order.mode === \"sell\" && amount > position) {\n    return {\n      valid: false,\n      amount,\n      price,\n      shares,\n      payout,\n      error: \"Not enough shares\",\n    };\n  }\n\n  return {\n    valid: true,\n    amount,\n    price,\n    shares,\n    payout,\n  };\n}\n\nfunction keyedAmountChars(value: string) {\n  const seen = new Map<string, number>();\n  return value.split(\"\").map((char) => {\n    const count = seen.get(char) ?? 0;\n    seen.set(char, count + 1);\n    return { id: `${char}-${count}`, char };\n  });\n}\n\nfunction amountInputSize(value: string) {\n  const length = value.replace(/\\D/g, \"\").length;\n  if (length >= 10) return \"text-3xl sm:text-4xl\";\n  if (length >= 8) return \"text-4xl sm:text-5xl\";\n  if (length >= 6) return \"text-[44px] sm:text-[56px]\";\n  return \"text-5xl sm:text-6xl\";\n}\n\nfunction payoutTickerSize(value: number) {\n  const length = formatCurrency(value).length;\n  if (length >= 16) return \"text-xl sm:text-2xl\";\n  if (length >= 13) return \"text-2xl\";\n  if (length >= 10) return \"text-3xl\";\n  return \"text-4xl\";\n}\n\nfunction AnimatedAmountInput({\n  id,\n  value,\n  mode,\n  inputSize,\n  disabled,\n  reduce,\n  onChange,\n}: {\n  id: string;\n  value: string;\n  mode: PredictionMarketMode;\n  inputSize: string;\n  disabled: boolean;\n  reduce: boolean;\n  onChange: (value: string) => void;\n}) {\n  const displayValue = value || \"0\";\n  const chars = keyedAmountChars(displayValue);\n  const inputStyle = {\n    \"--amount-chars\": String(chars.length),\n  } as AmountInputStyle;\n  const label = mode === \"buy\" ? \"Amount\" : \"Shares\";\n\n  return (\n    <div className=\"flex min-w-0 items-center justify-center overflow-hidden\">\n      {mode === \"buy\" ? (\n        <span\n          aria-hidden\n          className={cn(\n            \"shrink-0 font-semibold leading-none tracking-normal text-muted-foreground/65 tabular-nums transition-[font-size] duration-200\",\n            inputSize,\n          )}\n        >\n          $\n        </span>\n      ) : null}\n\n      <div className=\"relative min-w-0 shrink\">\n        <input\n          id={id}\n          value={value}\n          disabled={disabled}\n          onChange={(event) => onChange(sanitizeAmount(event.target.value))}\n          placeholder=\"0\"\n          inputMode=\"decimal\"\n          aria-label={label}\n          autoComplete=\"off\"\n          className={cn(\n            \"w-[calc((var(--amount-chars)+1)*0.62em)] min-w-[0.8em] max-w-[260px] bg-transparent text-left font-semibold leading-none tracking-normal text-transparent outline-none tabular-nums\",\n            \"caret-foreground transition-[font-size] duration-200 placeholder:text-transparent selection:bg-foreground/10 disabled:cursor-not-allowed\",\n            inputSize,\n          )}\n          style={inputStyle}\n        />\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 flex min-w-0 items-center justify-start overflow-hidden font-semibold leading-none tracking-normal text-foreground tabular-nums transition-[font-size] duration-200\",\n            !value && \"text-muted-foreground/55\",\n            inputSize,\n          )}\n          style={inputStyle}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {chars.map(({ id: charId, char }) => (\n              <motion.span\n                key={charId}\n                layout={reduce ? false : \"position\"}\n                initial={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: 18, filter: \"blur(10px)\" }\n                }\n                animate={\n                  reduce\n                    ? { opacity: 1 }\n                    : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                }\n                exit={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: -14, filter: \"blur(10px)\" }\n                }\n                transition={DIGIT_TRANSITION}\n                className=\"inline-block min-w-[0.55em] text-center will-change-[transform,opacity,filter]\"\n              >\n                {char}\n              </motion.span>\n            ))}\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport function PredictionMarket({\n  outcomes = DEFAULT_OUTCOMES,\n  value,\n  defaultValue,\n  onValueChange,\n  onTrade,\n  onSignIn,\n  authenticated = true,\n  orderTypeLabel = \"Market\",\n  balance = 500,\n  positions = { up: 24, down: 16 },\n  quickAmounts = DEFAULT_QUICK_AMOUNTS,\n  minTrade = 1,\n  className,\n  classNames,\n}: PredictionMarketProps) {\n  const inputId = useId();\n  const reduce = useReducedMotion() ?? false;\n  const amountRef = useRef<HTMLDivElement>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [status, setStatus] = useState<\"idle\" | \"placing\" | \"filled\">(\"idle\");\n  const [shakeKey, setShakeKey] = useState(0);\n  const [order, setOrder] = useControllableOrder({\n    value,\n    defaultValue,\n    outcomes,\n    onValueChange,\n  });\n\n  const selectedOutcome =\n    outcomes.find((outcome) => outcome.id === order.outcomeId) ?? outcomes[0];\n  const position = positions[selectedOutcome.id] ?? 0;\n  const quote = useMemo(\n    () =>\n      buildQuote({\n        order,\n        outcome: selectedOutcome,\n        balance,\n        position,\n        minTrade,\n      }),\n    [balance, minTrade, order, position, selectedOutcome],\n  );\n\n  const setOrderValue = useCallback(\n    (next: Partial<PredictionMarketOrderValue>) => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n        timeoutRef.current = null;\n      }\n\n      setStatus(\"idle\");\n      setOrder({ ...order, ...next });\n    },\n    [order, setOrder],\n  );\n\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) clearTimeout(timeoutRef.current);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (shakeKey === 0 || reduce || !amountRef.current) return;\n    animate(\n      amountRef.current,\n      { x: [0, -5, 5, -3, 3, -1, 0] },\n      { duration: 0.38, ease: EASE_OUT },\n    );\n  }, [reduce, shakeKey]);\n\n  const addAmount = (increment: number) => {\n    const next = parseAmount(order.amount) + increment;\n    setOrderValue({ amount: String(next) });\n  };\n\n  const setMax = () => {\n    if (order.mode === \"buy\") {\n      setOrderValue({ amount: String(Math.floor(balance)) });\n      return;\n    }\n\n    setOrderValue({ amount: position.toFixed(position % 1 === 0 ? 0 : 2) });\n  };\n\n  const submit = () => {\n    if (!authenticated) {\n      onSignIn?.();\n      return;\n    }\n\n    if (!quote.valid) {\n      setShakeKey((key) => key + 1);\n      return;\n    }\n\n    setStatus(\"placing\");\n    timeoutRef.current = setTimeout(() => {\n      setStatus(\"filled\");\n      onTrade?.(order, quote);\n    }, 650);\n  };\n\n  const inputSize = amountInputSize(order.amount);\n  const payoutSize = payoutTickerSize(quote.payout);\n  const actionState: ButtonState =\n    status === \"placing\"\n      ? \"loading\"\n      : status === \"filled\"\n        ? \"success\"\n        : quote.valid\n          ? \"idle\"\n          : \"error\";\n  const showFooter = authenticated;\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-[400px] overflow-hidden rounded-3xl border border-border bg-background\",\n        className,\n        classNames?.root,\n      )}\n    >\n      <div\n        className={cn(\n          \"border-b border-border/80 px-4 pt-4\",\n          classNames?.header,\n        )}\n      >\n        <div className=\"flex items-end justify-between gap-4\">\n          <Tabs\n            value={order.mode}\n            onValueChange={(mode) =>\n              setOrderValue({\n                mode: mode as PredictionMarketMode,\n                amount: \"\",\n              })\n            }\n            variant=\"underline\"\n            className={cn(\"shrink-0\", classNames?.tabs)}\n          >\n            <TabsList className=\"gap-5 border-b-0 bg-transparent p-0\">\n              {MODES.map((mode) => (\n                <TabsTrigger\n                  key={mode.id}\n                  value={mode.id}\n                  className=\"px-0 pb-3 pt-0 text-2xl font-semibold\"\n                  indicatorClassName=\"h-0.5 bg-foreground\"\n                >\n                  {mode.label}\n                </TabsTrigger>\n              ))}\n            </TabsList>\n          </Tabs>\n\n          <button\n            type=\"button\"\n            disabled={status === \"placing\"}\n            className=\"mb-3 inline-flex items-center gap-2 text-xl font-semibold text-foreground transition-opacity disabled:opacity-50\"\n          >\n            {orderTypeLabel}\n            <ChevronDown className=\"h-5 w-5\" />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"space-y-4 p-3\">\n        <Tabs\n          value={selectedOutcome.id}\n          onValueChange={(outcomeId) => setOrderValue({ outcomeId })}\n          variant=\"pill\"\n          className={classNames?.outcomes}\n        >\n          <TabsList className=\"grid w-full grid-cols-2 gap-2 p-1.5\">\n            {outcomes.map((outcome) => {\n              const selected = outcome.id === selectedOutcome.id;\n              const isNo =\n                outcome.label.toLowerCase() === \"no\" ||\n                outcome.label.toLowerCase() === \"down\";\n\n              return (\n                <TabsTrigger\n                  key={outcome.id}\n                  value={outcome.id}\n                  indicatorClassName={\n                    isNo\n                      ? \"bg-red-500/10 dark:bg-red-500/15\"\n                      : \"bg-emerald-500/20\"\n                  }\n                  className={cn(\n                    \"h-14 w-full rounded-[1.35rem] px-0 py-0 text-base font-semibold active:scale-[0.99]\",\n                    isNo\n                      ? selected\n                        ? \"text-red-300 dark:text-red-300\"\n                        : \"text-red-300/55 dark:text-red-300/50\"\n                      : selected\n                        ? \"text-emerald-400 dark:text-emerald-300\"\n                        : \"text-muted-foreground\",\n                  )}\n                >\n                  {outcome.label} {formatCents(outcome.price)}\n                </TabsTrigger>\n              );\n            })}\n          </TabsList>\n        </Tabs>\n\n        <div\n          ref={amountRef}\n          className={cn(\"rounded-3xl bg-card p-4\", classNames?.amount)}\n        >\n          <div className=\"flex min-h-24 flex-col items-center justify-center gap-5 text-center\">\n            <label\n              htmlFor={inputId}\n              className=\"text-xl font-medium text-foreground mr-6\"\n            >\n              {order.mode === \"buy\" ? \"Amount\" : \"Shares\"}\n            </label>\n\n            <div className=\"w-full min-w-0\">\n              <AnimatedAmountInput\n                id={inputId}\n                mode={order.mode}\n                value={order.amount}\n                disabled={status === \"placing\"}\n                inputSize={inputSize}\n                reduce={reduce}\n                onChange={(amount) => setOrderValue({ amount })}\n              />\n            </div>\n          </div>\n\n          <div\n            className={cn(\n              \"mt-8 flex flex-wrap justify-center gap-2\",\n              classNames?.chips,\n            )}\n          >\n            {quickAmounts.map((amount) => (\n              <button\n                key={amount}\n                type=\"button\"\n                disabled={status === \"placing\"}\n                onClick={() => addAmount(amount)}\n                className=\"h-9 rounded-xl bg-background px-3.5 text-sm font-semibold text-foreground transition-[background-color,transform] duration-150 active:scale-95 disabled:pointer-events-none disabled:opacity-50\"\n              >\n                +{order.mode === \"buy\" ? formatCompactCurrency(amount) : amount}\n              </button>\n            ))}\n            <button\n              type=\"button\"\n              disabled={status === \"placing\"}\n              onClick={setMax}\n              className=\"h-9 rounded-xl bg-background px-3.5 text-sm font-semibold text-foreground transition-[background-color,transform] duration-150 active:scale-95 disabled:pointer-events-none disabled:opacity-50\"\n            >\n              Max\n            </button>\n          </div>\n        </div>\n      </div>\n\n      {showFooter ? (\n        <div\n          className={cn(\n            \"border-t border-border/80 px-4 py-4\",\n            classNames?.footer,\n          )}\n        >\n          <div className=\"mb-4 flex items-end justify-between gap-3\">\n            <div className=\"min-w-0 shrink\">\n              <div className=\"flex items-center gap-2 text-xl font-semibold text-foreground\">\n                {order.mode === \"buy\" ? \"To win\" : \"To receive\"}\n                <Banknote className=\"h-5 w-5 text-emerald-500\" />\n              </div>\n              <p className=\"text-sm font-medium text-muted-foreground\">\n                Avg. Price {formatCents(quote.price)}\n              </p>\n            </div>\n            <NumberTicker\n              value={quote.payout * 100}\n              startOnView={false}\n              duration={0.45}\n              stagger={0}\n              blur\n              className={cn(\n                \"ml-auto min-w-0 shrink-0 justify-end whitespace-nowrap text-right font-semibold leading-none tracking-tight text-emerald-500 tabular-nums transition-[font-size] duration-200\",\n                payoutSize,\n              )}\n              format={(cents) => formatCurrency(cents / 100)}\n            />\n          </div>\n\n          <StatefulButton\n            state={actionState}\n            variant=\"primary\"\n            size=\"lg\"\n            pressScale={0.98}\n            onClick={submit}\n            loadingText=\"Trading\"\n            successText=\"Trade filled\"\n            errorText={quote.error ?? \"Enter an amount\"}\n            className={cn(\n              \"h-12 w-full rounded-2xl text-base font-semibold\",\n              classNames?.action,\n            )}\n          >\n            Trade\n          </StatefulButton>\n        </div>\n      ) : (\n        <div className=\"px-4 pb-5\">\n          <StatefulButton\n            state=\"idle\"\n            variant=\"primary\"\n            size=\"lg\"\n            pressScale={0.98}\n            onClick={submit}\n            className={cn(\n              \"h-14 w-full rounded-2xl text-base font-semibold\",\n              classNames?.action,\n            )}\n          >\n            Connect\n          </StatefulButton>\n        </div>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/motion/prediction-market-card.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/prediction-market\n\nimport { Bookmark } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useId, useState } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { Tooltip } from \"./tooltip\";\nimport { ActionSwapText } from \"./action-swap\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport interface PredictionMarketCardOutcome {\n\tid: string;\n\tlabel: string;\n\t/** Probability between 0 and 1. */\n\tprobability: number;\n\ticon?: ReactNode;\n\t/** Optional team color for the compact probability line. */\n\tcolor?: string;\n}\n\nexport interface PredictionMarketCardSelection {\n\toutcomeId: string;\n\tside: \"yes\" | \"no\";\n}\n\nexport interface PredictionMarketCardProps {\n\ttitle: string;\n\ticon?: ReactNode;\n\tcategory?: string;\n\tvolume: string;\n\t/** Chronological volume samples for the optional footer sparkline. */\n\tvolumeHistory?: number[];\n\t/** A scheduled time or live match status. */\n\tstatus?: string;\n\tlive?: boolean;\n\toutcomes: PredictionMarketCardOutcome[];\n\t/** Called on each outcome CTA click; the card keeps no selected state. */\n\tonOutcomeClick?: (value: PredictionMarketCardSelection) => void;\n\tbookmarked?: boolean;\n\tdefaultBookmarked?: boolean;\n\tonBookmarkChange?: (bookmarked: boolean) => void;\n\tclassName?: string;\n}\n\nfunction probability(value: number) {\n\treturn Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;\n}\n\n/** A listing surface with outcome CTAs and an independent bookmark toggle. */\nexport function PredictionMarketCard({\n\ttitle,\n\ticon,\n\tcategory,\n\tvolume,\n\tvolumeHistory,\n\tstatus,\n\tlive = false,\n\toutcomes,\n\tonOutcomeClick,\n\tbookmarked,\n\tdefaultBookmarked = false,\n\tonBookmarkChange,\n\tclassName,\n}: PredictionMarketCardProps) {\n\tconst titleId = useId();\n\tconst chartId = useId();\n\tconst samples = volumeHistory?.filter(Number.isFinite) ?? [];\n\tconst low = Math.min(...samples);\n\tconst range = Math.max(...samples) - low;\n\tconst chartPoints =\n\t\tsamples.length > 1\n\t\t\t? samples\n\t\t\t\t\t.map(\n\t\t\t\t\t\t(sample, index) =>\n\t\t\t\t\t\t\t`${2 + (index / (samples.length - 1)) * 44},${18 - (range ? (sample - low) / range : 0.5) * 14}`,\n\t\t\t\t\t)\n\t\t\t\t\t.join(\" \")\n\t\t\t: null;\n\tconst reduce = useReducedMotion();\n\tconst [internalBookmark, setInternalBookmark] = useState(defaultBookmarked);\n\tconst saved = bookmarked ?? internalBookmark;\n\n\treturn (\n\t\t<article\n\t\t\taria-labelledby={titleId}\n\t\t\tclassName={cn(\n\t\t\t\t\"flex h-full w-full min-w-0 flex-col overflow-hidden rounded-3xl bg-card text-foreground\",\n\t\t\t\tclassName,\n\t\t\t)}\n\t\t>\n\t\t\t<header className=\"flex shrink-0 items-center gap-3 px-4 py-3\">\n\t\t\t\t{icon && (\n\t\t\t\t\t<div\n\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\tclassName=\"flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-background text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{icon}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t<div className=\"min-w-0 flex-1\">\n\t\t\t\t\t<h3\n\t\t\t\t\t\tid={titleId}\n\t\t\t\t\t\tclassName=\"break-words font-display text-base font-medium leading-snug tracking-tight\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{title}\n\t\t\t\t\t</h3>\n\t\t\t\t\t<p className=\"mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground\">\n\t\t\t\t\t\t{status && (\n\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"inline-flex items-center gap-1.5\",\n\t\t\t\t\t\t\t\t\tlive && \"text-rose-500\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{live && (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\t\t\tclassName=\"size-1.5 rounded-full bg-current\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t{status}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t{status && category && <span aria-hidden>·</span>}\n\t\t\t\t\t\t{category && <span>{category}</span>}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t</header>\n\n\t\t\t<div className=\"mx-2 mb-2 flex flex-1 flex-col rounded-3xl bg-background px-4 py-3\">\n\t\t\t\t<div className=\"flex flex-1 flex-col justify-center gap-3\">\n\t\t\t\t\t{outcomes.map((outcome, index) => (\n\t\t\t\t\t\t<div key={outcome.id} className=\"space-y-1\">\n\t\t\t\t\t\t\t<div className=\"flex min-h-10 items-center gap-2\">\n\t\t\t\t\t\t\t\t{outcome.icon && (\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\t\t\tclassName=\"flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{outcome.icon}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<span className=\"min-w-0 flex-1 break-words text-sm font-medium\">\n\t\t\t\t\t\t\t\t\t{outcome.label}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t<Tooltip\n\t\t\t\t\t\t\t\t\tcontent=\"Potential payout per $1 if this outcome wins, including your stake. Before fees; based on the displayed price.\"\n\t\t\t\t\t\t\t\t\twrapperClassName=\"shrink-0\"\n\t\t\t\t\t\t\t\t\tclassName=\"w-44 whitespace-normal text-center leading-relaxed\"\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\taria-label={`Potential payout for ${outcome.label}`}\n\t\t\t\t\t\t\t\t\t\tclassName=\"rounded-md py-2 text-sm tabular-nums text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{probability(outcome.probability) > 0\n\t\t\t\t\t\t\t\t\t\t\t? `${(1 / probability(outcome.probability)).toFixed(1)}×`\n\t\t\t\t\t\t\t\t\t\t\t: \"—\"}\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t</Tooltip>\n\t\t\t\t\t\t\t\t<MarketOddsButton\n\t\t\t\t\t\t\t\t\tpositive={index % 2 === 0}\n\t\t\t\t\t\t\t\t\toutcome={outcome}\n\t\t\t\t\t\t\t\t\tonClick={() =>\n\t\t\t\t\t\t\t\t\t\tonOutcomeClick?.({ outcomeId: outcome.id, side: \"yes\" })\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\tclassName=\"h-0.5 w-24 overflow-hidden rounded-full\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<motion.div\n\t\t\t\t\t\t\t\t\tinitial={false}\n\t\t\t\t\t\t\t\t\tanimate={{ scaleX: probability(outcome.probability) }}\n\t\t\t\t\t\t\t\t\ttransition={\n\t\t\t\t\t\t\t\t\t\treduce\n\t\t\t\t\t\t\t\t\t\t\t? { duration: 0 }\n\t\t\t\t\t\t\t\t\t\t\t: { duration: 0.25, ease: EASE_OUT }\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tclassName=\"h-full origin-left rounded-full bg-emerald-300/70 dark:bg-emerald-400/40\"\n\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\toutcome.color\n\t\t\t\t\t\t\t\t\t\t\t? { backgroundColor: outcome.color }\n\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\n\t\t\t\t<footer className=\"mt-2 flex shrink-0 items-center gap-2 text-xs text-muted-foreground\">\n\t\t\t\t\t{chartPoints && (\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\tviewBox=\"0 0 48 22\"\n\t\t\t\t\t\t\tclassName=\"h-5 w-12 shrink-0 text-emerald-500 dark:text-emerald-400\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<defs>\n\t\t\t\t\t\t\t\t<linearGradient id={chartId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n\t\t\t\t\t\t\t\t\t<stop\n\t\t\t\t\t\t\t\t\t\toffset=\"0%\"\n\t\t\t\t\t\t\t\t\t\tstopColor=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\tstopOpacity=\"0.22\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t<stop\n\t\t\t\t\t\t\t\t\t\toffset=\"100%\"\n\t\t\t\t\t\t\t\t\t\tstopColor=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\tstopOpacity=\"0\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t</linearGradient>\n\t\t\t\t\t\t\t</defs>\n\t\t\t\t\t\t\t<polygon\n\t\t\t\t\t\t\t\tpoints={`2,22 ${chartPoints} 46,22`}\n\t\t\t\t\t\t\t\tfill={`url(#${chartId})`}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t<polyline\n\t\t\t\t\t\t\t\tpoints={chartPoints}\n\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\tstrokeWidth=\"1.75\"\n\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t)}\n\t\t\t\t\t<span className=\"shrink-0\">{volume} vol.</span>\n\t\t\t\t\t<motion.button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\taria-label={`Bookmark ${title}`}\n\t\t\t\t\t\taria-pressed={saved}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (bookmarked === undefined) setInternalBookmark(!saved);\n\t\t\t\t\t\t\tonBookmarkChange?.(!saved);\n\t\t\t\t\t\t}}\n\t\t\t\t\t\twhileTap={reduce ? undefined : { scale: 0.85 }}\n\t\t\t\t\t\ttransition={SPRING_PRESS}\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ml-auto flex size-9 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring\",\n\t\t\t\t\t\t\tsaved && \"text-foreground\",\n\t\t\t\t\t\t)}\n\t\t\t\t\t>\n\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\tanimate={{ scale: saved && !reduce ? [1, 1.2, 1] : 1 }}\n\t\t\t\t\t\t\ttransition={{ duration: 0.22, ease: EASE_OUT }}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Bookmark\n\t\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\t\tclassName={cn(\"size-4\", saved && \"fill-current\")}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</motion.span>\n\t\t\t\t\t</motion.button>\n\t\t\t\t</footer>\n\t\t\t</div>\n\t\t</article>\n\t);\n}\n\nfunction MarketOddsButton({\n\tpositive,\n\toutcome,\n\tonClick,\n}: {\n\toutcome: PredictionMarketCardOutcome;\n\tpositive: boolean;\n\tonClick: () => void;\n}) {\n\tconst reduce = useReducedMotion();\n\tconst cents = Math.round(probability(outcome.probability) * 100);\n\tconst canHover = useHoverCapable();\n\tconst [hovered, setHovered] = useState(false);\n\tconst [focused, setFocused] = useState(false);\n\tconst showAction = (canHover && hovered) || focused;\n\treturn (\n\t\t<motion.button\n\t\t\ttype=\"button\"\n\t\t\taria-label={`Trade ${outcome.label} at ${cents}%`}\n\t\t\tonClick={onClick}\n\t\t\tonPointerEnter={(event) => {\n\t\t\t\tif (event.pointerType !== \"touch\") setHovered(true);\n\t\t\t}}\n\t\t\tonPointerLeave={() => setHovered(false)}\n\t\t\tonFocus={(event) =>\n\t\t\t\tsetFocused(event.currentTarget.matches(\":focus-visible\"))\n\t\t\t}\n\t\t\tonBlur={() => setFocused(false)}\n\t\t\twhileTap={reduce ? undefined : { scale: 0.96 }}\n\t\t\ttransition={SPRING_PRESS}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative flex min-h-10 min-w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground shadow-[0_3px_0] transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n\t\t\t\tpositive &&\n\t\t\t\t\t\"shadow-emerald-500/20 border-emerald-500/25 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-400\",\n\t\t\t\t!positive &&\n\t\t\t\t\t\"shadow-rose-500/20 border-rose-500/25 bg-rose-500/10 text-rose-700 hover:bg-rose-500/15 dark:text-rose-400\",\n\t\t\t)}\n\t\t>\n\t\t\t<ActionSwapText\n\t\t\t\tvalue={showAction ? \"action\" : String(cents)}\n\t\t\t\tanimation=\"roll\"\n\t\t\t>\n\t\t\t\t{showAction ? (positive ? \"Yes\" : \"No\") : `${cents}%`}\n\t\t\t</ActionSwapText>\n\t\t</motion.button>\n\t);\n}\n"},{"path":"components/motion/button/stateful.tsx","type":"util","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/number-ticker.tsx","type":"util","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/tabs.tsx","type":"util","content":"\"use client\";\n\nimport { motion, MotionConfig, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"pill\" | \"underline\" | \"segment\";\n\ntype Ctx = {\n  value: string;\n  setValue: (v: string) => void;\n  layoutId: string;\n  variant: Variant;\n};\n\nconst TabsCtx = createContext<Ctx | null>(null);\n\nfunction useTabs() {\n  const ctx = useContext(TabsCtx);\n  if (!ctx) throw new Error(\"Tabs.* must be used inside <Tabs>\");\n  return ctx;\n}\n\n// Settle without overshoot: a scrollable tab list would turn even a small\n// overshoot into a transient scrollbar and layout shift.\nconst transition: Transition = {\n  type: \"spring\",\n  stiffness: 170,\n  damping: 30,\n  mass: 1.2,\n};\n\nexport function Tabs({\n  defaultValue,\n  value,\n  onValueChange,\n  variant = \"pill\",\n  children,\n  className,\n}: {\n  defaultValue?: string;\n  value?: string;\n  onValueChange?: (v: string) => void;\n  variant?: Variant;\n  children: ReactNode;\n  className?: string;\n}) {\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = useCallback(\n    (v: string) => {\n      if (!controlled) setInternal(v);\n      onValueChange?.(v);\n    },\n    [controlled, onValueChange],\n  );\n  const contextValue = useMemo(\n    () => ({ value: current, setValue, layoutId, variant }),\n    [current, layoutId, setValue, variant],\n  );\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : transition}>\n      <TabsCtx.Provider value={contextValue}>\n        {/* layoutRoot: the indicator's layoutId measures in page coordinates, so\n            inside fixed/scrolled containers it would replay scroll offsets as\n            movement. The pill only ever travels within the list, so scoping\n            projection to the Tabs wrapper is always correct. */}\n        <motion.div layoutRoot className={className}>\n          {children}\n        </motion.div>\n      </TabsCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nconst listClasses: Record<Variant, string> = {\n  pill: \"inline-flex items-center gap-1 rounded-full bg-card p-1\",\n  underline: \"inline-flex items-center gap-1 border-b border-border\",\n  segment: \"inline-flex items-center gap-0 rounded-lg bg-card p-0.5\",\n};\n\nexport function TabsList({ children, className }: { children: ReactNode; className?: string }) {\n  const { variant } = useTabs();\n  return (\n    <div role=\"tablist\" className={cn(listClasses[variant], className)}>\n      {children}\n    </div>\n  );\n}\n\nexport function TabsTrigger({\n  value,\n  children,\n  className,\n  indicatorClassName,\n}: {\n  value: string;\n  children: ReactNode;\n  className?: string;\n  indicatorClassName?: string;\n}) {\n  const { value: current, setValue, layoutId, variant } = useTabs();\n  const active = current === value;\n\n  if (variant === \"underline\") {\n    return (\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center\",\n          active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          className,\n        )}\n      >\n        {children}\n        {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout=\"position\"\n          className={cn(\n            \"absolute -bottom-px left-0 right-0 h-px bg-primary\",\n            indicatorClassName,\n          )}\n        />\n        ) : null}\n      </button>\n    );\n  }\n\n  const radius = variant === \"pill\" ? \"rounded-full\" : \"rounded-md\";\n\n  return (\n    <div className=\"relative\">\n      {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout=\"position\"\n          style={{ borderRadius: variant === \"pill\" ? 9999 : 8 }}\n          className={cn(\n            \"absolute inset-0 bg-primary\",\n            radius,\n            indicatorClassName,\n          )}\n        />\n      ) : null}\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none\",\n          \"transition-colors\",\n          active\n            ? \"text-primary-foreground\"\n            : \"text-muted-foreground hover:text-foreground\",\n          radius,\n          className,\n        )}\n      >\n        {children}\n      </button>\n    </div>\n  );\n}\n\nexport function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {\n  const { value: current } = useTabs();\n  const reduce = useReducedMotion();\n  const active = current === value;\n  // Inactive panels stay mounted but hidden, so their content (e.g. source\n  // code) is present in the server-rendered HTML for crawlers and assistive\n  // tech, instead of being dropped from the DOM.\n  if (!active) {\n    return (\n      <div hidden className={className}>\n        {children}\n      </div>\n    );\n  }\n  return (\n    <motion.div\n      key={value}\n      initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n      className={cn(\"mt-4\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/action-swap.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_OUT, 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\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\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.94, 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.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_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_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-primary/5\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 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                  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  ...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\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\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      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  cloneElement,\n  isValidElement,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\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\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children?: ReactElement;\n  /** Existing trigger for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<HTMLElement | SVGElement | null>;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: { x: number; y: number };\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  id?: string;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\n// Gap between trigger and tooltip, in px.\nconst GAP = 8;\n\n// Centering transform for the fixed-positioned anchor point, per side.\nconst anchorTransform: Record<Side, string> = {\n  top: \"translate(-50%, -100%)\",\n  bottom: \"translate(-50%, 0)\",\n  left: \"translate(-100%, -50%)\",\n  right: \"translate(0, -50%)\",\n};\n\nconst transformOrigin: Record<Side, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  delay = 120,\n  className,\n  wrapperClassName,\n  anchorRef: externalAnchorRef,\n  anchorPoint,\n  open: controlledOpen,\n  onOpenChange,\n  id: providedId,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = controlledOpen ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n  const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);\n  const generatedId = useId();\n  const id = providedId ?? generatedId;\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = externalAnchorRef ?? wrapperRef;\n  const hover = useHoverGesture();\n  const surfaceRef = useRef<HTMLSpanElement>(null);\n\n  // Anchor point in viewport coords, on the edge of the trigger facing `side`.\n  // Position:fixed means these viewport coords place the tooltip directly, so\n  // it escapes every ancestor's stacking context and overflow.\n  const place = useCallback(() => {\n    const el = anchorRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const cx = r.left + r.width * (anchorPoint?.x ?? 0.5);\n    const cy = r.top + r.height * (anchorPoint?.y ?? 0.5);\n    const point: Record<Side, { top: number; left: number }> = {\n      top: { top: (anchorPoint ? cy : r.top) - GAP, left: cx },\n      bottom: { top: (anchorPoint ? cy : r.bottom) + GAP, left: cx },\n      left: { top: cy, left: (anchorPoint ? cx : r.left) - GAP },\n      right: { top: cy, left: (anchorPoint ? cx : r.right) + GAP },\n    };\n    const next = point[side];\n    const width = surfaceRef.current?.offsetWidth ?? 0;\n    const height = surfaceRef.current?.offsetHeight ?? 0;\n    const dx = side === \"left\" ? width : side === \"right\" ? 0 : width / 2;\n    const dy = side === \"top\" ? height : side === \"bottom\" ? 0 : height / 2;\n    next.left = Math.max(GAP + dx, Math.min(next.left, window.innerWidth - GAP - width + dx));\n    next.top = Math.max(GAP + dy, Math.min(next.top, window.innerHeight - GAP - height + dy));\n    setCoords(previous => previous?.top === next.top && previous.left === next.left ? previous : next);\n  }, [side, anchorRef, anchorPoint]);\n\n  const positioned = coords !== null;\n  useLayoutEffect(() => {\n    if (!open) return;\n    place();\n    const observer = new ResizeObserver(place);\n    if (anchorRef.current) observer.observe(anchorRef.current);\n    if (positioned && surfaceRef.current) observer.observe(surfaceRef.current);\n    return () => observer.disconnect();\n  }, [open, place, anchorRef, positioned]);\n\n  const show = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    timer.current = setTimeout(\n      () => {\n        place();\n        setOpen(true);\n      },\n      warm ? 0 : delay,\n    );\n  }, [delay, place, setOpen]);\n\n  const hide = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  }, [open, setOpen]);\n\n  // A finger never hovers, and Safari does not focus a button on tap either, so\n  // the label is only reachable if the tap itself opens the tooltip. A click\n  // carries no pointerType, so the pointerdown that preceded it is what says\n  // whether this was a tap; keyboard activation arrives with no pointerdown at\n  // all, and focus has already shown the label there.\n  const tap = useTapGesture<boolean>();\n\n  const toggleOnTap = useCallback(() => {\n    const gesture = tap.take();\n    if (!gesture || gesture.pointerType === \"mouse\") return;\n    if (gesture.state) {\n      hide();\n      return;\n    }\n    if (timer.current) clearTimeout(timer.current);\n    place();\n    setOpen(true);\n  }, [hide, place, tap, setOpen]);\n\n  // ...and closed again by the next tap that lands somewhere else. The label\n  // covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, hide, anchorRef);\n\n  // Keep the tooltip pinned to the trigger while it's open and the page scrolls\n  // or resizes (fixed coords are viewport-relative).\n  useEffect(() => {\n    if (!open) return;\n    const onMove = () => place();\n    window.addEventListener(\"scroll\", onMove, true);\n    window.addEventListener(\"resize\", onMove);\n    return () => {\n      window.removeEventListener(\"scroll\", onMove, true);\n      window.removeEventListener(\"resize\", onMove);\n    };\n  }, [open, place]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  if (!externalAnchorRef && !isValidElement(children)) return children;\n\n  // The label describes the trigger, so it has to name the trigger itself.\n  // Everything else the tooltip needs is read off the anchor below instead of\n  // cloned on: a handler written onto the child is the child's handler as far\n  // as that child can tell, and a component that owns its activation —\n  // hard-wiring onClick and spreading the rest of its props over it, as\n  // ThemeToggle does — then runs the tooltip's instead of its own. Composing\n  // with `props.onClick` cannot save it either, because a component element's\n  // props hold nothing the component does internally.\n  const trigger = isValidElement(children)\n    ? cloneElement(children as ReactElement<Record<string, unknown>>, {\n        \"aria-describedby\": id,\n      })\n    : null;\n\n  return (\n    <>\n      {!externalAnchorRef ? (\n        // biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.\n        <span\n          ref={wrapperRef}\n          className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n          // Pointer events, not the mouse pair: a tap fires compatibility\n          // mouseenter/mouseleave that carry no pointerType, which raced the tap\n          // path into opening and closing the same label.\n          onPointerEnter={(event: PointerEvent) => {\n            if (hover.enter(event)) show();\n          }}\n          onPointerLeave={(event: PointerEvent) => {\n            if (hover.leave(event)) hide();\n          }}\n          onFocus={show}\n          onBlur={hide}\n          onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n          // A gesture the platform took away sends no click, and a key press\n          // starts an activation that never had a pointer behind it. Either way\n          // the record has to go, or the next click reads a finger that has long\n          // since lifted.\n          onPointerCancel={tap.drop}\n          onKeyDown={tap.drop}\n          onClick={toggleOnTap}\n        >\n          {trigger}\n        </span>\n      ) : null}\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open && coords ? (\n                <span\n                  className=\"pointer-events-none fixed z-[9999]\"\n                  style={{\n                    top: coords.top,\n                    left: coords.left,\n                    transform: anchorTransform[side],\n                  }}\n                >\n                  <TooltipSurface\n                    ref={surfaceRef}\n                    id={id}\n                    side={side}\n                    style={{ transformOrigin: transformOrigin[side], maxWidth: \"calc(100vw - 16px)\", whiteSpace: \"normal\" }}\n                    className={className}\n                  >\n                    {content}\n                  </TooltipSurface>\n                </span>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/button/base.tsx","type":"util","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\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/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { useMemo, type ComponentProps, type ReactNode, type Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n// Offset is in the direction *away* from the trigger — content originates near\n// the trigger and rises into resting position.\nconst offsetFrom: Record<Side, { x?: number; y?: number }> = {\n  top: { y: 8 },\n  bottom: { y: -8 },\n  left: { x: 8 },\n  right: { x: -8 },\n};\n\n// Small tooltip surfaces need the lighter spawn used by the original Tooltip.\nconst TOOLTIP_SPRING = { type: \"spring\", stiffness: 380, damping: 30, mass: 0.7 } as const;\n\nfunction buildVariants(side: Side): Variants {\n  const o = offsetFrom[side];\n  return {\n    initial: {\n      opacity: 0,\n      scale: 0.9,\n      filter: \"blur(5px)\",\n      x: o.x ?? 0,\n      y: o.y ?? 0,\n    },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      x: 0,\n      y: 0,\n      transition: {\n        ...TOOLTIP_SPRING,\n        opacity: { duration: 0.14, ease: EASE_OUT },\n        filter: { duration: 0.18, ease: EASE_OUT },\n      },\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: \"blur(3px)\",\n      x: (o.x ?? 0) * 0.6,\n      y: (o.y ?? 0) * 0.6,\n      transition: { duration: 0.12, ease: EASE_OUT },\n    },\n  };\n}\n\nconst REDUCED_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },\n  exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },\n};\n\n/** The shared visual surface for trigger tooltips and chart readouts. Positioning belongs to the caller. */\nexport function TooltipSurface({\n  children,\n  side = \"top\",\n  className,\n  ref,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  side?: Side;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const variants = useMemo(() => reduce ? REDUCED_VARIANTS : buildVariants(side), [reduce, side]);\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      variants={variants}\n      initial=\"initial\"\n      animate=\"animate\"\n      exit=\"exit\"\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/previews/blocks/prediction-market.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  PredictionMarket,\n  type PredictionMarketOrderValue,\n} from \"@/components/motion/prediction-market\";\n\nconst outcomes = [\n  {\n    id: \"yes\",\n    label: \"Yes\",\n    price: 0.167,\n  },\n  {\n    id: \"no\",\n    label: \"No\",\n    price: 0.834,\n  },\n];\n\nexport function PredictionMarketPreview() {\n  const [order, setOrder] = useState<PredictionMarketOrderValue>({\n    mode: \"buy\",\n    outcomeId: \"yes\",\n    amount: \"115\",\n  });\n\n  return (\n    <div className=\"flex w-full items-center justify-center\">\n      <PredictionMarket\n        outcomes={outcomes}\n        value={order}\n        onValueChange={setOrder}\n        balance={500}\n        positions={{ yes: 125, no: 48 }}\n        quickAmounts={[1, 5, 10, 100]}\n      />\n    </div>\n  );\n}\n"}]}