{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"price-target-fan","type":"registry:component","title":"Price Target Fan","description":"Composable price target chart with Header, Plot, SVG, Axes, History, Targets, Now, Cursor, and Tooltip parts. Supply dated price history and targets; scrub with a pointer or keyboard and customize the active readout.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/charts/price-target-fan.tsx","type":"registry:component","target":"@components/charts/price-target-fan.tsx","content":"\"use client\";\n// beui.dev/charts/price-target-fan\n\nimport { cn } from \"@/lib/utils\";\nimport { PriceTargetFanContext, usePriceTargetFanModel } from \"./price-target-fan/context\";\nimport { PriceTargetFanHeader } from \"./price-target-fan/header\";\nimport { PriceTargetFanPlot } from \"./price-target-fan/plot\";\nimport type { PriceTargetFanProps } from \"./price-target-fan/types\";\n\n/** Owns chart data and interaction; children may replace or rearrange the default parts. */\nexport function PriceTargetFan({ children, className, ...props }: PriceTargetFanProps) {\n  const model = usePriceTargetFanModel(props);\n  return (\n    <PriceTargetFanContext.Provider value={model}>\n      <div className={cn(\"w-[520px] max-w-full [--ink-l:0.5] dark:[--ink-l:1]\", className)}>\n        {children === undefined ? (\n          <>\n            <PriceTargetFanHeader />\n            <PriceTargetFanPlot />\n          </>\n        ) : (\n          children\n        )}\n      </div>\n    </PriceTargetFanContext.Provider>\n  );\n}\n\nexport { usePriceTargetFan } from \"./price-target-fan/context\";\nexport { PriceTargetFanHeader } from \"./price-target-fan/header\";\nexport { PriceTargetFanAxes, PriceTargetFanPlot, PriceTargetFanSvg } from \"./price-target-fan/plot\";\nexport {\n  PriceTargetFanCursor,\n  PriceTargetFanHistory,\n  PriceTargetFanNow,\n  PriceTargetFanTargets,\n} from \"./price-target-fan/series\";\nexport { PriceTargetFanTooltip } from \"./price-target-fan/tooltip\";\nexport type {\n  PriceHistoryPoint,\n  PriceTarget,\n  PriceTargetFanActive,\n  PriceTargetFanProps,\n} from \"./price-target-fan/types\";\n"},{"path":"components/charts/price-target-fan/context.ts","type":"registry:component","target":"@components/charts/price-target-fan/context.ts","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useId, useMemo, useRef, useState } from \"react\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport type { PriceTargetFanActive, PriceTargetFanProps } from \"./types\";\nimport { EMPTY_HISTORY, fmtScrubDate, H, LOW, MID, PAD, UP, W } from \"./utils\";\n\n/**\n * Analyst price targets. A year of history draws itself to now, then three\n * dashed projections fan out to the high, mean and low targets. The hot target\n * owns the header: its price rolls in on the shared NumberTicker and its\n * projection draws itself solid; the mean takes the header back on leave.\n * Scrubbing the history or hovering a target glides a value card\n * beside the point on a spring, one metric per row. Targets are focusable and\n * a tap toggles them on touch; Escape lets go. The now dot sends a slow ring\n * outward as the live-price signal. Reduced motion shows the finished chart,\n * moves the card without travel, and drops the ring.\n */\nexport function usePriceTargetFanModel({\n  label = \"Price target · 12 months\",\n  current,\n  targets,\n  dates = {},\n  history = EMPTY_HISTORY,\n  active: controlledActive,\n  defaultActive = null,\n  onActiveChange,\n}: PriceTargetFanProps) {\n  const reduce = useReducedMotion();\n  const clipId = useId();\n  const fadeId = `${clipId}-fade`;\n  const svgRef = useRef<SVGSVGElement>(null);\n  const canHover = useHoverCapable();\n  const tooltipId = useId();\n  const [internalActive, setInternalActive] = useState(defaultActive);\n  const requestedActive = controlledActive === undefined ? internalActive : controlledActive;\n  const setActive = (next: PriceTargetFanActive | null) => {\n    if (controlledActive === undefined) setInternalActive(next);\n    onActiveChange?.(next);\n  };\n  const targetIndex =\n    requestedActive?.type === \"target\"\n      ? targets.findIndex((target) => target.key === requestedActive.key)\n      : -1;\n  const historyIndex =\n    requestedActive?.type === \"history\"\n      ? history.findIndex((point) => point.date === requestedActive.date)\n      : -1;\n  const active = targetIndex >= 0 || historyIndex >= 0 ? requestedActive : null;\n  if (requestedActive && !active && controlledActive === undefined) setInternalActive(null);\n  const hotT = targetIndex >= 0 ? targetIndex : null;\n  const scrub = historyIndex >= 0 ? historyIndex : null;\n  const setHotT = (index: number | null) =>\n    setActive(index === null ? null : { type: \"target\", key: targets[index].key });\n  const setScrub = (index: number | null) =>\n    setActive(index === null ? null : { type: \"history\", date: history[index].date });\n  const hist = useMemo(() => {\n    let previous = Number.NEGATIVE_INFINITY;\n    return history.map((point) => {\n      const time = Date.parse(point.date);\n      if (\n        !/^\\d{4}-\\d{2}-\\d{2}(?:T.*(?:Z|[+-]\\d{2}:\\d{2}))?$/.test(point.date) ||\n        !Number.isFinite(time) ||\n        time <= previous ||\n        !Number.isFinite(point.price) ||\n        point.price <= 0\n      ) {\n        throw new RangeError(\n          \"PriceTargetFan history must contain positive prices and ascending, unique ISO dates\",\n        );\n      }\n      previous = time;\n      return point.price;\n    });\n  }, [history]);\n  if (\n    !Number.isFinite(current) ||\n    current <= 0 ||\n    targets.length !== 3 ||\n    targets.some((target) => !Number.isFinite(target.price) || target.price <= 0) ||\n    new Set(targets.map((target) => target.key)).size !== 3\n  ) {\n    throw new RangeError(\n      \"PriceTargetFan requires a positive current price and three positive targets with unique keys\",\n    );\n  }\n  // the domain follows the data: whatever history and targets arrive, the\n  // chart fills its height instead of assuming a $150-250 stock\n  const yLo = Math.min(current, ...hist, ...targets.map((t) => t.price));\n  const yHi = Math.max(current, ...hist, ...targets.map((t) => t.price));\n  const yPad = Math.max((yHi - yLo) * 0.06, 0.5);\n  const yMin = yLo - yPad;\n  const yMax = yHi + yPad;\n  const y = useCallback(\n    (v: number) => PAD.t + (1 - (v - yMin) / (yMax - yMin)) * (H - PAD.t - PAD.b),\n    [yMin, yMax],\n  );\n  const pct = (p: number) => ((p - current) / current) * 100;\n  const fmt = (p: number) => `$${p.toFixed(2)}`;\n\n  const geo = useMemo(() => {\n    const histW = (W - PAD.l - PAD.r) * 0.56;\n    const nowX = PAD.l + histW;\n    const times = history.map((point) => Date.parse(point.date));\n    const first = times[0] ?? 0;\n    const last = times[times.length - 1] ?? first;\n    const hx = (i: number) =>\n      last === first ? nowX - 12 : PAD.l + ((times[i] - first) / (last - first)) * (histW - 12);\n    const nowY = y(current);\n    const endX = W - PAD.r;\n    const line =\n      hist.map((v, i) => `${i === 0 ? \"M\" : \"L\"}${hx(i).toFixed(1)},${y(v).toFixed(1)}`).join(\" \") +\n      (hist.length ? ` L${nowX},${nowY}` : \"\");\n    const proj = targets.map((t, i) => {\n      const ty = y(t.price);\n      const cx = nowX + (endX - nowX) * 0.5;\n      const cy = nowY + (ty - nowY) * 0.15;\n      const color = t.color ?? [UP, MID, LOW][i];\n      return { ...t, color, ty, d: `M${nowX},${nowY} Q${cx},${cy} ${endX},${ty}`, cx, cy };\n    });\n    return { hx, nowX, nowY, endX, line, proj };\n  }, [hist, history, targets, current, y]);\n\n  const onMove = (e: React.PointerEvent) => {\n    if (!canHover || e.pointerType === \"touch\") return;\n    const el = svgRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const px = ((e.clientX - r.left) / r.width) * W;\n    if (px > geo.nowX + 6) {\n      // in the fan the nearest projection owns the pointer, so the card walks\n      // from high to mean to low as the pointer drifts instead of dropping out\n      const py = ((e.clientY - r.top) / r.height) * H;\n      const t = Math.min(1, (px - geo.nowX) / (geo.endX - geo.nowX));\n      let nearest = 0;\n      let best = Number.POSITIVE_INFINITY;\n      const ys = geo.proj.map((p) => (1 - t) * (1 - t) * geo.nowY + 2 * (1 - t) * t * p.cy + t * t * p.ty);\n      ys.forEach((cy, i) => {\n        const d = Math.abs(py - cy);\n        if (d < best) {\n          best = d;\n          nearest = i;\n        }\n      });\n      // right after now the three curves still overlap; picking one there would be a guess\n      const spread = Math.max(...ys) - Math.min(...ys);\n      setHotT(spread < 24 ? null : nearest);\n      return;\n    }\n    if (!hist.length || px > geo.nowX - 6) {\n      setActive(null);\n      return;\n    }\n    let nearest = 0;\n    for (let i = 1; i < hist.length; i++) {\n      if (Math.abs(geo.hx(i) - px) < Math.abs(geo.hx(nearest) - px)) nearest = i;\n    }\n    setScrub(nearest);\n  };\n\n  // a hovered target wins over the scrub; the card sits on whichever side keeps it in view\n  const overlay = (() => {\n    if (hotT !== null) {\n      const p = geo.proj[hotT];\n      const up = p.price >= current;\n      return {\n        px: geo.endX,\n        py: p.ty,\n        // the horizon rides in the faint header so the target has a date without a row\n        title: `${p.key} target${dates.horizon ? ` · ${dates.horizon}` : \"\"}`,\n        // one metric per row, label against value; the move sits alone under a hairline\n        metrics: [\n          { label: \"Price\", value: fmt(p.price) },\n          { label: \"Analysts\", value: String(p.analysts) },\n        ],\n        accent: {\n          label: \"vs now\",\n          value: `${up ? \"+\" : \"−\"}${Math.abs(pct(p.price)).toFixed(1)}%`,\n          color: p.color as string,\n        } as { label: string; value: string; color: string } | null,\n      };\n    }\n    if (scrub !== null) {\n      const title = fmtScrubDate.format(new Date(history[scrub].date));\n      return {\n        px: geo.hx(scrub),\n        py: y(hist[scrub]),\n        title,\n        metrics: [{ label: \"Price\", value: fmt(hist[scrub]) }],\n        accent: null as { label: string; value: string; color: string } | null,\n      };\n    }\n    return null;\n  })();\n\n  const mean = targets[1];\n  // whichever target is hot owns the header; the mean holds it otherwise\n  const head = hotT !== null ? geo.proj[hotT] : { key: mean.key, price: mean.price, color: undefined };\n  const headPct = pct(head.price);\n  // round-numbered gridlines derived from the domain, at most four of them\n  const gridVals = useMemo(() => {\n    const span = yMax - yMin;\n    const mag = 10 ** Math.floor(Math.log10(span / 3.2));\n    const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= 4.2) ?? 10 * mag;\n    const out: number[] = [];\n    for (let v = Math.ceil(yMin / step) * step; v <= yMax; v += step) out.push(v);\n    return { step, out };\n  }, [yMin, yMax]);\n  const drawn = reduce ? { duration: 0 } : undefined;\n\n  return {\n    reduce,\n    clipId,\n    fadeId,\n    svgRef,\n    tooltipId,\n    active,\n    setActive,\n    scrub,\n    hotT,\n    setHotT,\n    setScrub,\n    hist,\n    history,\n    current,\n    targets,\n    dates,\n    label,\n    y,\n    pct,\n    fmt,\n    geo,\n    overlay,\n    mean,\n    head,\n    headPct,\n    gridVals,\n    drawn,\n    onMove,\n  };\n}\n\nexport const PriceTargetFanContext = createContext<ReturnType<typeof usePriceTargetFanModel> | null>(null);\n\nexport function usePriceTargetFan() {\n  const context = useContext(PriceTargetFanContext);\n  if (!context) throw new Error(\"PriceTargetFan parts must be inside PriceTargetFan\");\n  return context;\n}\n"},{"path":"components/charts/price-target-fan/header.tsx","type":"registry:component","target":"@components/charts/price-target-fan/header.tsx","content":"\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { cn } from \"@/lib/utils\";\nimport { usePriceTargetFan } from \"./context\";\nimport { ink, LOW, UP } from \"./utils\";\n\nexport function PriceTargetFanHeader({ children, className }: { children?: ReactNode; className?: string }) {\n  const { head, headPct } = usePriceTargetFan();\n  return (\n    <div className={cn(\"mb-1 flex items-end justify-between px-1\", className)}>\n      {children ?? (\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <NumberTicker\n            value={Math.round(head.price * 100)}\n            format={(v) => (v / 100).toFixed(2)}\n            prefix=\"$\"\n            duration={0.5}\n            className=\"font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground\"\n          />\n          <span\n            className=\"font-mono text-xs font-medium tabular-nums\"\n            style={{ color: ink((head.color as string | undefined) ?? (headPct >= 0 ? UP : LOW)) }}\n          >\n            <NumberTicker\n              value={Math.round(Math.abs(headPct) * 10)}\n              format={(v) => (v / 10).toFixed(1)}\n              prefix={headPct >= 0 ? \"+\" : \"−\"}\n              suffix=\"%\"\n              duration={0.5}\n            />\n          </span>\n          <span className=\"text-xs text-muted-foreground\">{head.key} target</span>\n        </div>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/charts/price-target-fan/plot.tsx","type":"registry:component","target":"@components/charts/price-target-fan/plot.tsx","content":"\"use client\";\n\nimport { motion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { usePriceTargetFan } from \"./context\";\nimport {\n  PriceTargetFanCursor,\n  PriceTargetFanHistory,\n  PriceTargetFanNow,\n  PriceTargetFanTargets,\n} from \"./series\";\nimport { PriceTargetFanTooltip } from \"./tooltip\";\nimport { fmtAxisDate, H, PAD, W } from \"./utils\";\n\n/** HTML positioning container. Place Tooltip beside SVG, never inside SVG. */\nexport function PriceTargetFanPlot({ children, className }: { children?: ReactNode; className?: string }) {\n  return (\n    <div className={cn(\"relative\", className)}>\n      {children === undefined ? (\n        <>\n          <PriceTargetFanSvg />\n          <PriceTargetFanTooltip />\n        </>\n      ) : (\n        children\n      )}\n    </div>\n  );\n}\n\nexport function PriceTargetFanSvg({ children, className }: { children?: ReactNode; className?: string }) {\n  const { svgRef, label, mean, fmt, current, onMove, setActive, fadeId, clipId, reduce, geo, drawn } =\n    usePriceTargetFan();\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: SVG cannot contain a fieldset; group exposes the interactive SVG descendants.\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${W} ${H}`}\n      className={cn(\"block h-auto w-full cursor-crosshair [&_*]:cursor-crosshair\", className)}\n      role=\"group\"\n      aria-label={`${label}: mean ${fmt(mean.price)}, now ${fmt(current)}`}\n      onPointerMove={onMove}\n      onPointerLeave={(event) => {\n        if (event.pointerType !== \"touch\") setActive(null);\n      }}\n    >\n      <defs>\n        {/* both vertical guides fade out toward the top so they read as markers, not walls */}\n        <linearGradient id={fadeId} gradientUnits=\"userSpaceOnUse\" x1={0} y1={PAD.t} x2={0} y2={H - PAD.b}>\n          <stop offset=\"0\" stopColor=\"var(--border-strong)\" stopOpacity={0} />\n          <stop offset=\"0.45\" stopColor=\"var(--border-strong)\" stopOpacity={1} />\n          <stop offset=\"1\" stopColor=\"var(--border-strong)\" stopOpacity={1} />\n        </linearGradient>\n        {/* the fan reveals left to right through this clip, so the dashed projections draw as lines */}\n        <clipPath id={clipId}>\n          <motion.rect\n            x={geo.nowX}\n            y={0}\n            height={H}\n            initial={{ width: reduce ? W - geo.nowX : 0 }}\n            animate={{ width: W - geo.nowX }}\n            transition={drawn ?? { duration: 0.8, ease: EASE_OUT, delay: 0.85 }}\n          />\n        </clipPath>\n      </defs>\n\n      {children === undefined ? (\n        <>\n          <PriceTargetFanAxes />\n          <PriceTargetFanHistory />\n          <PriceTargetFanTargets />\n          <PriceTargetFanNow />\n          <PriceTargetFanCursor />\n        </>\n      ) : (\n        children\n      )}\n    </svg>\n  );\n}\n\nexport function PriceTargetFanAxes({ className }: { className?: string }) {\n  const { gridVals, y, geo, dates, history, fadeId } = usePriceTargetFan();\n  return (\n    <g className={cn(className)}>\n      {/* gridlines and the price axis */}\n      {gridVals.out.map((v) => (\n        <g key={v}>\n          <line x1={PAD.l} y1={y(v)} x2={geo.endX} y2={y(v)} stroke=\"var(--border)\" strokeDasharray=\"2 5\" />\n          <text\n            x={PAD.l - 8}\n            y={y(v) + 3}\n            textAnchor=\"end\"\n            fontSize={9}\n            fill=\"var(--muted-foreground)\"\n            className=\"font-mono tabular-nums\"\n          >\n            {gridVals.step >= 1 ? Math.round(v) : v.toFixed(1)}\n          </text>\n        </g>\n      ))}\n\n      {/* the date axis: history, now, horizon */}\n      {[\n        ...(history.length\n          ? [{ x: geo.hx(0), t: dates.start ?? fmtAxisDate(history[0].date), a: \"start\" as const }]\n          : []),\n        ...(history.length > 2\n          ? [\n              {\n                x: geo.hx(Math.floor(history.length / 2)),\n                t: dates.mid ?? fmtAxisDate(history[Math.floor(history.length / 2)].date),\n                a: \"middle\" as const,\n              },\n            ]\n          : []),\n        { x: geo.nowX, t: \"Now\", a: \"middle\" as const },\n        { x: geo.endX, t: dates.horizon ?? \"Target\", a: \"middle\" as const },\n      ].map((d) => (\n        <text\n          key={d.x}\n          x={d.x}\n          y={H - 8}\n          textAnchor={d.a}\n          fontSize={9}\n          fill=\"var(--muted-foreground)\"\n          className=\"font-mono\"\n        >\n          {d.t}\n        </text>\n      ))}\n\n      {/* the now marker */}\n      <line\n        x1={geo.nowX}\n        y1={PAD.t}\n        x2={geo.nowX}\n        y2={H - PAD.b}\n        stroke={`url(#${fadeId})`}\n        strokeWidth={1}\n        strokeDasharray=\"3 3\"\n      />\n    </g>\n  );\n}\n"},{"path":"components/charts/price-target-fan/series.tsx","type":"registry:component","target":"@components/charts/price-target-fan/series.tsx","content":"\"use client\";\n\nimport { motion } from \"motion/react\";\nimport { EASE_OUT, SPRING_GLIDE, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { usePriceTargetFan } from \"./context\";\nimport { H, ink, PAD } from \"./utils\";\n\nexport function PriceTargetFanHistory({ className }: { className?: string }) {\n  const { hist, history, scrub, setScrub, setActive, tooltipId, fmt, geo, reduce, drawn } =\n    usePriceTargetFan();\n  return (\n    <g className={cn(className)}>\n      {hist.length ? (\n        <g\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Price history\"\n          aria-valuemin={0}\n          aria-valuemax={hist.length - 1}\n          aria-valuenow={scrub ?? hist.length - 1}\n          aria-valuetext={`${history[scrub ?? hist.length - 1].date}: ${fmt(hist[scrub ?? hist.length - 1])}`}\n          aria-describedby={scrub !== null ? tooltipId : undefined}\n          onFocus={() => setScrub(hist.length - 1)}\n          onBlur={() => setActive(null)}\n          onKeyDown={(event) => {\n            const index = scrub ?? hist.length - 1;\n            if (event.key === \"Escape\") {\n              event.preventDefault();\n              setActive(null);\n            } else if ([\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) {\n              event.preventDefault();\n              setScrub(\n                event.key === \"Home\"\n                  ? 0\n                  : event.key === \"End\"\n                    ? hist.length - 1\n                    : Math.max(0, Math.min(hist.length - 1, index + (event.key === \"ArrowRight\" ? 1 : -1))),\n              );\n            }\n          }}\n        >\n          {\" \"}\n          {/* history draws itself to now */}\n          <motion.path\n            data-price-history=\"\"\n            d={geo.line}\n            fill=\"none\"\n            stroke=\"var(--foreground)\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            initial={{ pathLength: reduce ? 1 : 0 }}\n            animate={{ pathLength: 1 }}\n            transition={drawn ?? { duration: 0.9, ease: EASE_OUT }}\n          />\n        </g>\n      ) : null}\n    </g>\n  );\n}\n\nexport function PriceTargetFanTargets({ className }: { className?: string }) {\n  const { geo, hotT, setHotT, setActive, tooltipId, clipId, reduce, drawn, fmt } = usePriceTargetFan();\n  return (\n    <g className={cn(className)}>\n      {/* projections and target labels */}\n      <g clipPath={`url(#${clipId})`}>\n        {geo.proj.map((p, i) => {\n          const on = hotT === i;\n          const dim = hotT !== null && !on;\n          return (\n            <motion.g\n              key={p.key}\n              role=\"button\"\n              tabIndex={0}\n              aria-label={`${p.key} target ${fmt(p.price)}, ${p.analysts} analysts`}\n              className=\"outline-none\"\n              aria-pressed={on}\n              aria-describedby={on ? tooltipId : undefined}\n              initial={{ opacity: 1 }}\n              animate={{ opacity: dim ? 0.3 : 1 }}\n              transition={drawn ?? { duration: 0.25, ease: EASE_OUT }}\n              onFocus={() => setHotT(i)}\n              onBlur={() => setHotT(null)}\n              // a tap toggles on touch, where hover never fires; mouse and pen keep hover\n              onPointerDown={(e) => {\n                if (e.pointerType === \"touch\") {\n                  e.preventDefault();\n                  setHotT(on ? null : i);\n                }\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  setActive(null);\n                }\n                if (e.key === \"Enter\" || e.key === \" \") {\n                  e.preventDefault();\n                  setHotT(on ? null : i);\n                }\n              }}\n            >\n              <path d={p.d} fill=\"none\" stroke=\"transparent\" strokeWidth={16} />\n              <path\n                d={p.d}\n                fill=\"none\"\n                stroke={p.color}\n                strokeWidth={on ? 2.2 : 1.4}\n                strokeOpacity={on ? 1 : 0.75}\n                strokeDasharray=\"2 4\"\n                strokeLinecap=\"round\"\n              />\n              {/* the hot projection draws itself solid over the dashes, base to target */}\n              {on && (\n                <motion.path\n                  d={p.d}\n                  fill=\"none\"\n                  stroke={p.color}\n                  strokeWidth={2.2}\n                  strokeLinecap=\"round\"\n                  initial={{ pathLength: reduce ? 1 : 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={drawn ?? { duration: 0.35, ease: EASE_OUT }}\n                />\n              )}\n              <motion.circle\n                cx={geo.endX}\n                cy={p.ty}\n                fill=\"var(--background)\"\n                stroke={p.color}\n                strokeWidth={1.6}\n                r={3.2}\n                animate={{ r: on ? 4.5 : 3.2 }}\n                transition={drawn ?? SPRING_PANEL}\n              />\n              <text\n                x={geo.endX + 10}\n                y={p.ty + 4}\n                fontSize={11}\n                fontWeight={600}\n                fill={ink(p.color)}\n                className=\"font-mono tabular-nums\"\n              >\n                {p.price}\n              </text>\n            </motion.g>\n          );\n        })}\n      </g>\n    </g>\n  );\n}\n\nexport function PriceTargetFanNow({ className }: { className?: string }) {\n  const { geo, reduce, drawn } = usePriceTargetFan();\n  return (\n    <g className={cn(className)}>\n      {/* the now dot lands as the history arrives */}\n      <motion.circle\n        cx={geo.nowX}\n        cy={geo.nowY}\n        r={3.2}\n        fill=\"var(--foreground)\"\n        initial={{ opacity: reduce ? 1 : 0, scale: reduce ? 1 : 0 }}\n        animate={{ opacity: 1, scale: 1 }}\n        transition={drawn ?? { ...SPRING_PANEL, delay: 0.85 }}\n        style={{ transformOrigin: `${geo.nowX}px ${geo.nowY}px` }}\n      />\n      {/* the live tail: a ring leaves the now dot every few seconds */}\n      {!reduce && (\n        <motion.circle\n          cx={geo.nowX}\n          cy={geo.nowY}\n          r={3.2}\n          fill=\"none\"\n          stroke=\"var(--foreground)\"\n          strokeWidth={1}\n          initial={{ opacity: 0, scale: 1 }}\n          animate={{ opacity: [0.45, 0], scale: [1, 2.8] }}\n          transition={{\n            duration: 2.2,\n            ease: EASE_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n            repeatDelay: 1.6,\n            delay: 1.6,\n          }}\n          style={{ transformOrigin: `${geo.nowX}px ${geo.nowY}px` }}\n        />\n      )}\n    </g>\n  );\n}\n\nexport function PriceTargetFanCursor({ className }: { className?: string }) {\n  const { scrub, geo, y, hist, fadeId, reduce } = usePriceTargetFan();\n  return (\n    <g className={cn(className)}>\n      {/* the scrub crosshair: it flows along the history on a spring instead of\n              stepping cell to cell, so a slow drag reads as one continuous read-out */}\n      {scrub !== null && (\n        <g pointerEvents=\"none\">\n          <motion.line\n            y1={PAD.t}\n            y2={H - PAD.b}\n            stroke={`url(#${fadeId})`}\n            strokeWidth={1}\n            initial={false}\n            animate={{ x1: geo.hx(scrub), x2: geo.hx(scrub) }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", ...SPRING_GLIDE }}\n          />\n          <motion.circle\n            r={3.2}\n            fill=\"var(--foreground)\"\n            stroke=\"var(--background)\"\n            strokeWidth={1.5}\n            initial={false}\n            animate={{ cx: geo.hx(scrub), cy: y(hist[scrub]) }}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", ...SPRING_GLIDE }}\n          />\n        </g>\n      )}\n    </g>\n  );\n}\n"},{"path":"components/charts/price-target-fan/tooltip.tsx","type":"registry:component","target":"@components/charts/price-target-fan/tooltip.tsx","content":"\"use client\";\n\nimport { type ReactNode, useState } from \"react\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { usePriceTargetFan } from \"./context\";\nimport { H, ink, W } from \"./utils\";\n\nexport function PriceTargetFanTooltip({\n  children,\n  className,\n}: {\n  children?: ReactNode | ((data: NonNullable<ReturnType<typeof usePriceTargetFan>[\"overlay\"]>) => ReactNode);\n  className?: string;\n}) {\n  const { svgRef, tooltipId, overlay } = usePriceTargetFan();\n  const [dismissed, setDismissed] = useState<typeof overlay>(null);\n  return (\n    <Tooltip\n      open={overlay !== null && dismissed !== overlay}\n      onOpenChange={(open) => { if (!open) setDismissed(overlay); }}\n      id={tooltipId}\n      anchorRef={svgRef}\n      anchorPoint={{ x: (overlay?.px ?? 0) / W, y: (overlay?.py ?? 0) / H }}\n      className={cn(\"w-[148px]\", className)}\n      content={overlay &&\n        (typeof children === \"function\"\n          ? children(overlay)\n          : (children ?? (\n              <>\n                <span className=\"block text-[10px] text-muted-foreground\">{overlay.title}</span>\n                <span className=\"mt-1.5 flex flex-col gap-1\">\n                  {overlay.metrics.map((metric) => (\n                    <span key={metric.label} className=\"flex items-center justify-between gap-3 text-xs\">\n                      <span className=\"font-medium text-foreground\">{metric.label}</span>\n                      <span className=\"font-mono tabular-nums text-foreground\">{metric.value}</span>\n                    </span>\n                  ))}\n                </span>\n                {overlay.accent ? (\n                  <span className=\"mt-1.5 flex items-center justify-between gap-3 border-t border-border pt-1.5 text-xs\">\n                    <span className=\"font-medium text-foreground\">{overlay.accent.label}</span>\n                    <span className=\"font-mono tabular-nums\" style={{ color: ink(overlay.accent.color) }}>\n                      {overlay.accent.value}\n                    </span>\n                  </span>\n                ) : null}\n              </>\n            )))}\n    />\n  );\n}\n"},{"path":"components/charts/price-target-fan/types.ts","type":"registry:component","target":"@components/charts/price-target-fan/types.ts","content":"import type { ReactNode } from \"react\";\n\nexport interface PriceTarget {\n  key: string;\n  price: number;\n  analysts: number;\n  /** Any CSS color; the projection, its dot and its label take it. */\n  color?: string;\n}\n\nexport interface PriceHistoryPoint {\n  date: string;\n  price: number;\n}\n\nexport type PriceTargetFanActive = { type: \"history\"; date: string } | { type: \"target\"; key: string };\n\nexport interface PriceTargetFanProps {\n  /** Accessible name of the chart. */\n  label?: string;\n  /** Last traded price; the history walks to this point. */\n  current: number;\n  /** High, mean and low targets, in that order. */\n  targets: [PriceTarget, PriceTarget, PriceTarget];\n  /** Axis labels, oldest to horizon. */\n  dates?: { start?: string; mid?: string; horizon?: string };\n  /** Actual prices, oldest first, using ISO dates. Empty history renders only the current price and targets. */\n  history?: PriceHistoryPoint[];\n  children?: ReactNode;\n  active?: PriceTargetFanActive | null;\n  defaultActive?: PriceTargetFanActive | null;\n  onActiveChange?: (active: PriceTargetFanActive | null) => void;\n  className?: string;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/charts/price-target-fan/utils.ts","type":"registry:component","target":"@components/charts/price-target-fan/utils.ts","content":"import type { PriceHistoryPoint } from \"./types\";\n\n/** e.g. \"Mon, Jul 15\" — the scrub read-out's date. */\nexport const fmtScrubDate = new Intl.DateTimeFormat(\"en-US\", {\n  timeZone: \"UTC\",\n  weekday: \"short\",\n  month: \"short\",\n  day: \"numeric\",\n});\n\nexport const UP = \"var(--success)\";\n\nexport const MID = \"var(--accent)\";\n\nexport const LOW = \"var(--warning)\";\n\nexport const W = 520;\n\nexport const H = 236;\n\nexport const PAD = { l: 36, r: 108, t: 16, b: 28 };\n\n/** A hue as text: on the light page its lightness is capped so 11px numbers reach AA while the chroma stays, in dark it is native (`--ink-l` flips per theme on the root). */\nexport const ink = (c: string) => `oklch(from ${c} min(l, var(--ink-l, 1)) c h)`;\n\nexport const EMPTY_HISTORY: PriceHistoryPoint[] = [];\n\nexport const fmtAxisDate = (date: string) =>\n  new Intl.DateTimeFormat(\"en-US\", { timeZone: \"UTC\", month: \"short\", year: \"numeric\" }).format(\n    new Date(date),\n  );\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert locale group separators (commas). Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString()\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"components/motion/tooltip.tsx","type":"registry:component","target":"@components/motion/tooltip.tsx","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  cloneElement,\n  isValidElement,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children?: ReactElement;\n  /** Existing trigger for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<HTMLElement | SVGElement | null>;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: { x: number; y: number };\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  id?: string;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\n// Gap between trigger and tooltip, in px.\nconst GAP = 8;\n\n// Centering transform for the fixed-positioned anchor point, per side.\nconst anchorTransform: Record<Side, string> = {\n  top: \"translate(-50%, -100%)\",\n  bottom: \"translate(-50%, 0)\",\n  left: \"translate(-100%, -50%)\",\n  right: \"translate(0, -50%)\",\n};\n\nconst transformOrigin: Record<Side, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  delay = 120,\n  className,\n  wrapperClassName,\n  anchorRef: externalAnchorRef,\n  anchorPoint,\n  open: controlledOpen,\n  onOpenChange,\n  id: providedId,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = controlledOpen ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n  const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);\n  const generatedId = useId();\n  const id = providedId ?? generatedId;\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = externalAnchorRef ?? wrapperRef;\n  const hover = useHoverGesture();\n  const surfaceRef = useRef<HTMLSpanElement>(null);\n\n  // Anchor point in viewport coords, on the edge of the trigger facing `side`.\n  // Position:fixed means these viewport coords place the tooltip directly, so\n  // it escapes every ancestor's stacking context and overflow.\n  const place = useCallback(() => {\n    const el = anchorRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const cx = r.left + r.width * (anchorPoint?.x ?? 0.5);\n    const cy = r.top + r.height * (anchorPoint?.y ?? 0.5);\n    const point: Record<Side, { top: number; left: number }> = {\n      top: { top: (anchorPoint ? cy : r.top) - GAP, left: cx },\n      bottom: { top: (anchorPoint ? cy : r.bottom) + GAP, left: cx },\n      left: { top: cy, left: (anchorPoint ? cx : r.left) - GAP },\n      right: { top: cy, left: (anchorPoint ? cx : r.right) + GAP },\n    };\n    const next = point[side];\n    const width = surfaceRef.current?.offsetWidth ?? 0;\n    const height = surfaceRef.current?.offsetHeight ?? 0;\n    const dx = side === \"left\" ? width : side === \"right\" ? 0 : width / 2;\n    const dy = side === \"top\" ? height : side === \"bottom\" ? 0 : height / 2;\n    next.left = Math.max(GAP + dx, Math.min(next.left, window.innerWidth - GAP - width + dx));\n    next.top = Math.max(GAP + dy, Math.min(next.top, window.innerHeight - GAP - height + dy));\n    setCoords(previous => previous?.top === next.top && previous.left === next.left ? previous : next);\n  }, [side, anchorRef, anchorPoint]);\n\n  const positioned = coords !== null;\n  useLayoutEffect(() => {\n    if (!open) return;\n    place();\n    const observer = new ResizeObserver(place);\n    if (anchorRef.current) observer.observe(anchorRef.current);\n    if (positioned && surfaceRef.current) observer.observe(surfaceRef.current);\n    return () => observer.disconnect();\n  }, [open, place, anchorRef, positioned]);\n\n  const show = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    timer.current = setTimeout(\n      () => {\n        place();\n        setOpen(true);\n      },\n      warm ? 0 : delay,\n    );\n  }, [delay, place, setOpen]);\n\n  const hide = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  }, [open, setOpen]);\n\n  // A finger never hovers, and Safari does not focus a button on tap either, so\n  // the label is only reachable if the tap itself opens the tooltip. A click\n  // carries no pointerType, so the pointerdown that preceded it is what says\n  // whether this was a tap; keyboard activation arrives with no pointerdown at\n  // all, and focus has already shown the label there.\n  const tap = useTapGesture<boolean>();\n\n  const toggleOnTap = useCallback(() => {\n    const gesture = tap.take();\n    if (!gesture || gesture.pointerType === \"mouse\") return;\n    if (gesture.state) {\n      hide();\n      return;\n    }\n    if (timer.current) clearTimeout(timer.current);\n    place();\n    setOpen(true);\n  }, [hide, place, tap, setOpen]);\n\n  // ...and closed again by the next tap that lands somewhere else. The label\n  // covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, hide, anchorRef);\n\n  // Keep the tooltip pinned to the trigger while it's open and the page scrolls\n  // or resizes (fixed coords are viewport-relative).\n  useEffect(() => {\n    if (!open) return;\n    const onMove = () => place();\n    window.addEventListener(\"scroll\", onMove, true);\n    window.addEventListener(\"resize\", onMove);\n    return () => {\n      window.removeEventListener(\"scroll\", onMove, true);\n      window.removeEventListener(\"resize\", onMove);\n    };\n  }, [open, place]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  if (!externalAnchorRef && !isValidElement(children)) return children;\n\n  // The label describes the trigger, so it has to name the trigger itself.\n  // Everything else the tooltip needs is read off the anchor below instead of\n  // cloned on: a handler written onto the child is the child's handler as far\n  // as that child can tell, and a component that owns its activation —\n  // hard-wiring onClick and spreading the rest of its props over it, as\n  // ThemeToggle does — then runs the tooltip's instead of its own. Composing\n  // with `props.onClick` cannot save it either, because a component element's\n  // props hold nothing the component does internally.\n  const trigger = isValidElement(children)\n    ? cloneElement(children as ReactElement<Record<string, unknown>>, {\n        \"aria-describedby\": id,\n      })\n    : null;\n\n  return (\n    <>\n      {!externalAnchorRef ? (\n        // biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.\n        <span\n          ref={wrapperRef}\n          className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n          // Pointer events, not the mouse pair: a tap fires compatibility\n          // mouseenter/mouseleave that carry no pointerType, which raced the tap\n          // path into opening and closing the same label.\n          onPointerEnter={(event: PointerEvent) => {\n            if (hover.enter(event)) show();\n          }}\n          onPointerLeave={(event: PointerEvent) => {\n            if (hover.leave(event)) hide();\n          }}\n          onFocus={show}\n          onBlur={hide}\n          onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n          // A gesture the platform took away sends no click, and a key press\n          // starts an activation that never had a pointer behind it. Either way\n          // the record has to go, or the next click reads a finger that has long\n          // since lifted.\n          onPointerCancel={tap.drop}\n          onKeyDown={tap.drop}\n          onClick={toggleOnTap}\n        >\n          {trigger}\n        </span>\n      ) : null}\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open && coords ? (\n                <span\n                  className=\"pointer-events-none fixed z-[9999]\"\n                  style={{\n                    top: coords.top,\n                    left: coords.left,\n                    transform: anchorTransform[side],\n                  }}\n                >\n                  <TooltipSurface\n                    ref={surfaceRef}\n                    id={id}\n                    side={side}\n                    style={{ transformOrigin: transformOrigin[side], maxWidth: \"calc(100vw - 16px)\", whiteSpace: \"normal\" }}\n                    className={className}\n                  >\n                    {content}\n                  </TooltipSurface>\n                </span>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"registry:component","target":"@components/motion/tooltip-surface.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { useMemo, type ComponentProps, type ReactNode, type Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n// Offset is in the direction *away* from the trigger — content originates near\n// the trigger and rises into resting position.\nconst offsetFrom: Record<Side, { x?: number; y?: number }> = {\n  top: { y: 8 },\n  bottom: { y: -8 },\n  left: { x: 8 },\n  right: { x: -8 },\n};\n\n// Small tooltip surfaces need the lighter spawn used by the original Tooltip.\nconst TOOLTIP_SPRING = { type: \"spring\", stiffness: 380, damping: 30, mass: 0.7 } as const;\n\nfunction buildVariants(side: Side): Variants {\n  const o = offsetFrom[side];\n  return {\n    initial: {\n      opacity: 0,\n      scale: 0.9,\n      filter: \"blur(5px)\",\n      x: o.x ?? 0,\n      y: o.y ?? 0,\n    },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      x: 0,\n      y: 0,\n      transition: {\n        ...TOOLTIP_SPRING,\n        opacity: { duration: 0.14, ease: EASE_OUT },\n        filter: { duration: 0.18, ease: EASE_OUT },\n      },\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: \"blur(3px)\",\n      x: (o.x ?? 0) * 0.6,\n      y: (o.y ?? 0) * 0.6,\n      transition: { duration: 0.12, ease: EASE_OUT },\n    },\n  };\n}\n\nconst REDUCED_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },\n  exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },\n};\n\n/** The shared visual surface for trigger tooltips and chart readouts. Positioning belongs to the caller. */\nexport function TooltipSurface({\n  children,\n  side = \"top\",\n  className,\n  ref,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  side?: Side;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const variants = useMemo(() => reduce ? REDUCED_VARIANTS : buildVariants(side), [reduce, side]);\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      variants={variants}\n      initial=\"initial\"\n      animate=\"animate\"\n      exit=\"exit\"\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"registry:hook","target":"@lib/hooks/use-hover-gesture.ts","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"registry:hook","target":"@lib/hooks/use-tap-gesture.ts","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"}]}