{"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-10-18","dependencies":["@floating-ui/dom","clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["./action-swap","./base","./button/stateful","./number-ticker","./tabs","./tooltip","./tooltip/grace","./tooltip/positioner","./tooltip/use-position","./use-position","@/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\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=\"shrink-0 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                  // Own states: a label that swaps away and straight back\n                  // re-enters mid-exit, and only owned targets are restored.\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"components/motion/number-ticker.tsx","type":"util","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /**\n   * Fraction digits to keep. The value is rounded to this many places before\n   * `format` runs (default 0, whole numbers).\n   */\n  decimals?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  decimals = 0,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const places = Math.max(0, Math.floor(decimals));\n    const factor = 10 ** places;\n    const rounded = Math.round(value * factor) / factor;\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(undefined, {\n            minimumFractionDigits: places,\n            maximumFractionDigits: places,\n          })\n        : rounded.toFixed(places);\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, decimals, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-baseline tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    // The invisible in-flow digit gives the slot a real text baseline so it\n    // lines up with surrounding text; clip-path clips the rolling column\n    // without moving that baseline the way overflow: hidden would.\n    <span\n      className={cn(\n        \"relative inline-block text-center [clip-path:inset(0)]\",\n        className,\n      )}\n      style={{\n        height: `${DIGIT_HEIGHT_EM}em`,\n        lineHeight: `${DIGIT_HEIGHT_EM}em`,\n        width: \"1ch\",\n      }}\n    >\n      <span className=\"invisible\">{digit}</span>\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/tabs.tsx","type":"util","content":"\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { cancelFrame, frame, motion, MotionConfig, useReducedMotion, useSpring, type MotionValue, type Transition } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useMemo,\n  useState,\n  type ButtonHTMLAttributes,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"pill\" | \"underline\" | \"segment\";\n/** Whether trigger icons show on every tab or only on the selected one. */\ntype IconDisplay = \"always\" | \"selected\";\n\ntype Ctx = {\n  value: string;\n  setValue: (v: string) => void;\n  layoutId: string;\n  variant: Variant;\n  icons: IconDisplay;\n  /** The pill's box in the list, for tabs whose width changes as they're selected. */\n  track: Track;\n  /** Whether the tracked pill has been measured; until then CSS places it. */\n  placed: boolean;\n  setPlaced: (placed: boolean) => void;\n  /**\n   * The one tab Tab reaches: the selected tab, or the first enabled one when\n   * nothing enabled is selected. `null` until the triggers have registered,\n   * when every enabled tab stays reachable.\n   */\n  tabStop: string | null;\n  registerTab: (value: string, disabled: boolean) => void;\n  unregisterTab: (value: string) => void;\n  /** Values with a mounted panel, so triggers only point at panels that exist. */\n  panels: ReadonlySet<string>;\n  registerPanel: (value: string) => void;\n  unregisterPanel: (value: string) => void;\n  tabId: (value: string) => string;\n  panelId: (value: string) => string;\n};\n\ntype Track = Record<\"x\" | \"y\" | \"width\" | \"height\", MotionValue<number>>;\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.\n// Scale stiffness and damping together for a quicker glide with the same feel.\nconst transition: Transition = {\n  type: \"spring\",\n  stiffness: 245,\n  damping: 36,\n  mass: 1.2,\n};\n\nexport function Tabs({\n  defaultValue,\n  value,\n  onValueChange,\n  variant = \"pill\",\n  icons = \"always\",\n  children,\n  className,\n}: {\n  defaultValue?: string;\n  value?: string;\n  onValueChange?: (v: string) => void;\n  variant?: Variant;\n  /**\n   * With \"selected\", a trigger's icon springs open only on the selected tab\n   * and folds away when another is chosen. Defaults to \"always\".\n   */\n  icons?: IconDisplay;\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 x = useSpring(0, transition);\n  const y = useSpring(0, transition);\n  const width = useSpring(0, transition);\n  const height = useSpring(0, transition);\n  const track = useMemo(() => ({ x, y, width, height }), [x, y, width, height]);\n  const [placed, setPlaced] = useState(false);\n  const [tabs, setTabs] = useState<Map<string, boolean>>(new Map());\n  const registerTab = useCallback((tab: string, disabled: boolean) => {\n    setTabs((map) => (map.get(tab) === disabled ? map : new Map(map).set(tab, disabled)));\n  }, []);\n  const unregisterTab = useCallback((tab: string) => {\n    setTabs((map) => {\n      if (!map.has(tab)) return map;\n      const next = new Map(map);\n      next.delete(tab);\n      return next;\n    });\n  }, []);\n  const [panels, setPanels] = useState<ReadonlySet<string>>(new Set());\n  const registerPanel = useCallback((panel: string) => {\n    setPanels((set) => (set.has(panel) ? set : new Set(set).add(panel)));\n  }, []);\n  const unregisterPanel = useCallback((panel: string) => {\n    setPanels((set) => {\n      if (!set.has(panel)) return set;\n      const next = new Set(set);\n      next.delete(panel);\n      return next;\n    });\n  }, []);\n  // Resolved during render so the list has exactly one tab stop in the same\n  // commit that changes the selection or the tabs.\n  let tabStop: string | null = null;\n  if (tabs.size > 0) {\n    const enabled = Array.from(tabs).filter(([, disabled]) => !disabled);\n    tabStop = enabled.some(([tab]) => tab === current) ? current : (enabled[0]?.[0] ?? \"\");\n  }\n  const baseId = useId();\n  const tabId = useCallback((tab: string) => `${baseId}-tab-${tab.replace(/[^\\w-]/g, \"_\")}`, [baseId]);\n  const panelId = useCallback((tab: string) => `${baseId}-panel-${tab.replace(/[^\\w-]/g, \"_\")}`, [baseId]);\n  const contextValue = useMemo(\n    () => ({\n      value: current,\n      setValue,\n      layoutId,\n      variant,\n      icons,\n      track,\n      placed,\n      setPlaced,\n      tabStop,\n      registerTab,\n      unregisterTab,\n      panels,\n      registerPanel,\n      unregisterPanel,\n      tabId,\n      panelId,\n    }),\n    [current, icons, layoutId, setValue, variant, track, placed, tabStop, registerTab, unregisterTab, panels, registerPanel, unregisterPanel, tabId, panelId],\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({\n  children,\n  className,\n  wrapperClassName,\n  onKeyDown,\n  ...props\n}: {\n  wrapperClassName?: string;\n} & HTMLAttributes<HTMLDivElement>) {\n  const { variant, value, icons, track, placed, setPlaced } = useTabs();\n  const reduce = useReducedMotion();\n  const tracked = icons === \"selected\" && variant !== \"underline\";\n  const rootRef = useRef<HTMLDivElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const viewportId = useId();\n  const [edges, setEdges] = useState({ overflow: false, left: false, right: false });\n\n  const measure = useCallback(() => {\n    const root = rootRef.current;\n    const viewport = viewportRef.current;\n    if (!root || !viewport) return;\n    // Overlay controls do not reduce the viewport or change its scroll range.\n    const overflow = viewport.scrollWidth > root.clientWidth + 1;\n    const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);\n    const rtl = getComputedStyle(viewport).direction === \"rtl\";\n    // Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding.\n    const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));\n    const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 };\n    setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);\n  }, []);\n\n  const reveal = useCallback((tab: HTMLElement | null) => {\n    const viewport = viewportRef.current;\n    if (!viewport || !tab) return;\n    const frame = viewport.getBoundingClientRect();\n    const item = tab.getBoundingClientRect();\n    const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);\n    const rtl = getComputedStyle(viewport).direction === \"rtl\";\n    const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));\n    // Keep the selected/focused label clear of the arrows over the faded edges.\n    const left = frame.left + (fromLeft > 1 ? 36 : 0);\n    const right = frame.right - (fromLeft < max - 1 ? 36 : 0);\n    const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;\n    // Scroll only this viewport; scrollIntoView can also move the whole page.\n    if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? \"instant\" : \"smooth\" });\n  }, [reduce]);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const viewport = viewportRef.current;\n    const list = listRef.current;\n    if (!root || !viewport || !list) return;\n    const update = () => {\n      measure();\n      reveal(list.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]'));\n    };\n    const observer = new ResizeObserver(update);\n    observer.observe(root);\n    observer.observe(viewport);\n    observer.observe(list);\n    viewport.addEventListener(\"scroll\", measure, { passive: true });\n    update();\n    return () => {\n      observer.disconnect();\n      viewport.removeEventListener(\"scroll\", measure);\n    };\n  }, [measure, reveal]);\n\n  useLayoutEffect(() => {\n    // Children may change without a resize; controlled selection must also reveal.\n    void children;\n    void value;\n    void edges.overflow;\n    measure();\n    reveal(listRef.current?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]') ?? null);\n  }, [children, value, edges.overflow, measure, reveal]);\n\n  useLayoutEffect(() => {\n    // With icons=\"selected\" the chosen tab grows as its icon opens and the\n    // last one shrinks. A layout animation measures the tabs mid-change, so\n    // the pill would squeeze and catch up. Instead the pill springs straight\n    // to where the selected tab will settle, with every icon open or closed.\n    if (!tracked) return;\n    const list = listRef.current;\n    if (!list) return;\n    void children;\n    const place = () => {\n      const tabs = Array.from(list.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n      const target = tabs.find((tab) => tab.dataset.tabsValue === value)?.parentElement;\n      if (!target) return;\n      // Measure the settled layout: open the selected icon and fold the rest\n      // for one synchronous read, then hand the styles back to Motion before\n      // anything paints. Exact in any layout, from flex rows to grids.\n      const icons = Array.from(list.querySelectorAll<HTMLElement>('[role=\"tab\"] > [data-tabs-icon]'));\n      const saved = icons.map((icon) => [icon.style.width, icon.style.marginRight]);\n      for (const icon of icons) {\n        const open = icon.parentElement?.parentElement === target;\n        icon.style.width = open ? \"auto\" : \"0px\";\n        icon.style.marginRight = open ? `${ICON_GAP}px` : \"0px\";\n      }\n      const box = {\n        x: target.offsetLeft,\n        y: target.offsetTop,\n        // Fractional: a rounded width leaves the pill a hair off its tab.\n        width: target.getBoundingClientRect().width,\n        height: target.offsetHeight,\n      };\n      icons.forEach((icon, index) => {\n        [icon.style.width, icon.style.marginRight] = saved[index];\n      });\n      // A first measurement, or one after the list was hidden, lands in place.\n      const instant = reduce || track.width.get() === 0;\n      for (const key of [\"x\", \"y\", \"width\", \"height\"] as const) {\n        if (instant) track[key].jump(box[key]);\n        else track[key].set(box[key]);\n      }\n      // Hand over from the CSS pill once there's a real box, before paint.\n      if (box.width > 0) setPlaced(true);\n    };\n    place();\n    const observer = new ResizeObserver(place);\n    for (const tab of list.querySelectorAll('[role=\"tab\"]')) if (tab.parentElement) observer.observe(tab.parentElement);\n    return () => observer.disconnect();\n  }, [tracked, value, children, reduce, track, setPlaced]);\n\n  useLayoutEffect(() => {\n    if (variant === \"underline\") return;\n    const list = listRef.current;\n    if (!list) return;\n    void children;\n    // `placed` swaps the indicator element, so measure the new one.\n    void placed;\n    const labels = Array.from(list.querySelectorAll<HTMLElement>(\"[data-tabs-label]\"));\n    const indicator = list.querySelector<HTMLElement>(\"[data-tabs-indicator]\");\n    const target = list.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n    if (!indicator || !target || target.dataset.tabsValue !== value) {\n      for (const label of labels) label.style.clipPath = \"inset(0 100% 0 0)\";\n      return;\n    }\n    let frames = 0;\n    let stillFrames = 0;\n    let previous: { left: number; right: number } | undefined;\n    const syncClips = () => {\n      const pill = (reduce ? target : indicator).getBoundingClientRect();\n      // Read ALL geometry before writing ANY masks. A loop per tab interleaved\n      // reads and writes, forcing the browser to flush styles repeatedly.\n      const clips = labels.map((label) => {\n        const bounds = label.getBoundingClientRect();\n        const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left));\n        const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right));\n        return left + right >= bounds.width ? \"inset(0 100% 0 0)\" : `inset(0 ${right}px 0 ${left}px)`;\n      });\n      labels.forEach((label, index) => {\n        if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index];\n      });\n      frames += 1;\n      stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0;\n      previous = { left: pill.left, right: pill.right };\n      if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips);\n    };\n    // One shared pass after Motion paints the projected pill keeps every label\n    // in sync, including labels crossed during a long or interrupted glide.\n    frame.postRender(syncClips, true);\n    // A list measured while hidden (display: none, a closed panel, a\n    // breakpoint) gets every mask closed. Sync again once it has a size, or\n    // the selected label stays invisible until the value changes.\n    const resync = () => {\n      frames = 0;\n      stillFrames = 0;\n      previous = undefined;\n      cancelFrame(syncClips);\n      frame.postRender(syncClips, true);\n    };\n    const observer = new ResizeObserver(resync);\n    observer.observe(list);\n    return () => {\n      observer.disconnect();\n      cancelFrame(syncClips);\n    };\n  }, [value, children, variant, reduce, placed]);\n\n  // Arrow keys move along the list and select as focus arrives, wrapping at\n  // the ends; Home and End jump to the first and last enabled tab.\n  const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    const list = listRef.current;\n    if (!list || !(event.target instanceof HTMLElement)) return;\n    const tabs = Array.from(list.querySelectorAll<HTMLButtonElement>('[role=\"tab\"]')).filter(\n      (tab) => !tab.disabled && tab.closest('[role=\"tablist\"]') === list,\n    );\n    const at = tabs.indexOf(event.target as HTMLButtonElement);\n    if (at < 0) return;\n    const rtl = getComputedStyle(list).direction === \"rtl\";\n    const forward = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const back = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    const next =\n      event.key === forward\n        ? tabs[(at + 1) % tabs.length]\n        : event.key === back\n          ? tabs[(at - 1 + tabs.length) % tabs.length]\n          : event.key === \"Home\"\n            ? tabs[0]\n            : event.key === \"End\"\n              ? tabs[tabs.length - 1]\n              : null;\n    if (!next) return;\n    event.preventDefault();\n    next.focus();\n    next.click();\n  };\n\n  const scroll = (direction: number) => {\n    const viewport = viewportRef.current;\n    if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? \"instant\" : \"smooth\" });\n  };\n  const controlClass = \"absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0\";\n  const surfaceClass = variant === \"pill\" ? \"rounded-full bg-card\" : variant === \"segment\" ? \"rounded-lg bg-card\" : \"\";\n\n  return (\n    <div ref={rootRef} className={cn(\"relative isolate flex w-full max-w-full min-w-0 items-center\", edges.overflow && surfaceClass, wrapperClassName)}>\n      {edges.overflow && (\n        <button type=\"button\" aria-label=\"Scroll tabs left\" aria-controls={viewportId} disabled={!edges.left} onClick={() => scroll(-1)} className={cn(controlClass, \"left-0 rounded-l-full\")}>\n          <ChevronLeft size={20} aria-hidden=\"true\" />\n        </button>\n      )}\n      <motion.div\n        ref={viewportRef}\n        id={viewportId}\n        layoutScroll\n        className={cn(\"w-full min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\", edges.overflow && \"[border-radius:inherit]\")}\n        style={edges.overflow ? {\n          maskImage: `linear-gradient(to right, ${edges.left ? \"transparent, black 40px\" : \"black, black 0px\"}, ${edges.right ? \"black calc(100% - 40px), transparent\" : \"black 100%\"})`,\n        } : undefined}\n        onFocusCapture={(event) => {\n          if (event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"tab\") reveal(event.target);\n        }}\n      >\n        <div {...props} ref={listRef} role=\"tablist\" onKeyDown={onListKeyDown} className={cn(listClasses[variant], \"w-max\", tracked && \"relative\", className)}>\n          {children}\n        </div>\n      </motion.div>\n      {edges.overflow && edges.left && (\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 left-0 z-10 w-10 rounded-l-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_right,black,transparent)]\" />\n      )}\n      {edges.overflow && edges.right && (\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 right-0 z-10 w-10 rounded-r-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_left,black,transparent)]\" />\n      )}\n      {edges.overflow && (\n        <button type=\"button\" aria-label=\"Scroll tabs right\" aria-controls={viewportId} disabled={!edges.right} onClick={() => scroll(1)} className={cn(controlClass, \"right-0 rounded-r-full\")}>\n          <ChevronRight size={20} aria-hidden=\"true\" />\n        </button>\n      )}\n    </div>\n  );\n}\n\n/** Space between a selected-only icon and its label, in pixels. */\nconst ICON_GAP = 6;\n\n/**\n * A trigger's icon before its label. Shown on every tab, or with\n * `icons=\"selected\"` springing open on the selected tab only.\n */\nfunction TriggerIcon({ icon, active }: { icon: ReactNode; active: boolean }) {\n  const { icons } = useTabs();\n  const reduce = useReducedMotion();\n  if (icons === \"always\") {\n    return (\n      <span aria-hidden=\"true\" className=\"mr-1.5 inline-flex shrink-0 items-center\">\n        {icon}\n      </span>\n    );\n  }\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      data-tabs-icon=\"\"\n      initial={false}\n      animate={{\n        width: active ? \"auto\" : 0,\n        marginRight: active ? ICON_GAP : 0,\n        opacity: active ? 1 : 0,\n        scale: active ? 1 : 0.6,\n      }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"inline-flex shrink-0 items-center justify-center overflow-hidden\"\n    >\n      {icon}\n    </motion.span>\n  );\n}\n\nexport function TabsTrigger({\n  value,\n  icon,\n  children,\n  className,\n  indicatorClassName,\n  onClick,\n  ...props\n}: {\n  value: string;\n  /** An icon before the label, such as a Lucide icon element. */\n  icon?: ReactNode;\n  indicatorClassName?: string;\n} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"value\">) {\n  const {\n    value: current,\n    setValue,\n    layoutId,\n    variant,\n    icons,\n    track,\n    placed,\n    tabStop,\n    registerTab,\n    unregisterTab,\n    panels,\n    tabId,\n    panelId,\n  } = useTabs();\n  const active = current === value;\n  const disabled = Boolean(props.disabled);\n  useLayoutEffect(() => {\n    registerTab(value, disabled);\n  }, [registerTab, value, disabled]);\n  useLayoutEffect(() => () => unregisterTab(value), [unregisterTab, value]);\n  const a11y = {\n    id: tabId(value),\n    \"aria-controls\": panels.has(value) ? panelId(value) : undefined,\n    tabIndex: !disabled && (tabStop === null || tabStop === value) ? 0 : -1,\n  };\n  const content = icon ? (\n    <>\n      <TriggerIcon icon={icon} active={active} />\n      {children}\n    </>\n  ) : (\n    children\n  );\n  // React owns the initial mask only; TabsList synchronizes subsequent masks.\n  const [initialClip] = useState(() => active ? \"inset(0)\" : \"inset(0 100% 0 0)\");\n\n  if (variant === \"underline\") {\n    return (\n      <button\n        // Consumer ids and wiring win, e.g. several tabs sharing one panel.\n        {...a11y}\n        {...props}\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) setValue(value);\n        }}\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 whitespace-nowrap shrink-0\",\n          active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          className,\n        )}\n      >\n        {content}\n        {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout\n          className={cn(\n            \"absolute bottom-0 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  // Tracked pills are placed in the list, so this wrapper must not be their\n  // containing block. Server HTML can't measure, so until the first\n  // measurement the pill fills the selected tab with CSS, like the others.\n  const tracked = icons === \"selected\" && placed;\n\n  return (\n    <div className={cn(\"shrink-0\", !tracked && \"relative\")}>\n      {/* Distinct keys: reused across the swap, the CSS pill's leftover\n          `transform: none` would hide the tracked pill's offset until the\n          next value change. */}\n      {active && tracked ? (\n        <motion.span\n          key=\"tracked\"\n          data-tabs-indicator=\"\"\n          style={{ ...track, borderRadius: variant === \"pill\" ? 9999 : 8 }}\n          className={cn(\"absolute top-0 left-0 bg-primary\", radius, indicatorClassName)}\n        />\n      ) : active ? (\n        <motion.span\n          key=\"layout\"\n          data-tabs-indicator=\"\"\n          layoutId={layoutId}\n          layout\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        // Consumer ids and wiring win, e.g. several tabs sharing one panel.\n        {...a11y}\n        {...props}\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        data-tabs-value={value}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) setValue(value);\n        }}\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          \"text-muted-foreground hover:text-foreground\",\n          radius,\n          className,\n        )}\n      >\n        {content}\n        <span\n          data-tabs-label=\"\"\n          aria-hidden=\"true\"\n          inert\n          className=\"pointer-events-none absolute inset-0 inline-flex items-center justify-center text-primary-foreground [gap:inherit] [padding:inherit]\"\n          style={{ clipPath: initialClip }}\n        >\n          {content}\n        </span>\n      </button>\n    </div>\n  );\n}\n\nexport function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {\n  const { value: current, registerPanel, unregisterPanel, tabId, panelId } = useTabs();\n  const reduce = useReducedMotion();\n  const active = current === value;\n  useLayoutEffect(() => {\n    registerPanel(value);\n    return () => unregisterPanel(value);\n  }, [registerPanel, unregisterPanel, value]);\n  const a11y = {\n    id: panelId(value),\n    role: \"tabpanel\",\n    \"aria-labelledby\": tabId(value),\n  } as const;\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 {...a11y} hidden className={className}>\n        {children}\n      </div>\n    );\n  }\n  return (\n    <motion.div\n      {...a11y}\n      // Reachable with Tab so a panel without controls can still be read.\n      tabIndex={0}\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 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-md\", 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, type HTMLMotionProps, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\n// The arriving layer springs in, so a second press mid-swap bends it toward\n// the new target instead of restarting. The leaving layer is a little quicker,\n// still long enough to cross the arrival so the swap reads as one morph.\nconst BLUR_TRANSITION = SPRING_SWAP;\nconst BLUR_EXIT_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\n// A light blur softens the crossing without smearing small glyphs.\nconst SWAP_BLUR = \"blur(2px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.9, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.96,\n      filter: SWAP_BLUR,\n      transition: BLUR_EXIT_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_EXIT_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n  ghost: \"text-muted-foreground hover:bg-muted/60 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\n        \"relative -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom\",\n        className,\n      )}\n      style={{\n        clipPath: \"inset(0 -999px)\",\n        WebkitClipPath: \"inset(0 -999px)\",\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-[0.08em] inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  // Own states: a label that swaps away and straight back\n                  // re-enters mid-exit, and only owned targets are restored.\n                  initial=\"initial\"\n                  animate=\"animate\"\n                  exit=\"exit\"\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            // Truncation lives on the layer that holds the text — the layer\n            // moves as a whole, so clipping it never eats the roll.\n            className=\"absolute left-0 top-[0.08em] inline-block max-w-full truncate will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  style,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      // A label of a different length resizes the button: glide the width on\n      // the soft layout spring, and only when the value changes, so unrelated\n      // page reflow never animates the button.\n      layout\n      layoutDependency={activeItem.id}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={{ ...SPRING_PRESS, layout: layoutTransition }}\n      // Layout animations only correct a radius set inline.\n      style={{ borderRadius: 9999, ...style }}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {/* Position-only layout keeps the contents from stretching with the\n          button while its width glides. */}\n      {hasIcon ? (\n        <motion.span\n          layout=\"position\"\n          layoutDependency={activeItem.id}\n          transition={layoutTransition}\n          className=\"inline-flex shrink-0\"\n        >\n          <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n            {activeItem.icon ?? null}\n          </ActionSwapIcon>\n        </motion.span>\n      ) : null}\n      {!iconOnly ? (\n        <motion.span\n          layout=\"position\"\n          layoutDependency={activeItem.id}\n          transition={layoutTransition}\n          className=\"inline-flex min-w-0 max-w-full\"\n        >\n          <ActionSwapText value={activeItem.id} animation={animation}>\n            {activeItem.label}\n          </ActionSwapText>\n        </motion.span>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type PointerEvent as ReactPointerEvent,\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\nimport { focusFromKeyboard, graceArea, inBox, inPolygon, watchModality } from \"./tooltip/grace\";\nimport { TooltipArrow, TooltipPositioner } from \"./tooltip/positioner\";\nimport {\n  type TooltipAlign,\n  type TooltipAnchor,\n  type TooltipPoint,\n  type TooltipSide,\n  useTooltipPointer,\n} from \"./tooltip/use-position\";\n\nexport type { TooltipAlign, TooltipSide };\n\n/* -------------------------------------------------------------------------- */\n/* Provider                                                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface TooltipTiming {\n  /** Delay before a hover opens a tooltip (ms). */\n  delay: number;\n  /** Delay before a tooltip closes once the pointer has left it (ms). */\n  closeDelay: number;\n  /** After one tooltip closes, others open instantly for this long (ms). */\n  skipDelay: number;\n}\n\nconst TimingContext = createContext<TooltipTiming>({ delay: 120, closeDelay: 0, skipDelay: 300 });\n\nexport interface TooltipProviderProps extends Partial<TooltipTiming> {\n  children: ReactNode;\n}\n\n/** Shared timing for every tooltip inside it. Optional: tooltips work without one. */\nexport function TooltipProvider({ children, ...timing }: TooltipProviderProps) {\n  const parent = useContext(TimingContext);\n  const { delay = parent.delay, closeDelay = parent.closeDelay, skipDelay = parent.skipDelay } =\n    timing;\n  const value = useMemo(() => ({ delay, closeDelay, skipDelay }), [delay, closeDelay, skipDelay]);\n  return <TimingContext.Provider value={value}>{children}</TimingContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                       */\n/* -------------------------------------------------------------------------- */\n\n// Moving along a toolbar should feel instant after the first label, and only\n// one label may show at a time: a keyboard-focused control and a hovered one\n// would otherwise both claim the screen.\nlet lastClosedAt = 0;\nlet current: { close: () => void } | null = null;\n\nfunction isEmpty(content: ReactNode) {\n  return content == null || content === false || content === \"\";\n}\n\ninterface TooltipContextValue {\n  open: boolean;\n  contentId: string;\n  labelId: string;\n  asLabel: boolean;\n  empty: boolean;\n  followCursor: boolean;\n  anchor: TooltipAnchor | null;\n  /** True when a TooltipTrigger owns the anchor; false for an external `anchorRef`. */\n  triggered: boolean;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  pointer: ReturnType<typeof useTooltipPointer>;\n  setTrigger: (element: TooltipAnchor | null) => void;\n  setEmpty: (empty: boolean) => void;\n  close: () => void;\n  onContentEnter: () => void;\n  onContentLeave: (event: ReactPointerEvent) => void;\n}\n\nconst TooltipContext = createContext<TooltipContextValue | null>(null);\n\nfunction useTooltipContext(part: string) {\n  const context = useContext(TooltipContext);\n  if (!context) throw new Error(`${part} must be used inside <Tooltip>`);\n  return context;\n}\n\nexport interface TooltipRootProps {\n  children?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Delay before showing on hover (ms). Keyboard focus shows immediately. */\n  delay?: number;\n  /** Delay before hiding once the pointer has left (ms). */\n  closeDelay?: number;\n  /** Never show, e.g. while the label it carries is already visible. */\n  disabled?: boolean;\n  /**\n   * Use the tooltip as the trigger's accessible name rather than its\n   * description. For icon-only controls whose only label is the tooltip.\n   */\n  asLabel?: boolean;\n  /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n  followCursor?: boolean;\n  /** Existing anchor for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<TooltipAnchor | null>;\n  /** Id of the tooltip surface, for anchors that reference it themselves. */\n  id?: string;\n}\n\nfunction TooltipRoot({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  delay: delayProp,\n  closeDelay: closeDelayProp,\n  disabled = false,\n  asLabel = false,\n  followCursor = false,\n  anchorRef,\n  id,\n  initialEmpty = false,\n}: TooltipRootProps & { initialEmpty?: boolean }) {\n  const timing = useContext(TimingContext);\n  const delay = delayProp ?? timing.delay;\n  const closeDelay = closeDelayProp ?? timing.closeDelay;\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? uncontrolledOpen;\n  const [empty, setEmpty] = useState(initialEmpty);\n  const [trigger, setTrigger] = useState<TooltipAnchor | null>(null);\n  const [external, setExternal] = useState<TooltipAnchor | null>(null);\n  const anchor = anchorRef ? external : trigger;\n  const generatedId = useId();\n  const contentId = id ?? `${generatedId}-content`;\n  const labelId = `${generatedId}-label`;\n  const floatingRef = useRef<HTMLSpanElement | null>(null);\n  const anchorObject = useRef<TooltipAnchor | null>(null);\n  const pointer = useTooltipPointer(anchor, followCursor);\n\n  // An external ref can attach after this component renders; read it once it has.\n  useLayoutEffect(() => {\n    if (!anchorRef) return;\n    const element = anchorRef.current ?? null;\n    if (element !== external) setExternal(element);\n  });\n\n  // Everything the event listeners read, current without re-binding them.\n  const latest = useRef({ open, delay, closeDelay, disabled, empty, onOpenChange, controlled: false });\n  useLayoutEffect(() => {\n    anchorObject.current = anchor;\n    latest.current = {\n      open,\n      delay,\n      closeDelay,\n      disabled,\n      empty,\n      onOpenChange,\n      controlled: controlledOpen !== undefined,\n    };\n  });\n\n  const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const grace = useRef<(() => void) | null>(null);\n  const self = useRef<{ close: () => void }>({ close: () => {} });\n\n  const clearTimers = useCallback(() => {\n    if (openTimer.current) clearTimeout(openTimer.current);\n    if (closeTimer.current) clearTimeout(closeTimer.current);\n    openTimer.current = null;\n    closeTimer.current = null;\n  }, []);\n\n  const stopGrace = useCallback(() => {\n    grace.current?.();\n    grace.current = null;\n  }, []);\n\n  // Only a real change reaches the consumer: a pointer leaving a closed\n  // tooltip is not news.\n  const commit = useCallback((next: boolean) => {\n    if (latest.current.open === next) return;\n    if (!latest.current.controlled) setUncontrolledOpen(next);\n    latest.current.onOpenChange?.(next);\n  }, []);\n\n  const openNow = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.disabled || latest.current.empty) return;\n    if (current && current !== self.current) current.close();\n    current = self.current;\n    commit(true);\n  }, [clearTimers, stopGrace, commit]);\n\n  const close = useCallback(() => {\n    clearTimers();\n    stopGrace();\n    if (latest.current.open) lastClosedAt = Date.now();\n    if (current === self.current) current = null;\n    commit(false);\n  }, [clearTimers, stopGrace, commit]);\n  useLayoutEffect(() => {\n    self.current.close = close;\n  }, [close]);\n\n  const requestOpen = useCallback(() => {\n    clearTimers();\n    if (latest.current.open || latest.current.disabled || latest.current.empty) return;\n    const warm = Date.now() - lastClosedAt < timing.skipDelay;\n    if (warm || latest.current.delay <= 0) openNow();\n    else openTimer.current = setTimeout(openNow, latest.current.delay);\n  }, [clearTimers, openNow, timing.skipDelay]);\n\n  const requestClose = useCallback(() => {\n    clearTimers();\n    if (latest.current.closeDelay > 0) {\n      closeTimer.current = setTimeout(close, latest.current.closeDelay);\n    } else close();\n  }, [clearTimers, close]);\n\n  // Hold the label open while the pointer crosses from one box toward the other.\n  const startGrace = useCallback(\n    (exit: TooltipPoint, from: Element, to: Element | null) => {\n      stopGrace();\n      if (!to) return requestClose();\n      const target = to.getBoundingClientRect();\n      const origin = from.getBoundingClientRect();\n      const corridor = graceArea(exit, target);\n      const onMove = (event: PointerEvent) => {\n        const point = { x: event.clientX, y: event.clientY };\n        if (inBox(point, target) || inBox(point, origin)) return;\n        if (!inPolygon(point, corridor)) requestClose();\n      };\n      document.addEventListener(\"pointermove\", onMove);\n      grace.current = () => document.removeEventListener(\"pointermove\", onMove);\n    },\n    [stopGrace, requestClose],\n  );\n\n  const hover = useHoverGesture();\n  const tap = useTapGesture<boolean>();\n\n  // Listeners go on the trigger itself rather than on cloned props, so a\n  // component that owns its own handlers keeps them, and no wrapper element\n  // sits between the control and its layout.\n  useEffect(() => {\n    if (anchorRef || !trigger) return;\n    watchModality();\n    // A mouse press dismisses the label and keeps it away until the pointer\n    // leaves; hovering a control you just clicked should not bring it back.\n    let suppressed = false;\n    // Focus that came from the keyboard holds the label open on its own.\n    let keyboardFocus = false;\n\n    const onEnter = (event: PointerEvent) => {\n      if (!hover.enter(event)) return;\n      stopGrace();\n      if (closeTimer.current) clearTimeout(closeTimer.current);\n      if (!suppressed) requestOpen();\n    };\n    const onLeave = (event: PointerEvent) => {\n      if (!hover.leave(event)) return;\n      suppressed = false;\n      if (keyboardFocus) return;\n      if (openTimer.current) {\n        clearTimers();\n        return;\n      }\n      if (latest.current.open && !followCursor) {\n        startGrace({ x: event.clientX, y: event.clientY }, trigger, floatingRef.current);\n      } else requestClose();\n    };\n    const onDown = (event: PointerEvent) => {\n      tap.start(event, latest.current.open);\n      if (event.pointerType === \"mouse\") {\n        suppressed = true;\n        close();\n      }\n    };\n    // A finger never hovers, and Safari does not focus a button on tap either,\n    // so the label is only reachable if the tap itself toggles it. The click\n    // carries no pointerType; the pointerdown before it says whether it was a\n    // tap, and keyboard activation has none at all.\n    const onClick = () => {\n      const gesture = tap.take();\n      if (!gesture || gesture.pointerType === \"mouse\") return;\n      if (gesture.state) close();\n      else openNow();\n    };\n    const onKey = () => tap.drop();\n    const onFocusIn = () => {\n      if (!focusFromKeyboard()) return;\n      keyboardFocus = true;\n      openNow();\n    };\n    const onFocusOut = (event: FocusEvent) => {\n      if (trigger.contains(event.relatedTarget as Node | null)) return;\n      keyboardFocus = false;\n      close();\n    };\n\n    // Both element kinds dispatch the same events; one type keeps the listeners typed.\n    const el = trigger as HTMLElement;\n    el.addEventListener(\"pointerenter\", onEnter);\n    el.addEventListener(\"pointerleave\", onLeave);\n    el.addEventListener(\"pointerdown\", onDown);\n    el.addEventListener(\"pointercancel\", tap.drop);\n    el.addEventListener(\"click\", onClick);\n    el.addEventListener(\"keydown\", onKey);\n    el.addEventListener(\"focusin\", onFocusIn);\n    el.addEventListener(\"focusout\", onFocusOut);\n    return () => {\n      el.removeEventListener(\"pointerenter\", onEnter);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      el.removeEventListener(\"pointerdown\", onDown);\n      el.removeEventListener(\"pointercancel\", tap.drop);\n      el.removeEventListener(\"click\", onClick);\n      el.removeEventListener(\"keydown\", onKey);\n      el.removeEventListener(\"focusin\", onFocusIn);\n      el.removeEventListener(\"focusout\", onFocusOut);\n    };\n  }, [\n    anchorRef,\n    trigger,\n    followCursor,\n    hover,\n    tap,\n    requestOpen,\n    requestClose,\n    openNow,\n    close,\n    clearTimers,\n    stopGrace,\n    startGrace,\n  ]);\n\n  // While open, a trigger that turns disabled loses focus without a blur event,\n  // and a window that loses focus never reports the pointer leaving.\n  useEffect(() => {\n    if (!open || anchorRef || !trigger) return;\n    const observer = new MutationObserver(() => {\n      if (trigger.matches(\":disabled, [aria-disabled='true']\")) close();\n    });\n    observer.observe(trigger, { attributes: true, attributeFilter: [\"disabled\", \"aria-disabled\"] });\n    window.addEventListener(\"blur\", close);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"blur\", close);\n    };\n  }, [open, anchorRef, trigger, close]);\n\n  // A chart drives its own open state and swaps content as it goes; only a\n  // trigger-owned label closes itself for having nothing to say.\n  useEffect(() => {\n    if (disabled || (empty && !anchorRef)) close();\n  }, [disabled, empty, anchorRef, close]);\n\n  useEffect(\n    () => () => {\n      clearTimers();\n      stopGrace();\n      if (current === self.current) current = null;\n    },\n    [clearTimers, stopGrace],\n  );\n\n  const insideTooltip = useCallback(\n    (target: Element) => Boolean(floatingRef.current?.contains(target)),\n    [],\n  );\n  // ...and closed by the next tap that lands somewhere else, or Escape. The\n  // label covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, close, anchorObject, { ignore: insideTooltip });\n\n  const onContentEnter = useCallback(() => {\n    stopGrace();\n    clearTimers();\n  }, [stopGrace, clearTimers]);\n\n  const onContentLeave = useCallback(\n    (event: ReactPointerEvent) => {\n      const floating = floatingRef.current;\n      if (!floating) return;\n      if (anchorRef) {\n        // Bridge the small gap back onto a chart; it reopens on its own terms.\n        clearTimers();\n        closeTimer.current = setTimeout(close, 100);\n        return;\n      }\n      startGrace({ x: event.clientX, y: event.clientY }, floating, anchorObject.current);\n    },\n    [anchorRef, clearTimers, close, startGrace],\n  );\n\n  const value = useMemo<TooltipContextValue>(\n    () => ({\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      triggered: !anchorRef,\n      floatingRef,\n      pointer,\n      setTrigger,\n      setEmpty,\n      close,\n      onContentEnter,\n      onContentLeave,\n    }),\n    [\n      open,\n      contentId,\n      labelId,\n      asLabel,\n      empty,\n      followCursor,\n      anchor,\n      anchorRef,\n      pointer,\n      close,\n      onContentEnter,\n      onContentLeave,\n    ],\n  );\n\n  return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger                                                                    */\n/* -------------------------------------------------------------------------- */\n\nfunction mergeIds(...ids: unknown[]) {\n  return ids.filter((value) => typeof value === \"string\" && value).join(\" \") || undefined;\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as { current: T | null }).current = value;\n}\n\nexport interface TooltipTriggerProps extends ComponentProps<\"button\"> {\n  /** Use the single child element as the trigger instead of rendering a button. */\n  asChild?: boolean;\n}\n\nexport function TooltipTrigger({ asChild = false, children, ref, ...props }: TooltipTriggerProps) {\n  const { open, contentId, labelId, asLabel, empty, setTrigger } =\n    useTooltipContext(\"TooltipTrigger\");\n  const child = asChild && isValidElement(children) ? children : null;\n  const childProps = (child?.props ?? props) as Record<string, unknown>;\n  const childRef = child ? (childProps.ref as Ref<TooltipAnchor> | undefined) : undefined;\n  const attached = useRef<TooltipAnchor | null>(null);\n\n  const setRef = useCallback(\n    (element: TooltipAnchor | null) => {\n      attached.current = element;\n      setTrigger(element);\n      assignRef(childRef ?? (ref as Ref<TooltipAnchor> | undefined), element);\n    },\n    [setTrigger, childRef, ref],\n  );\n\n  useEffect(() => {\n    if (process.env.NODE_ENV !== \"production\" && child && !attached.current) {\n      console.warn(\n        \"TooltipTrigger: the child did not attach a ref, so the tooltip has nothing to listen to. Render a DOM element or a component that passes `ref` through.\",\n      );\n    }\n  }, [child]);\n\n  const aria = asLabel\n    ? {\n        \"aria-labelledby\": empty\n          ? (childProps[\"aria-labelledby\"] as string | undefined)\n          : labelId,\n      }\n    : {\n        \"aria-describedby\": mergeIds(\n          childProps[\"aria-describedby\"],\n          open && !empty ? contentId : undefined,\n        ),\n      };\n\n  if (asChild) {\n    if (!child) return children;\n    return cloneElement(child as ReactElement<Record<string, unknown>>, { ref: setRef, ...aria });\n  }\n\n  return (\n    <button\n      type=\"button\"\n      data-state={open ? \"open\" : \"closed\"}\n      {...props}\n      ref={setRef as Ref<HTMLButtonElement>}\n      {...aria}\n    >\n      {children}\n    </button>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Content                                                                    */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipContentProps {\n  children?: ReactNode;\n  side?: TooltipSide;\n  align?: TooltipAlign;\n  /** Gap between anchor and tooltip (px). */\n  sideOffset?: number;\n  /** Draw an arrow pointing at the anchor. */\n  arrow?: boolean;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: TooltipPoint;\n  className?: string;\n}\n\nexport function TooltipContent({\n  children,\n  side = \"top\",\n  align = \"center\",\n  sideOffset,\n  arrow = false,\n  anchorPoint,\n  className,\n}: TooltipContentProps) {\n  const {\n    open,\n    contentId,\n    labelId,\n    asLabel,\n    followCursor,\n    anchor,\n    floatingRef,\n    pointer,\n    setEmpty,\n    close,\n    onContentEnter,\n    onContentLeave,\n  } = useTooltipContext(\"TooltipContent\");\n  const empty = isEmpty(children);\n  const arrowRef = useRef<SVGSVGElement | null>(null);\n\n  useLayoutEffect(() => {\n    setEmpty(empty);\n  }, [empty, setEmpty]);\n\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <>\n      {/* The name has to exist while the label is closed, so it lives apart from the surface. */}\n      {asLabel && !empty ? (\n        <span id={labelId} hidden>\n          {children}\n        </span>\n      ) : null}\n      <AnimatePresence>\n        {open && !empty ? (\n          <TooltipPositioner\n            key=\"tooltip\"\n            anchor={anchor}\n            floatingRef={floatingRef}\n            arrowRef={arrow ? arrowRef : undefined}\n            anchorPoint={anchorPoint}\n            followCursor={followCursor}\n            side={side}\n            align={align}\n            sideOffset={sideOffset}\n            onDismiss={close}\n            pointer={pointer}\n          >\n            {(positioned, isPresent, placedSide) => (\n              <TooltipSurface\n                id={asLabel ? undefined : contentId}\n                aria-hidden={asLabel || undefined}\n                ready={positioned}\n                onPointerEnter={onContentEnter}\n                onPointerLeave={onContentLeave}\n                style={{ pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\" }}\n                className={cn(\n                  \"max-w-[min(20rem,calc(100vw-16px))] whitespace-normal break-words\",\n                  arrow && \"relative\",\n                  className,\n                )}\n              >\n                {children}\n                {arrow ? <TooltipArrow ref={arrowRef} side={placedSide} /> : null}\n              </TooltipSurface>\n            )}\n          </TooltipPositioner>\n        ) : null}\n      </AnimatePresence>\n    </>,\n    document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Tooltip (root, or one-line shorthand when given `content`)                 */\n/* -------------------------------------------------------------------------- */\n\nexport interface TooltipProps\n  extends TooltipRootProps,\n    Omit<TooltipContentProps, \"children\" | \"className\"> {\n  /**\n   * Shorthand: the tooltip's content. With it, `children` is the trigger\n   * element; without it, compose `TooltipTrigger` and `TooltipContent`.\n   */\n  content?: ReactNode;\n  /** Shorthand only: classes for the tooltip surface. */\n  className?: string;\n}\n\nexport function Tooltip(props: TooltipProps) {\n  if (!(\"content\" in props)) return <TooltipRoot {...props} />;\n  const {\n    content,\n    children,\n    side,\n    align,\n    sideOffset,\n    arrow,\n    anchorPoint,\n    className,\n    ...root\n  } = props;\n  if (!root.anchorRef && !isValidElement(children)) return children ?? null;\n  return (\n    <TooltipRoot {...root} initialEmpty={isEmpty(content)}>\n      {isValidElement(children) ? <TooltipTrigger asChild>{children}</TooltipTrigger> : null}\n      <TooltipContent\n        side={side}\n        align={align}\n        sideOffset={sideOffset}\n        arrow={arrow}\n        anchorPoint={anchorPoint}\n        className={className}\n      >\n        {content}\n      </TooltipContent>\n    </TooltipRoot>\n  );\n}\n"},{"path":"lib/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-muted/60\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-muted/60\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/tooltip/grace.ts","type":"util","content":"type Point = { x: number; y: number };\ntype Box = { left: number; top: number; right: number; bottom: number };\n\n/**\n * The corridor a pointer may cross between the trigger and the open tooltip\n * without closing it: the convex hull of where it left and the box it is\n * heading for. A fixed timeout either cuts a slow diagonal move short or keeps\n * the label alive after the pointer has plainly gone elsewhere; this follows\n * the path itself.\n */\nexport function graceArea(exit: Point, target: Box): Point[] {\n  // A few pixels either side of the exit point give the corridor some width at\n  // its narrow end, so a hand that drifts sideways while it moves stays inside.\n  const pad = 5;\n  return hull([\n    { x: exit.x - pad, y: exit.y - pad },\n    { x: exit.x + pad, y: exit.y - pad },\n    { x: exit.x - pad, y: exit.y + pad },\n    { x: exit.x + pad, y: exit.y + pad },\n    { x: target.left, y: target.top },\n    { x: target.right, y: target.top },\n    { x: target.right, y: target.bottom },\n    { x: target.left, y: target.bottom },\n  ]);\n}\n\nexport function inBox(point: Point, box: Box) {\n  return (\n    point.x >= box.left && point.x <= box.right && point.y >= box.top && point.y <= box.bottom\n  );\n}\n\nexport function inPolygon(point: Point, polygon: Point[]) {\n  let inside = false;\n  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {\n    const a = polygon[i];\n    const b = polygon[j];\n    if (\n      a.y > point.y !== b.y > point.y &&\n      point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x\n    ) {\n      inside = !inside;\n    }\n  }\n  return inside;\n}\n\n// Monotone chain; the corridor is always convex, so a hull is all it needs.\nfunction hull(points: Point[]) {\n  const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);\n  const cross = (o: Point, a: Point, b: Point) =>\n    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n  const lower: Point[] = [];\n  for (const p of sorted) {\n    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {\n      lower.pop();\n    }\n    lower.push(p);\n  }\n  const upper: Point[] = [];\n  for (let i = sorted.length - 1; i >= 0; i--) {\n    const p = sorted[i];\n    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {\n      upper.pop();\n    }\n    upper.push(p);\n  }\n  lower.pop();\n  upper.pop();\n  return lower.concat(upper);\n}\n\n/**\n * How the user last reached for the page. A focus that a mouse press caused is\n * not a request to read the label: the pointer is already on the control and\n * hovering showed it. A focus that followed a key — Tab, or a roving arrow key\n * moving focus in script — is the only way a keyboard user sees it at all.\n */\nlet modality: \"keyboard\" | \"pointer\" = \"keyboard\";\nlet listening = false;\n\nexport function watchModality() {\n  if (listening || typeof window === \"undefined\") return;\n  listening = true;\n  window.addEventListener(\"keydown\", () => (modality = \"keyboard\"), true);\n  window.addEventListener(\"pointerdown\", () => (modality = \"pointer\"), true);\n}\n\nexport function focusFromKeyboard() {\n  return modality === \"keyboard\";\n}\n"},{"path":"components/motion/tooltip/positioner.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, type Ref, useCallback, useState } from \"react\";\nimport { type TooltipSide, useTooltipPosition } from \"./use-position\";\n\ntype PositionProps = Parameters<typeof useTooltipPosition>[0];\n\n/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */\nexport function TooltipPositioner({\n  children,\n  ...position\n}: Omit<PositionProps, \"open\" | \"onPosition\"> & {\n  children: (ready: boolean, present: boolean, side: TooltipSide) => ReactNode;\n}) {\n  const present = useIsPresent();\n  const [ready, setReady] = useState(false);\n  const [side, setSide] = useState<TooltipSide>(position.side);\n  const onPosition = useCallback((placed: TooltipSide) => {\n    setReady(true);\n    setSide(placed);\n  }, []);\n  useTooltipPosition({ ...position, open: present, onPosition });\n  return (\n    <span\n      ref={position.floatingRef}\n      inert={!present}\n      aria-hidden={!present || undefined}\n      className=\"pointer-events-none fixed left-0 top-0 z-[9999] w-max\"\n      style={{ visibility: \"hidden\", maxWidth: \"calc(100vw - 16px)\" }}\n    >\n      {children(ready, present, side)}\n    </span>\n  );\n}\n\n// Each arrow is a 12px box whose base sits on the surface's inner border edge,\n// so its fill covers the border beneath it and only the two outer edges stroke.\nconst ARROW: Record<TooltipSide, { path: string; style: CSSProperties }> = {\n  top: { path: \"M0 6L6 12L12 6\", style: { top: \"calc(100% - 6px)\" } },\n  bottom: { path: \"M0 6L6 0L12 6\", style: { bottom: \"calc(100% - 6px)\" } },\n  left: { path: \"M6 0L12 6L6 12\", style: { left: \"calc(100% - 6px)\" } },\n  right: { path: \"M6 0L0 6L6 12\", style: { right: \"calc(100% - 6px)\" } },\n};\n\nexport function TooltipArrow({ side, ref }: { side: TooltipSide; ref: Ref<SVGSVGElement> }) {\n  const { path, style } = ARROW[side];\n  return (\n    <svg\n      ref={ref}\n      aria-hidden=\"true\"\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 12 12\"\n      className=\"pointer-events-none absolute overflow-visible\"\n      style={style}\n    >\n      <path d={`${path}Z`} style={{ fill: \"var(--tooltip-arrow-fill, transparent)\" }} />\n      <path\n        d={path}\n        fill=\"none\"\n        strokeWidth=\"1\"\n        style={{ stroke: \"var(--tooltip-arrow-stroke, transparent)\" }}\n      />\n    </svg>\n  );\n}\n"},{"path":"components/motion/tooltip/use-position.ts","type":"util","content":"\"use client\";\n\nimport {\n  arrow as arrowMiddleware,\n  autoUpdate,\n  computePosition,\n  flip,\n  hide,\n  offset,\n  type Placement,\n  shift,\n  type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { type RefObject, useCallback, useLayoutEffect, useRef } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipAlign = \"start\" | \"center\" | \"end\";\nexport type TooltipPoint = { x: number; y: number };\nexport type TooltipAnchor = HTMLElement | SVGElement;\n\nconst ORIGIN: Record<TooltipSide, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n  open,\n  anchor,\n  floatingRef,\n  arrowRef,\n  anchorPoint,\n  followCursor,\n  side,\n  align,\n  sideOffset,\n  onDismiss,\n  onPosition,\n  pointer,\n}: {\n  open: boolean;\n  anchor: TooltipAnchor | null;\n  floatingRef: RefObject<HTMLSpanElement | null>;\n  arrowRef?: RefObject<SVGSVGElement | null>;\n  anchorPoint?: TooltipPoint;\n  followCursor: boolean;\n  side: TooltipSide;\n  align: TooltipAlign;\n  sideOffset?: number;\n  onDismiss: () => void;\n  onPosition: (side: TooltipSide) => void;\n  pointer: ReturnType<typeof useTooltipPointer>;\n}) {\n  const { cursor, onMove } = pointer;\n  const preferred: Placement = align === \"center\" ? side : `${side}-${align}`;\n  const cursorSide = useRef<Placement | null>(null);\n  useLayoutEffect(() => {\n    cursorSide.current = open ? preferred : null;\n  }, [open, preferred]);\n  const frame = useRef<number | null>(null);\n  const version = useRef(0);\n  const update = useRef<() => void>(() => {});\n  const schedule = useCallback(() => {\n    // Invalidate older async calculations as soon as new geometry is requested.\n    version.current++;\n    if (frame.current !== null) return;\n    frame.current = requestAnimationFrame(() => {\n      frame.current = null;\n      update.current();\n    });\n  }, []);\n  useLayoutEffect(() => {\n    onMove.current = schedule;\n    return () => {\n      onMove.current = null;\n    };\n  }, [onMove, schedule]);\n  const pointX = anchorPoint?.x;\n  const pointY = anchorPoint?.y;\n\n  // Latest committed inputs are read without recreating observers for every period/content update.\n  useLayoutEffect(() => {\n    update.current = () => {\n      const floating = floatingRef.current;\n      if (!open || !anchor || !floating) return;\n      const revision = ++version.current;\n      const currentCursor = followCursor ? cursor.current : null;\n      const virtual = Boolean(currentCursor) || pointX !== undefined || pointY !== undefined;\n      const reference: Element | VirtualElement = virtual\n        ? {\n            contextElement: anchor,\n            getBoundingClientRect: () => {\n              const rect = anchor.getBoundingClientRect();\n              const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);\n              const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);\n              return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };\n            },\n          }\n        : anchor;\n      const arrowElement = arrowRef?.current ?? null;\n      void computePosition(reference, floating, {\n        strategy: \"fixed\",\n        placement: currentCursor ? (cursorSide.current ?? preferred) : preferred,\n        middleware: [\n          offset(sideOffset ?? (currentCursor ? 12 : 8)),\n          flip({ padding: 8 }),\n          shift({ padding: 8 }),\n          ...(arrowElement ? [arrowMiddleware({ element: arrowElement, padding: 10 })] : []),\n          // A label left floating over whatever scrolled into its trigger's place\n          // describes nothing there. Points inside a chart move with the chart.\n          ...(virtual ? [] : [hide({ strategy: \"referenceHidden\" })]),\n        ],\n      }).then(({ x, y, placement, middlewareData }) => {\n        if (version.current !== revision || !floating.isConnected) return;\n        // Hold the chosen side for this hover session. Crossing a flip threshold\n        // repeatedly must not bounce the surface above and below the pointer.\n        if (currentCursor) cursorSide.current = placement;\n        else cursorSide.current = null;\n        const placedSide = placement.split(\"-\")[0] as TooltipSide;\n        const dpr = window.devicePixelRatio || 1;\n        floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;\n        floating.style.setProperty(\"--tooltip-origin\", ORIGIN[placedSide]);\n        // A box with no size has no clipped state to report, only a missing layout.\n        const box = anchor.getBoundingClientRect();\n        const clipped =\n          Boolean(middlewareData.hide?.referenceHidden) && (box.width > 0 || box.height > 0);\n        floating.style.visibility = clipped ? \"hidden\" : \"visible\";\n        floating.dataset.placement = placement;\n        const surface = arrowElement?.parentElement;\n        if (arrowElement && surface) {\n          // Measured against the positioner, drawn inside the surface's border.\n          const { x: ax, y: ay } = middlewareData.arrow ?? {};\n          if (ax != null) arrowElement.style.left = `${ax - surface.clientLeft}px`;\n          if (ay != null) arrowElement.style.top = `${ay - surface.clientTop}px`;\n          // Paint with whatever the surface resolved to, so a restyled tooltip\n          // keeps a matching arrow without a second set of classes.\n          const style = getComputedStyle(surface);\n          arrowElement.style.setProperty(\"--tooltip-arrow-fill\", style.backgroundColor);\n          arrowElement.style.setProperty(\"--tooltip-arrow-stroke\", style.borderTopColor);\n        }\n        onPosition(placedSide);\n      });\n    };\n    if (open) schedule();\n  }, [\n    open,\n    anchor,\n    floatingRef,\n    arrowRef,\n    followCursor,\n    pointX,\n    pointY,\n    preferred,\n    sideOffset,\n    schedule,\n    onPosition,\n    cursor,\n  ]);\n\n  useLayoutEffect(() => {\n    const floating = floatingRef.current;\n    if (!open || !anchor || !floating) return;\n    const stop = autoUpdate(anchor, floating, schedule);\n    const onScroll = () => {\n      // A stationary pointer no longer describes the same chart point after scrolling.\n      if (followCursor && cursor.current) onDismiss();\n    };\n    window.addEventListener(\"scroll\", onScroll, true);\n    return () => {\n      stop();\n      window.removeEventListener(\"scroll\", onScroll, true);\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n      frame.current = null;\n    };\n  }, [open, anchor, floatingRef, followCursor, onDismiss, schedule, cursor]);\n\n  useLayoutEffect(\n    () => () => {\n      version.current++;\n      if (frame.current !== null) cancelAnimationFrame(frame.current);\n    },\n    [],\n  );\n}\n\n/** Pointer lifetime belongs to the anchor, including the opening delay. */\nexport function useTooltipPointer(anchor: TooltipAnchor | null, followCursor: boolean) {\n  const cursor = useRef<TooltipPoint | null>(null);\n  const onMove = useRef<(() => void) | null>(null);\n  const pointerFocus = useRef(false);\n  useLayoutEffect(() => {\n    if (!anchor || !followCursor) return;\n    const point = (event: PointerEvent) => {\n      if (event.type === \"pointermove\" && event.pointerType === \"touch\" && !event.buttons) return;\n      cursor.current = { x: event.clientX, y: event.clientY };\n      if (event.type === \"pointerdown\") pointerFocus.current = true;\n      onMove.current?.();\n    };\n    const keyboard = () => {\n      cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const focus = () => {\n      if (!pointerFocus.current) cursor.current = null;\n      pointerFocus.current = false;\n      onMove.current?.();\n    };\n    const leave = () => {\n      cursor.current = null;\n    };\n    anchor.addEventListener(\"pointerenter\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointermove\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerdown\", point as EventListener, { passive: true });\n    anchor.addEventListener(\"pointerleave\", leave);\n    anchor.addEventListener(\"pointercancel\", leave);\n    anchor.addEventListener(\"keydown\", keyboard);\n    anchor.addEventListener(\"focusin\", focus);\n    return () => {\n      cursor.current = null;\n      anchor.removeEventListener(\"pointerenter\", point as EventListener);\n      anchor.removeEventListener(\"pointermove\", point as EventListener);\n      anchor.removeEventListener(\"pointerdown\", point as EventListener);\n      anchor.removeEventListener(\"pointerleave\", leave);\n      anchor.removeEventListener(\"pointercancel\", leave);\n      anchor.removeEventListener(\"keydown\", keyboard);\n      anchor.removeEventListener(\"focusin\", focus);\n    };\n  }, [anchor, followCursor]);\n\n  return { cursor, onMove };\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** Presentation only: the unanimated parent owns measurement and positioning. */\nexport function TooltipSurface({\n  children,\n  side: _side = \"top\",\n  className,\n  ref,\n  ready = true,\n  style,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  /** Start the entrance only after the positioning layer has been measured. */\n  ready?: boolean;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      initial={closed}\n      animate={{\n        ...(ready ? { opacity: 1, scale: 1 } : closed),\n        transition: { duration: 0.18, ease: EASE_OUT },\n      }}\n      exit={{ ...closed, transition: { duration: 0.12, ease: EASE_OUT } }}\n      style={{ transformOrigin: \"var(--tooltip-origin, center)\", ...style }}\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/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"}]}