{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"knockout-wheel","type":"registry:block","title":"Fixtures Knockout Wheel","description":"The tournament drawn radially. The champion holds the hub, each round is a ring further out, and the teams themselves form the rim. Nodes spring in ring by ring, and hovering one isolates that team while the rest recede. Teams show a flag, a logo or their initials, and a deeper draw grows another ring.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/knockout-wheel.tsx","type":"registry:component","target":"@components/motion/knockout-wheel.tsx","content":"\"use client\";\n// beui.dev/components/blocks/knockout-bracket\n\nimport { Shield } from \"lucide-react\";\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  memo,\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport { SPRING_PANEL } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Team = {\n  name: string;\n  /**\n   * Any square image URL — club crest, org mark, player photo. Wins over `code`.\n   * Drawn as-is on the node's card-colored disc, so a transparent-background\n   * mark inked for one theme disappears in the other: ship artwork that reads on\n   * both, or pick the URL yourself from your theme state.\n   */\n  logo?: string;\n  /** ISO 3166-1 alpha-2 code, loaded from flagcdn.com (England is gb-eng). Used when `logo` is absent. */\n  code?: string;\n};\n\nexport type MatchSide = {\n  team: Team | null;\n  score: number | null;\n  /** Present on both sides to render shootout scores — 1 (3). */\n  penalties?: number | null;\n};\n\n/** Structurally compatible with the knockout bracket's Match, minus the fields\n * the wheel never draws (date, time, status). */\nexport type Match = {\n  id: string;\n  home: MatchSide;\n  away: MatchSide;\n  winner?: \"home\" | \"away\";\n};\n\nexport type Round = {\n  /** Read out with the match in tooltips and the screen-reader list. */\n  name: string;\n  matches: Match[];\n};\n\nexport interface KnockoutWheelProps {\n  /**\n   * The whole draw, ordered widest round first — the same array the knockout\n   * bracket takes. Any single-elimination tournament fits: each round holds half\n   * the matches of the one before it (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]`\n   * is fed by matches `2k` and `2k + 1` of the round before it. Two rounds are\n   * enough; the wheel grows a ring per round and sizes itself to the rim.\n   */\n  rounds: Round[];\n  /**\n   * Index of the outermost round to draw. Earlier rounds are dropped and the\n   * kept round's own teams become the rim, so `1` on a 32-team draw opens at the\n   * Round of 16. Defaults to 0 (the whole tree); clamped to the valid range.\n   */\n  initialRound?: number;\n  className?: string;\n}\n\nconst SIZE = 760;\nconst CENTER = SIZE / 2;\n// Solid trophy glyph drawn in a 24-unit box.\nconst TROPHY_SIZE = 24;\nconst TROPHY_PATH =\n  \"M5 1h12v3h3a2 2 0 0 1 2 2c0 3.3-2.2 5.6-5.2 6A6 6 0 0 1 12 15a6 6 0 0 1-4.8-2.9C4.2 11.6 2 9.3 2 6a2 2 0 0 1 2-2h1V1Zm0 5H4c0 1.9 1.1 3.2 2.6 3.7A12 12 0 0 1 5 6Zm14 0h-1a12 12 0 0 1-1.6 3.7C17.9 9.2 19 7.9 19 6ZM9 16.5h6V19h2.5v2h-11v-2H9v-2.5Z\";\n// Clears the hub's top edge. The hub's two feeders sit on the horizontal, so\n// the space directly above it is always free.\nconst TROPHY_GAP = 6;\n// Outermost ring. The remaining 62px of the box absorbs the largest node plus\n// its ring stroke, so nothing clips at the viewBox edge.\nconst OUTER_R = 318;\nconst HUB_R = 34;\n// Nodes grow outward so the crowded outer ring still reads at small sizes.\nconst NODE_MIN = 14.2;\nconst NODE_STEP = 2.2;\n// Initials floor, in viewBox units. The stage never goes below 32rem against a\n// 760-unit box (scale ~0.674), so 15 units is ~10px on screen — under that, two\n// letters are a smudge. `node.r * 0.8` alone puts the inner ring at 7.6px.\nconst INITIALS_MIN = 15;\n// Siblings pull slightly toward their parent, opening a lane between subtrees.\nconst SIBLING_GAP = 0.9;\n// Puts the hub's two feeders on the horizontal, where there's room for them.\nconst HUB_ANGLE = 90;\n\n// Module scope so the memoized marks keep a stable transition identity.\nconst DIM_TRANSITION = { duration: 0.18 } as const;\nconst NO_TRANSITION = { duration: 0 } as const;\n\n// Math.sin/cos are implementation-defined down in the last digits, so the SSR\n// engine and the browser disagree and React reports a hydration mismatch on\n// every coordinate. Quantizing well below sub-pixel makes both agree exactly.\nconst quantize = (n: number) => Math.round(n * 1e3) / 1e3;\n\nconst polar = (radius: number, deg: number) => {\n  const rad = (deg * Math.PI) / 180;\n  return {\n    x: quantize(CENTER + radius * Math.cos(rad)),\n    y: quantize(CENTER + radius * Math.sin(rad)),\n  };\n};\n\nconst point = (radius: number, deg: number) => {\n  const { x, y } = polar(radius, deg);\n  return `${x.toFixed(2)} ${y.toFixed(2)}`;\n};\n\n// Fixed precision so the server and client render byte-identical style strings.\n// Raw floats serialize differently across the two and trip a hydration mismatch.\nconst pct = (value: number) => `${((value / SIZE) * 100).toFixed(4)}%`;\n\ntype WheelNode = {\n  id: string;\n  parentId: string | null;\n  depth: number;\n  /** Position around the wheel, in degrees. Orders arrow-key navigation. */\n  angle: number;\n  x: number;\n  y: number;\n  r: number;\n  team: Team | null;\n  label: string;\n  /** Round the node's match belongs to; null on the rim, which holds teams. */\n  round: string | null;\n};\n\ntype WheelLink = {\n  id: string;\n  d: string;\n  depth: number;\n};\n\n// Names and round labels are single ideas, so they wrap as a unit. Without this\n// \"Round of 16\" strands a lone \"16\" on the next line.\nconst keepTogether = (text: string) => text.replace(/ /g, \" \");\n\nconst teamName = (side: MatchSide) => keepTogether(side.team?.name ?? \"TBD\");\n\n/** A `logo` is used as given; a country `code` loads a flag from flagcdn.com. */\nconst crestSrc = (team: Team) =>\n  team.logo ?? (team.code ? `https://flagcdn.com/w80/${team.code}.png` : null);\n\n/** Two-letter stand-in when a team has no artwork — \"Real Madrid\" → RM.\n * Spread, not `word[0]`: an emoji or astral first character is a surrogate pair\n * and indexing it renders a replacement glyph. */\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((word) => [...word][0])\n    .join(\"\")\n    .toUpperCase();\n\n/** Teams · score, in the order they were played. The round is prepended by the\n * caller that has it, so the round list can reuse this without repeating it. */\nfunction matchLabel(match: Match) {\n  const teams = `${teamName(match.home)} v ${teamName(match.away)}`;\n  if (match.home.score == null || match.away.score == null) return teams;\n  const pens =\n    match.home.penalties != null && match.away.penalties != null\n      ? ` (${match.home.penalties}–${match.away.penalties} pens)`\n      : \"\";\n  return `${teams} · ${match.home.score}–${match.away.score}${pens}`;\n}\n\n/** Walks the match tree from the final outward, laying every node on a ring and\n * splitting each parent's wedge between its two feeders. */\nfunction buildWheel(rounds: Round[]) {\n  const nodes: WheelNode[] = [];\n  const links: WheelLink[] = [];\n  const layers = rounds.length;\n  const ringR = (depth: number) => (depth / layers) * OUTER_R;\n  const nodeR = (depth: number) => NODE_MIN + (depth - 1) * NODE_STEP;\n\n  // An empty or malformed catalog renders nothing rather than throwing on the\n  // way to the hub.\n  const final = rounds[layers - 1]?.matches[0];\n  if (!final) return { nodes, links, champion: null };\n\n  const champion = final.winner ? final[final.winner].team : null;\n  nodes.push({\n    id: final.id,\n    parentId: null,\n    depth: 0,\n    angle: HUB_ANGLE,\n    x: CENTER,\n    y: CENTER,\n    r: HUB_R,\n    team: champion,\n    label: matchLabel(final),\n    round: rounds[layers - 1].name,\n  });\n\n  // `roundIndex` is the round `match` belongs to; its two feeders live one round\n  // out, or, past the first round, are the two teams that played it.\n  const walk = (\n    match: Match,\n    roundIndex: number,\n    index: number,\n    parent: WheelNode,\n    angle: number,\n    wedge: number,\n  ) => {\n    const depth = parent.depth + 1;\n    const radius = ringR(depth);\n    const r = nodeR(depth);\n    // The hub's two feeders sit opposite each other, so they get plain radial\n    // lines; an arc between them would be a half circle.\n    const offset = (wedge / 4) * (parent.depth === 0 ? 1 : SIBLING_GAP);\n    const angles = [angle - offset, angle + offset];\n    const sides = [\"home\", \"away\"] as const;\n\n    const children = angles.map((childAngle, side) => {\n      const { x, y } = polar(radius, childAngle);\n      const feeder =\n        roundIndex > 0\n          ? rounds[roundIndex - 1].matches[2 * index + side]\n          : undefined;\n      const node: WheelNode = feeder\n        ? {\n            id: feeder.id,\n            parentId: parent.id,\n            depth,\n            angle: childAngle,\n            x,\n            y,\n            r,\n            team: feeder.winner ? feeder[feeder.winner].team : null,\n            label: matchLabel(feeder),\n            round: rounds[roundIndex - 1].name,\n          }\n        : {\n            id: `${match.id}-${sides[side]}`,\n            parentId: parent.id,\n            depth,\n            angle: childAngle,\n            x,\n            y,\n            r,\n            team: match[sides[side]].team,\n            label: match[sides[side]].team?.name ?? \"TBD\",\n            round: null,\n          };\n      nodes.push(node);\n      return { node, angle: childAngle, feeder };\n    });\n\n    if (parent.depth === 0) {\n      for (const child of children) {\n        links.push({\n          id: `${parent.id}-${child.node.id}`,\n          d: `M ${point(radius, child.angle)} L ${CENTER} ${CENTER}`,\n          depth,\n        });\n      }\n    } else {\n      const midR = (ringR(parent.depth) + radius) / 2;\n      links.push({\n        id: `${parent.id}-arc`,\n        d: `M ${point(radius, angles[0])} L ${point(midR, angles[0])} A ${midR} ${midR} 0 0 1 ${point(midR, angles[1])} L ${point(radius, angles[1])}`,\n        depth,\n      });\n      links.push({\n        id: `${parent.id}-stem`,\n        d: `M ${point(midR, angle)} L ${point(ringR(parent.depth), angle)}`,\n        depth,\n      });\n    }\n\n    for (const [side, child] of children.entries()) {\n      if (child.feeder) {\n        walk(\n          child.feeder,\n          roundIndex - 1,\n          2 * index + side,\n          child.node,\n          child.angle,\n          wedge / 2,\n        );\n      }\n    }\n  };\n\n  walk(final, layers - 1, 0, nodes[0], HUB_ANGLE, 360);\n  return { nodes, links, champion };\n}\n\n/** Match ids from the hub down to the champion's first-round win. */\nfunction championPath(rounds: Round[]) {\n  const path = new Set<string>();\n  let index = 0;\n  for (let r = rounds.length - 1; r >= 0; r--) {\n    const match = rounds[r].matches[index];\n    if (!match?.winner) return path;\n    path.add(match.id);\n    if (r === 0) path.add(`${match.id}-${match.winner}`);\n    index = 2 * index + (match.winner === \"home\" ? 0 : 1);\n  }\n  return path;\n}\n\nfunction TeamMark({\n  node,\n  clipId,\n  lit,\n  dimmed,\n  loadFlag,\n  transition,\n}: {\n  node: WheelNode;\n  clipId: string;\n  lit: boolean;\n  dimmed: boolean;\n  loadFlag: boolean;\n  transition: object;\n}) {\n  // The failed URL, not a boolean: a corrected logo on the same node should be\n  // tried again rather than stay initials for the life of the wheel.\n  const [failedSrc, setFailedSrc] = useState<string | null>(null);\n  const resolved = node.team ? crestSrc(node.team) : null;\n  const src = resolved === failedSrc ? null : resolved;\n  const showFlag = src != null && loadFlag;\n  // A square logo is fitted whole; a 4:3 flag is cropped to fill the disc.\n  const box = node.team?.logo\n    ? { w: node.r * 1.44, h: node.r * 1.44, fit: \"xMidYMid meet\" }\n    : { w: node.r * 2.68, h: node.r * 2, fit: \"xMidYMid slice\" };\n  // Dimming rides on the mark itself rather than a scrim tinted with the page\n  // background, so the wheel recedes correctly on any surface it's dropped on.\n  const fade = { opacity: dimmed ? 0.38 : 1 };\n\n  return (\n    <>\n      {/* Stays opaque at every state: links are routed underneath and would\n          otherwise read straight through the flag. */}\n      <circle cx={node.x} cy={node.y} r={node.r} className=\"fill-card\" />\n      {showFlag && node.team ? (\n        <>\n          <clipPath id={clipId}>\n            <circle cx={node.x} cy={node.y} r={node.r} />\n          </clipPath>\n          {/* Plain <image> — flags from flagcdn.com, logos from wherever you host\n              them. A 4:3 flag is cropped to fill the disc; a logo is fitted whole\n              inside it, since a crest cropped to a circle loses its shape. */}\n          <motion.image\n            href={src}\n            x={node.x - box.w / 2}\n            y={node.y - box.h / 2}\n            width={box.w}\n            height={box.h}\n            clipPath={`url(#${clipId})`}\n            preserveAspectRatio={box.fit}\n            initial={false}\n            animate={fade}\n            transition={transition}\n            onError={() => setFailedSrc(src)}\n          />\n        </>\n      ) : node.team ? (\n        // No artwork on this team — initials keep the ring readable.\n        <motion.text\n          x={node.x}\n          y={node.y}\n          textAnchor=\"middle\"\n          dominantBaseline=\"central\"\n          fontSize={Math.max(node.r * 0.8, INITIALS_MIN)}\n          initial={false}\n          animate={fade}\n          transition={transition}\n          className=\"fill-muted-foreground font-semibold\"\n        >\n          {initials(node.team.name)}\n        </motion.text>\n      ) : (\n        // Same shield the knockout bracket uses for a TBD slot, so an\n        // undecided place reads identically across both fixture styles.\n        <motion.g initial={false} animate={fade} transition={transition}>\n          <Shield\n            x={node.x - node.r * 0.7}\n            y={node.y - node.r * 0.7}\n            width={node.r * 1.4}\n            height={node.r * 1.4}\n            className=\"fill-current text-muted-foreground/50\"\n          />\n        </motion.g>\n      )}\n      <circle\n        cx={node.x}\n        cy={node.y}\n        r={node.r}\n        fill=\"none\"\n        strokeWidth={lit ? 2 : 1}\n        className={lit ? \"stroke-foreground\" : \"stroke-border\"}\n      />\n    </>\n  );\n}\n\n/** Memoized so pointing at one flag re-renders two marks, not all 63. */\nconst WheelMark = memo(function WheelMark({\n  node,\n  isLit,\n  dimmed,\n  loadFlag,\n  reduce,\n  enter,\n  showTrophy,\n  clipId,\n}: {\n  node: WheelNode;\n  isLit: boolean;\n  dimmed: boolean;\n  loadFlag: boolean;\n  reduce: boolean;\n  enter: object;\n  showTrophy: boolean;\n  clipId: string;\n}) {\n  return (\n    <motion.g\n      initial={reduce ? false : { opacity: 0, scale: 0.6 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={enter}\n      style={{ transformOrigin: `${node.x}px ${node.y}px` }}\n    >\n      {showTrophy && (\n        <g\n          transform={`translate(${CENTER - TROPHY_SIZE / 2}, ${CENTER - HUB_R - TROPHY_GAP - TROPHY_SIZE})`}\n        >\n          <path d={TROPHY_PATH} className=\"fill-warning\" />\n        </g>\n      )}\n      <TeamMark\n        node={node}\n        clipId={clipId}\n        lit={isLit}\n        dimmed={dimmed}\n        loadFlag={loadFlag}\n        transition={reduce ? NO_TRANSITION : DIM_TRANSITION}\n      />\n    </motion.g>\n  );\n});\n\n/** Invisible hit area over one flag: hover, tap, focus and arrow keys. */\nconst WheelAnchor = memo(function WheelAnchor({\n  node,\n  caption,\n  isTabStop,\n  isPinned,\n  canHover,\n  uid,\n  onHover,\n  onFocusNode,\n  onToggle,\n  onKey,\n}: {\n  node: WheelNode;\n  caption: string;\n  isTabStop: boolean;\n  isPinned: boolean;\n  canHover: boolean;\n  uid: string;\n  onHover: (id: string | null) => void;\n  onFocusNode: (id: string | null) => void;\n  onToggle: (id: string) => void;\n  onKey: (node: WheelNode, key: string) => void;\n}) {\n  const size = pct(node.r * 2);\n  // Pointer and capture-phase focus props: Tooltip clones the child with\n  // onMouseEnter/onFocus, so those names would be overwritten.\n  const trigger = (\n    <button\n      type=\"button\"\n      id={`${uid}-${node.id}`}\n      tabIndex={isTabStop ? 0 : -1}\n      aria-label={caption}\n      onKeyDown={(event: KeyboardEvent) => {\n        if (!event.key.startsWith(\"Arrow\")) return;\n        // Arrows drive the wheel here, so they must not also scroll the page.\n        event.preventDefault();\n        onKey(node, event.key);\n      }}\n      onFocusCapture={() => onFocusNode(node.id)}\n      onBlurCapture={() => onFocusNode(null)}\n      onPointerEnter={canHover ? () => onHover(node.id) : undefined}\n      onPointerLeave={canHover ? () => onHover(null) : undefined}\n      // Click, not pointerdown: a tap focuses the button first, and unpinning\n      // has to also drop that focus or the flag stays lit.\n      onClick={\n        canHover\n          ? undefined\n          : (event) => {\n              onToggle(node.id);\n              if (isPinned) event.currentTarget.blur();\n            }\n      }\n      // ring-foreground, not the ring token: --ring is a 10% white hairline\n      // that disappears over a flag. Focus has to be obvious.\n      className=\"block h-full w-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n    />\n  );\n\n  return (\n    <div\n      className=\"absolute\"\n      style={{\n        left: pct(node.x - node.r),\n        top: pct(node.y - node.r),\n        width: size,\n        height: size,\n      }}\n    >\n      {/* Touch never opens a Tooltip, so those devices skip mounting one per\n          flag and read the tapped label instead. */}\n      {canHover ? (\n        <Tooltip\n          content={caption}\n          side=\"top\"\n          wrapperClassName=\"block h-full w-full\"\n          className=\"max-w-[20rem] whitespace-normal text-balance break-words text-center\"\n        >\n          {trigger}\n        </Tooltip>\n      ) : (\n        trigger\n      )}\n    </div>\n  );\n});\n\nexport function KnockoutWheel({\n  rounds,\n  initialRound = 0,\n  className,\n}: KnockoutWheelProps) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const uid = useId().replace(/:/g, \"\");\n  const ref = useRef<SVGSVGElement>(null);\n  // 63 flags for a 32-team draw, and SVG <image> has no lazy attribute — so the\n  // requests wait until the wheel is nearly on screen.\n  const inView = useInView(ref, { once: true, margin: \"300px\" });\n  // Hover and focus are tracked apart. Sharing one slot let a stray mouse move\n  // clear the isolation while a node still held focus.\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const active = hovered ?? focused;\n\n  // Everything downstream reads the trimmed catalog, so the kept round's own\n  // teams become the rim and the sr-only list matches what's drawn.\n  const visible = useMemo(() => {\n    const from = Math.min(Math.max(initialRound, 0), Math.max(rounds.length - 1, 0));\n    return from > 0 ? rounds.slice(from) : rounds;\n  }, [rounds, initialRound]);\n\n  const { nodes, links, champion } = useMemo(\n    () => buildWheel(visible),\n    [visible],\n  );\n  const winners = useMemo(() => championPath(visible), [visible]);\n  const activeNode = useMemo(\n    () => nodes.find((node) => node.id === active),\n    [active, nodes],\n  );\n  // Pointing at one flag isolates that flag. Only at rest does the wheel fall\n  // back to lighting the champion's whole run.\n  const lit = useMemo(\n    () => (activeNode ? new Set([activeNode.id]) : winners),\n    [activeNode, winners],\n  );\n\n  // Stable identity, or every mark re-renders on each pointer move.\n  const enter = useMemo(\n    () =>\n      reduce\n        ? { duration: 0, opacity: { duration: 0 } }\n        : { ...SPRING_PANEL, opacity: { duration: 0.24 } },\n    [reduce],\n  );\n\n  // One transition object per ring, cached, so the ring-by-ring entrance delay\n  // survives memoization instead of allocating 63 objects per render.\n  const enterFor = useMemo(() => {\n    const cache = new Map<number, object>();\n    return (depth: number) => {\n      const hit = cache.get(depth);\n      if (hit) return hit;\n      const value = { ...enter, delay: reduce ? 0 : depth * 0.06 };\n      cache.set(depth, value);\n      return value;\n    };\n  }, [enter, reduce]);\n\n  // Tooltip is hover-only by design, so touch gets the same label anchored to\n  // the tapped flag. It flips to the far side near the rim so it stays on stage.\n  const tapped = canHover ? undefined : activeNode;\n  const tapAbove = tapped ? tapped.y > CENTER : false;\n\n  // Arrow keys follow the geometry: up walks toward the hub, down walks out to\n  // a feeder, left/right go round the ring.\n  const { ring, firstChild } = useMemo(() => {\n    const byDepth = new Map<number, WheelNode[]>();\n    const child = new Map<string, WheelNode>();\n    for (const node of nodes) {\n      const peers = byDepth.get(node.depth) ?? [];\n      peers.push(node);\n      byDepth.set(node.depth, peers);\n      if (node.parentId && !child.has(node.parentId)) {\n        child.set(node.parentId, node);\n      }\n    }\n    for (const peers of byDepth.values()) {\n      peers.sort((a, b) => a.angle - b.angle);\n    }\n    return { ring: byDepth, firstChild: child };\n  }, [nodes]);\n\n  const tabStop = activeNode?.id ?? nodes[0]?.id;\n\n  // Stable callbacks so the memoized anchors don't re-render on every hover.\n  const onKey = useCallback(\n    (node: WheelNode, key: string) => {\n      if (!key.startsWith(\"Arrow\")) return;\n      const target =\n        key === \"ArrowUp\"\n          ? nodes.find((peer) => peer.id === node.parentId)\n          : key === \"ArrowDown\"\n            ? firstChild.get(node.id)\n            : (() => {\n                const peers = ring.get(node.depth) ?? [];\n                if (peers.length < 2) return undefined;\n                const at = peers.indexOf(node);\n                const next = key === \"ArrowRight\" ? at + 1 : at - 1;\n                return peers[(next + peers.length) % peers.length];\n              })();\n      if (!target) return;\n      // Focus moves; the focus handler lights the node it lands on.\n      document.getElementById(`${uid}-${target.id}`)?.focus();\n    },\n    [nodes, ring, firstChild, uid],\n  );\n\n  const onToggle = useCallback(\n    (id: string) => setHovered((current) => (current === id ? null : id)),\n    [],\n  );\n\n  // Round · teams · score for a decided match; a rim node is just a team. A slot\n  // whose team isn't known yet reads TBD, matching the shield drawn in its place.\n  const captions = useMemo(\n    () =>\n      new Map(\n        nodes.map((node) => [\n          node.id,\n          node.team == null\n            ? \"TBD\"\n            : node.round\n              ? `${keepTogether(node.round)} · ${node.label}`\n              : node.team.name,\n        ]),\n      ),\n    [nodes],\n  );\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-full overflow-x-auto overscroll-x-contain\",\n        className,\n      )}\n    >\n      {/* Below the min width the rim's marks collapse too small to tell apart or\n          tap, so the wheel holds its size and pans instead. The floor is fixed,\n          not rim-derived: node radius grows with depth, so a shallower draw has\n          *smaller* marks and needs the width more, not less. */}\n      <div className=\"relative mx-auto w-full min-w-[32rem] max-w-[34rem]\">\n        <svg\n          ref={ref}\n          viewBox={`0 0 ${SIZE} ${SIZE}`}\n          role=\"img\"\n          aria-label={`Tournament wheel${champion ? `, won by ${champion.name}` : \"\"}`}\n          className=\"h-auto w-full touch-manipulation\"\n        >\n          <defs>\n            {/* Warm halo marking the champion. Kept faint: --warning is saturated\n                enough that anything stronger drowns the connectors under it. */}\n            <radialGradient id={`${uid}-glow`}>\n              <stop offset=\"0%\" stopColor=\"var(--color-warning)\" stopOpacity=\"0.14\" />\n              <stop offset=\"45%\" stopColor=\"var(--color-warning)\" stopOpacity=\"0.04\" />\n              <stop offset=\"100%\" stopColor=\"var(--color-warning)\" stopOpacity=\"0\" />\n            </radialGradient>\n          </defs>\n\n          {champion && (\n            <circle\n              cx={CENTER}\n              cy={CENTER}\n              r={OUTER_R * 0.62}\n              fill={`url(#${uid}-glow)`}\n            />\n          )}\n\n          {/* role=\"img\" on the svg prunes descendants from the accessibility tree,\n              so the structure needs no aria-hidden of its own. */}\n          <g>\n            {links.map((link) => (\n              <motion.path\n                key={link.id}\n                d={link.d}\n                fill=\"none\"\n                initial={reduce ? false : { opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.3, delay: link.depth * 0.06 }}\n                className=\"stroke-border-strong\"\n              />\n            ))}\n          </g>\n\n          {nodes.map((node) => (\n            <WheelMark\n              key={node.id}\n              node={node}\n              isLit={lit.has(node.id)}\n              dimmed={lit.size > 0 && !lit.has(node.id)}\n              loadFlag={inView}\n              reduce={Boolean(reduce)}\n              enter={enterFor(node.depth)}\n              showTrophy={node.depth === 0 && champion != null}\n              clipId={`${uid}-clip-${node.id}`}\n            />\n          ))}\n        </svg>\n\n        {/* An SVG <g> can't anchor a Tooltip, so each flag gets an invisible\n            HTML hit area laid over it in percentage units, which track the\n            wheel as it scales. */}\n        {nodes.map((node) => (\n          <WheelAnchor\n            key={node.id}\n            node={node}\n            caption={captions.get(node.id) ?? \"\"}\n            isTabStop={node.id === tabStop}\n            isPinned={node.id === hovered}\n            canHover={canHover}\n            uid={uid}\n            onHover={setHovered}\n            onFocusNode={setFocused}\n            onToggle={onToggle}\n            onKey={onKey}\n          />\n        ))}\n\n        {tapped && (\n          <motion.p\n            initial={reduce ? false : { opacity: 0, scale: 0.94 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={reduce ? NO_TRANSITION : DIM_TRANSITION}\n            style={{\n              left: pct(tapped.x),\n              top: pct(tapped.y + (tapAbove ? -tapped.r : tapped.r)),\n            }}\n            className={cn(\n              \"pointer-events-none absolute z-10 w-max max-w-[min(18rem,80vw)] -translate-x-1/2 text-balance break-words rounded-lg border border-border bg-background px-2.5 py-1 text-center text-xs font-medium text-foreground shadow-lg\",\n              tapAbove ? \"-translate-y-[calc(100%+8px)]\" : \"translate-y-2\",\n            )}\n          >\n            {captions.get(tapped.id)}\n          </motion.p>\n        )}\n      </div>\n\n      {/* role=\"img\" prunes the svg's descendants, so every result is restated\n          here for screen readers. */}\n      {/* Named lists, not <section aria-label> — a named section is a landmark,\n          and five of those would crowd real page landmarks. */}\n      <div className=\"sr-only\">\n        {visible\n          .slice()\n          .reverse()\n          .map((round) => (\n            <ul key={round.name} aria-label={round.name}>\n              {round.matches.map((match) => (\n                <li key={match.id}>{matchLabel(match)}</li>\n              ))}\n            </ul>\n          ))}\n      </div>\n    </div>\n  );\n}\n\n// ── Sample data ──────────────────────────────────────────────────────────────\n// A finished 32-team cup, here to demo the shape. Swap it for your own\n// tournament. Rounds run widest first and each holds half as many matches as the\n// one before it (16 → 8 → 4 → 2 → 1); `matches[k]` of a round is fed by matches\n// `2k` and `2k + 1` of the round before it, which is what pairs the branches.\n// Any draw works: pass fewer rounds for a smaller cup, give teams a `logo`\n// instead of a country `code`, or neither for initials. The knockout bracket\n// takes the same array, so one dataset feeds both fixture styles.\n\nexport const TEAMS = {\n  spain: { name: \"Spain\", code: \"es\" },\n  japan: { name: \"Japan\", code: \"jp\" },\n  netherlands: { name: \"Netherlands\", code: \"nl\" },\n  portugal: { name: \"Portugal\", code: \"pt\" },\n  england: { name: \"England\", code: \"gb-eng\" },\n  uruguay: { name: \"Uruguay\", code: \"uy\" },\n  croatia: { name: \"Croatia\", code: \"hr\" },\n  brazil: { name: \"Brazil\", code: \"br\" },\n  france: { name: \"France\", code: \"fr\" },\n  morocco: { name: \"Morocco\", code: \"ma\" },\n  belgium: { name: \"Belgium\", code: \"be\" },\n  italy: { name: \"Italy\", code: \"it\" },\n  argentina: { name: \"Argentina\", code: \"ar\" },\n  mexico: { name: \"Mexico\", code: \"mx\" },\n  germany: { name: \"Germany\", code: \"de\" },\n  norway: { name: \"Norway\", code: \"no\" },\n  costaRica: { name: \"Costa Rica\", code: \"cr\" },\n  serbia: { name: \"Serbia\", code: \"rs\" },\n  ecuador: { name: \"Ecuador\", code: \"ec\" },\n  ghana: { name: \"Ghana\", code: \"gh\" },\n  wales: { name: \"Wales\", code: \"gb-wls\" },\n  canada: { name: \"Canada\", code: \"ca\" },\n  denmark: { name: \"Denmark\", code: \"dk\" },\n  cameroon: { name: \"Cameroon\", code: \"cm\" },\n  poland: { name: \"Poland\", code: \"pl\" },\n  senegal: { name: \"Senegal\", code: \"sn\" },\n  tunisia: { name: \"Tunisia\", code: \"tn\" },\n  switzerland: { name: \"Switzerland\", code: \"ch\" },\n  peru: { name: \"Peru\", code: \"pe\" },\n  qatar: { name: \"Qatar\", code: \"qa\" },\n  sweden: { name: \"Sweden\", code: \"se\" },\n  austria: { name: \"Austria\", code: \"at\" },\n} satisfies Record<string, Team>;\n\nexport const ROUNDS: Round[] = [\n  {\n    name: \"Round of 32\",\n    matches: [\n      {\n        id: \"w-r32-1\",\n        home: { team: TEAMS.spain, score: 3 },\n        away: { team: TEAMS.costaRica, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-2\",\n        home: { team: TEAMS.japan, score: 2 },\n        away: { team: TEAMS.serbia, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-3\",\n        home: { team: TEAMS.netherlands, score: 2 },\n        away: { team: TEAMS.ecuador, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-4\",\n        home: { team: TEAMS.ghana, score: 2 },\n        away: { team: TEAMS.portugal, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r32-5\",\n        home: { team: TEAMS.england, score: 4 },\n        away: { team: TEAMS.wales, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-6\",\n        home: { team: TEAMS.canada, score: 0 },\n        away: { team: TEAMS.uruguay, score: 2 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r32-7\",\n        home: { team: TEAMS.croatia, score: 1 },\n        away: { team: TEAMS.denmark, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-8\",\n        home: { team: TEAMS.brazil, score: 3 },\n        away: { team: TEAMS.cameroon, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-9\",\n        home: { team: TEAMS.france, score: 2 },\n        away: { team: TEAMS.poland, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-10\",\n        home: { team: TEAMS.senegal, score: 0 },\n        away: { team: TEAMS.morocco, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r32-11\",\n        home: { team: TEAMS.belgium, score: 2 },\n        away: { team: TEAMS.tunisia, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-12\",\n        home: { team: TEAMS.switzerland, score: 1 },\n        away: { team: TEAMS.italy, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r32-13\",\n        home: { team: TEAMS.argentina, score: 2 },\n        away: { team: TEAMS.peru, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-14\",\n        home: { team: TEAMS.qatar, score: 0 },\n        away: { team: TEAMS.mexico, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r32-15\",\n        home: { team: TEAMS.germany, score: 4 },\n        away: { team: TEAMS.sweden, score: 2 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r32-16\",\n        home: { team: TEAMS.austria, score: 1 },\n        away: { team: TEAMS.norway, score: 2 },\n        winner: \"away\",\n      },\n    ],\n  },\n  {\n    name: \"Round of 16\",\n    matches: [\n      {\n        id: \"w-r16-1\",\n        home: { team: TEAMS.spain, score: 2 },\n        away: { team: TEAMS.japan, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r16-2\",\n        home: { team: TEAMS.netherlands, score: 1 },\n        away: { team: TEAMS.portugal, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r16-3\",\n        home: { team: TEAMS.england, score: 2 },\n        away: { team: TEAMS.uruguay, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r16-4\",\n        home: { team: TEAMS.croatia, score: 0 },\n        away: { team: TEAMS.brazil, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r16-5\",\n        home: { team: TEAMS.france, score: 3 },\n        away: { team: TEAMS.morocco, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r16-6\",\n        home: { team: TEAMS.belgium, score: 1 },\n        away: { team: TEAMS.italy, score: 2 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-r16-7\",\n        home: { team: TEAMS.argentina, score: 2 },\n        away: { team: TEAMS.mexico, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-r16-8\",\n        home: { team: TEAMS.germany, score: 1, penalties: 4 },\n        away: { team: TEAMS.norway, score: 1, penalties: 2 },\n        winner: \"home\",\n      },\n    ],\n  },\n  {\n    name: \"Quarter-finals\",\n    matches: [\n      {\n        id: \"w-qf-1\",\n        home: { team: TEAMS.spain, score: 1 },\n        away: { team: TEAMS.portugal, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-qf-2\",\n        home: { team: TEAMS.england, score: 2 },\n        away: { team: TEAMS.brazil, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"w-qf-3\",\n        home: { team: TEAMS.france, score: 2 },\n        away: { team: TEAMS.italy, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-qf-4\",\n        home: { team: TEAMS.argentina, score: 3 },\n        away: { team: TEAMS.germany, score: 1 },\n        winner: \"home\",\n      },\n    ],\n  },\n  {\n    name: \"Semi-finals\",\n    matches: [\n      {\n        id: \"w-sf-1\",\n        home: { team: TEAMS.spain, score: 2 },\n        away: { team: TEAMS.brazil, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"w-sf-2\",\n        home: { team: TEAMS.france, score: 0, penalties: 3 },\n        away: { team: TEAMS.argentina, score: 0, penalties: 4 },\n        winner: \"away\",\n      },\n    ],\n  },\n  {\n    name: \"Final\",\n    matches: [\n      {\n        id: \"w-f-1\",\n        home: { team: TEAMS.spain, score: 2 },\n        away: { team: TEAMS.argentina, score: 1 },\n        winner: \"home\",\n      },\n    ],\n  },\n];\n"},{"path":"components/motion/tooltip.tsx","type":"registry:component","target":"@components/motion/tooltip.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children: ReactElement;\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// 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\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        type: \"spring\",\n        stiffness: 380,\n        damping: 30,\n        mass: 0.7,\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// 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}: TooltipProps) {\n  const [open, setOpen] = useState(false);\n  const [coords, setCoords] = useState<{ top: number; left: number } | null>(\n    null,\n  );\n  const id = useId();\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const anchorRef = useRef<HTMLSpanElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\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 / 2;\n    const cy = r.top + r.height / 2;\n    const point: Record<Side, { top: number; left: number }> = {\n      top: { top: r.top - GAP, left: cx },\n      bottom: { top: r.bottom + GAP, left: cx },\n      left: { top: cy, left: r.left - GAP },\n      right: { top: cy, left: r.right + GAP },\n    };\n    setCoords(point[side]);\n  }, [side]);\n\n  const show = useCallback(() => {\n    if (!canHover) return;\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  }, [canHover, delay, place]);\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]);\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  const variants = useMemo(\n    () => (reduce ? REDUCED_VARIANTS : buildVariants(side)),\n    [reduce, side],\n  );\n\n  if (!isValidElement(children)) return children;\n\n  const trigger = cloneElement(\n    children as ReactElement<Record<string, unknown>>,\n    {\n      onMouseEnter: show,\n      onMouseLeave: hide,\n      onFocus: show,\n      onBlur: hide,\n      \"aria-describedby\": id,\n    },\n  );\n\n  return (\n    <>\n      <span\n        ref={anchorRef}\n        className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n      >\n        {trigger}\n      </span>\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open && coords ? (\n                <span\n                  aria-hidden\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                  <motion.span\n                    id={id}\n                    role=\"tooltip\"\n                    variants={variants}\n                    initial=\"initial\"\n                    animate=\"animate\"\n                    exit=\"exit\"\n                    style={{ transformOrigin: transformOrigin[side] }}\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                  >\n                    {content}\n                  </motion.span>\n                </span>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/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":"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"}]}