{"slug":"knockout-bracket","name":"Fixtures","description":"Animated tournament fixtures in two styles: a knockout bracket that pages through rounds, and a wheel that wraps the same tree around the champion. Both read the same array of rounds, so one dataset draws either.","category":"blocks","source_url":"https://beui.dev/r/knockout-bracket/raw","detail_url":"https://beui.dev/r/knockout-bracket","raw_url":"https://beui.dev/r/knockout-bracket/raw","page_url":"https://beui.dev/components/blocks/knockout-bracket","markdown_url":"https://beui.dev/components/blocks/knockout-bracket.md","published_at":"2026-07-12","updated_at":"2026-07-27","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/knockout-bracket","@/components/motion/tooltip","@/lib/ease","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-capable","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-tap-gesture","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/knockout-bracket.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/knockout-bracket\n\nimport { ChevronLeft, ChevronRight, Shield } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useMemo, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\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 card surface, so a transparent-background mark inked for\n   * one theme disappears in the other: ship artwork that reads on both, or pick\n   * 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  /** null renders a TBD slot with a shield icon. */\n  team: Team | null;\n  score: number | null;\n  /** Present on both sides to render Google-style shootout scores — 1 (3). */\n  penalties?: number | null;\n};\n\nexport type Match = {\n  id: string;\n  /** Kick-off day, already formatted. Omit it and the card drops the date row. */\n  date?: string;\n  time?: string;\n  /** Omit it and a match with a `winner` counts as finished. */\n  status?: \"finished\" | \"upcoming\";\n  home: MatchSide;\n  away: MatchSide;\n  /** Decides the marker and which side dims. */\n  winner?: \"home\" | \"away\";\n  /** Replaces the derived result chip (\"FT\", \"FT (P)\") — e.g. \"AET\", \"BO5\", \"Forfeit\". */\n  badge?: string;\n};\n\nexport type Round = {\n  /** Shown as the column header — \"Round of 32\", \"Upper bracket final\", \"Last 8\". */\n  name: string;\n  matches: Match[];\n};\n\nexport interface KnockoutBracketProps {\n  /**\n   * The whole draw, ordered widest round first. Any single-elimination\n   * tournament fits: each round holds half the matches of the one before it\n   * (16 → 8 → 4 → 2 → 1) and `rounds[r].matches[k]` is fed by matches `2k` and\n   * `2k + 1` of the round before it. Two rounds are enough.\n   */\n  rounds: Round[];\n  /** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */\n  initialRound?: number;\n  /** Third place play-off, rendered under the bracket instead of inside it. */\n  thirdPlace?: Match;\n  /** Heading over `thirdPlace`. Defaults to \"Third place play-off\". */\n  thirdPlaceLabel?: string;\n  className?: string;\n}\n\n\n// Card geometry drives the whole computed layout — every later match sits at the\n// exact vertical midpoint of its two feeders, so pairs line up with connectors.\n// Keep CARD_H in sync with the card's internal spacing.\nconst CARD_W = 250;\nconst CARD_H = 124;\n// Pocket (20) + stem (20) — matches the CSS `]` connector geometry.\nconst GAP_X = 40;\nconst GAP_Y = 20;\nconst COL_W = CARD_W + GAP_X;\nconst ROW = CARD_H + GAP_Y;\nconst VISIBLE_COLS = 3;\nconst CONNECTOR_POCKET = 20;\nconst CONNECTOR_STEM = GAP_X - CONNECTOR_POCKET;\n// Tall enough for 44px chevron hit areas without clipping the focus ring.\nconst HEADER_H = 44;\n// Breathing room baked into the computed layout so the base column isn't flush\n// against the clip edge and connector nubs aren't shaved off.\nconst PAD_X = 8;\nconst PAD_Y = 12;\n\n// Firmer than SPRING_LAYOUT so the many cards, connectors and stage height\n// glide as one piece; damping just over critical (~1.05) settles with no bounce\n// and no lazy overdamped tail.\nconst REFLOW = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 32,\n  mass: 0.9,\n} as const;\n\n// Opacity cross-fades a touch ahead of the position spring so columns don't\n// ghost while sliding.\nconst REFLOW_OPACITY = {\n  duration: 0.28,\n  ease: EASE_OUT,\n} as const;\n\nconst clamp = (n: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, n));\n\n// Column x-offset and window test — shared by the render pass and the memoized\n// layout so the two can't drift. Module-level (stable identity) so the layout\n// memo can call them without widening its dependency list.\nconst colX = (r: number, page: number) => PAD_X + (r - page) * COL_W;\nconst isInWindow = (r: number, page: number, visibleCols: number) =>\n  r >= page && r < page + visibleCols;\n\ntype Connector = {\n  key: string;\n  /** Feeder card right edge — left of the `]` pocket. */\n  x: number;\n  /** Top feeder center Y. */\n  y: number;\n  /** Distance between the two feeder centers. */\n  height: number;\n  visible: boolean;\n};\n\n// CSS `]` pocket + stem: border-y/border-r + a hairline to the child.\n// Transform/opacity only — no SVG path morph, so paging stays flicker-free.\nfunction BracketConnector({\n  connector,\n  transition,\n}: {\n  connector: Connector;\n  transition: object;\n}) {\n  const { x, y, height, visible } = connector;\n  const geo = visible\n    ? transition\n    : {\n        ...transition,\n        x: { duration: 0 },\n        y: { duration: 0 },\n        height: { duration: 0 },\n      };\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      initial={false}\n      animate={{ x, y, height, opacity: visible ? 1 : 0 }}\n      transition={geo}\n      className=\"pointer-events-none absolute left-0 top-0 rounded-r-xl border-y border-r border-border\"\n      style={{ width: CONNECTOR_POCKET, willChange: \"transform\" }}\n    >\n      <span\n        className=\"absolute left-full top-1/2 h-px bg-border\"\n        style={{ width: CONNECTOR_STEM }}\n      />\n    </motion.div>\n  );\n}\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// Fixed 28px slot whatever it holds, so names and scores stay aligned down the\n// column: flag, crest, initials or the TBD shield.\nfunction TeamCrest({ team }: { team: Team | null }) {\n  // The failed URL, not a boolean: a corrected logo on the same match should be\n  // tried again rather than stay initials for the life of the card.\n  const [failedSrc, setFailedSrc] = useState<string | null>(null);\n  const resolved = team ? crestSrc(team) : null;\n  const src = resolved === failedSrc ? null : resolved;\n\n  return (\n    <span className=\"flex h-5 w-7 shrink-0 items-center justify-center\">\n      {src ? (\n        // Plain <img> — flags come from flagcdn.com, logos from wherever you host them.\n        // biome-ignore lint/performance/noImgElement: remote asset, no next/image benefit\n        <img\n          src={src}\n          alt=\"\"\n          loading=\"lazy\"\n          draggable={false}\n          onError={() => setFailedSrc(src)}\n          className={cn(\n            \"shrink-0 rounded-[4px] border border-border/40\",\n            // Flags are 4:3 and fill the slot; a logo keeps its own shape inside it.\n            team?.logo\n              ? \"h-5 w-5 border-transparent object-contain\"\n              : \"h-5 w-7 object-cover\",\n          )}\n        />\n      ) : team ? (\n        // text-foreground, not muted: the /10 tint lifts the disc toward the\n        // muted ramp, leaving 4.44:1 light and 3.55:1 dark — both under AA.\n        <span className=\"grid size-5 place-items-center rounded-full bg-foreground/10 text-[10px] font-semibold leading-none text-foreground\">\n          {initials(team.name)}\n        </span>\n      ) : (\n        <Shield className=\"size-5 fill-current text-muted-foreground/50\" />\n      )}\n    </span>\n  );\n}\n\nfunction WinnerMarker() {\n  return (\n    <svg\n      viewBox=\"0 0 6 8\"\n      aria-hidden=\"true\"\n      className=\"h-2 w-1.5 shrink-0 fill-foreground\"\n    >\n      <path d=\"M6 0 0 4 6 8Z\" />\n    </svg>\n  );\n}\n\nfunction TeamRow({\n  side,\n  isWinner,\n  decided,\n}: {\n  side: MatchSide;\n  isWinner: boolean;\n  decided: boolean;\n}) {\n  const dim = decided && !isWinner;\n  return (\n    <div className=\"flex items-center gap-3\">\n      <TeamCrest team={side.team} />\n      <span\n        className={cn(\n          \"min-w-0 flex-1 truncate text-base font-medium\",\n          dim && \"text-muted-foreground\",\n        )}\n      >\n        {side.team?.name ?? \"TBD\"}\n      </span>\n      {side.score != null && (\n        <span\n          className={cn(\n            \"shrink-0 text-base font-medium tabular-nums\",\n            dim && \"text-muted-foreground\",\n          )}\n        >\n          {side.penalties != null\n            ? `${side.score} (${side.penalties})`\n            : side.score}\n        </span>\n      )}\n      {/* Fixed 6px marker slot keeps every score right-aligned; the winner's\n          triangle fills it, losers reserve it empty. */}\n      <span className=\"flex w-1.5 shrink-0 items-center\">\n        {isWinner && <WinnerMarker />}\n      </span>\n    </div>\n  );\n}\n\nfunction sideLabel(side: MatchSide) {\n  const name = side.team?.name ?? \"TBD\";\n  if (side.score == null) return name;\n  const pen =\n    side.penalties != null ? ` (${side.penalties} on penalties)` : \"\";\n  return `${name} ${side.score}${pen}`;\n}\n\n/** `status` is optional, so a decided match reads as finished without it. */\nconst isFinished = (m: Match) =>\n  m.status ? m.status === \"finished\" : m.winner != null;\n\nfunction matchLabel(roundName: string, m: Match) {\n  const finished = isFinished(m);\n  const sides = finished\n    ? `${sideLabel(m.home)}, ${sideLabel(m.away)}`\n    : `${sideLabel(m.home)} versus ${sideLabel(m.away)}`;\n  // Same pair the card's header row shows, so a time-only match isn't announced\n  // without its kick-off.\n  const schedule = finished ? [] : [m.date, m.time].filter(Boolean);\n  const when = schedule.length ? `, ${schedule.join(\", \")}` : \"\";\n  const winnerName = m.winner ? m[m.winner].team?.name : undefined;\n  const outcome = winnerName ? `, ${winnerName} won` : \"\";\n  return `${roundName}: ${sides}${when}${outcome}`;\n}\n\nfunction MatchCard({ match }: { match: Match }) {\n  const finished = isFinished(match);\n  const decided = finished && match.winner != null;\n  const shootout =\n    match.home.penalties != null || match.away.penalties != null;\n  // A per-match `badge` wins, so a draw that isn't football can label its own\n  // result (\"AET\", \"BO5\", \"Forfeit\") instead of the derived full-time chip.\n  const badge = match.badge ?? (finished ? (shootout ? \"FT (P)\" : \"FT\") : null);\n\n  return (\n    <div\n      style={{ width: CARD_W, height: CARD_H }}\n      className=\"rounded-2xl border border-border bg-card p-4\"\n    >\n      {/* h-5 holds the row open when a match carries no date or badge, so a\n          dateless draw's cards don't sit top-heavy inside the fixed CARD_H. */}\n      <div className=\"mb-3 flex h-5 items-center justify-between gap-2\">\n        <span className=\"min-w-0 flex-1 truncate text-sm leading-5 text-muted-foreground\">\n          {[match.date, match.time].filter(Boolean).join(\", \")}\n        </span>\n        {badge && (\n          <span className=\"shrink-0 rounded-full bg-background px-2.5 text-xs font-medium leading-5 text-muted-foreground\">\n            {badge}\n          </span>\n        )}\n      </div>\n      <div className=\"space-y-2.5\">\n        <TeamRow\n          side={match.home}\n          decided={decided}\n          isWinner={decided && match.winner === \"home\"}\n        />\n        <TeamRow\n          side={match.away}\n          decided={decided}\n          isWinner={decided && match.winner === \"away\"}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function KnockoutBracket({\n  rounds,\n  initialRound = 1,\n  thirdPlace,\n  thirdPlaceLabel = \"Third place play-off\",\n  className,\n}: KnockoutBracketProps) {\n  const reduce = useReducedMotion();\n  const visibleCols = Math.min(VISIBLE_COLS, rounds.length);\n  // The last page shows the final two rounds (semi-finals + final), not a full\n  // window — so paging continues past the QF/SF/Final view down to SF + Final.\n  const maxPage = Math.max(0, rounds.length - Math.min(2, rounds.length));\n  const [page, setPage] = useState(() => clamp(initialRound, 0, maxPage));\n\n  // Shared reflow — cards, connectors, headers and stage height page together.\n  // Height springs with the same token (layout morph is the product feel for\n  // collapsing rounds); opacity uses a short ease so fades don't lag the glide.\n  const transition = reduce\n    ? { duration: 0 }\n    : { ...REFLOW, opacity: REFLOW_OPACITY };\n\n  const pageStatus = useMemo(() => {\n    const names = rounds\n      .slice(page, page + visibleCols)\n      .map((round) => round.name);\n    if (names.length <= 1) return `Showing ${names[0] ?? \"rounds\"}`;\n    if (names.length === 2) return `Showing ${names[0]} and ${names[1]}`;\n    return `Showing ${names.slice(0, -1).join(\", \")}, and ${names.at(-1)}`;\n  }, [rounds, page, visibleCols]);\n\n  // Layout is computed, not scrolled. The leftmost visible round (`page`) is the\n  // base and stacks at a fixed rhythm; every later match centers on its feeders,\n  // and behind rounds spread out (below). Cards and connectors derive from one\n  // pass and page together under the shared transition.\n  const { cy, containerHeight, connectors } = useMemo(() => {\n    const centers: number[][] = new Array(rounds.length);\n    const base = rounds[page];\n    centers[page] = base.matches.map((_, i) => PAD_Y + i * ROW + CARD_H / 2);\n    for (let r = page + 1; r < rounds.length; r++) {\n      const feeders = centers[r - 1];\n      const row: number[] = [];\n      for (let k = 0; k < rounds[r].matches.length; k++) {\n        const top = feeders[2 * k];\n        if (top == null) {\n          // A round with more matches than its feeders allow (an odd draw, a bye\n          // left out) stacks a full row under the last card placed in this\n          // round — a fixed rhythm from the top can land on top of a midpoint.\n          const prev = row[k - 1];\n          row[k] = prev == null ? PAD_Y + CARD_H / 2 : prev + ROW;\n        } else {\n          row[k] = (top + (feeders[2 * k + 1] ?? top)) / 2;\n        }\n      }\n      centers[r] = row;\n    }\n    // Behind rounds keep their natural spread (spacing halves each step out,\n    // each match straddling its parent) instead of collapsing, so paging back\n    // slides a formed column in from the left just as paging forward does.\n    for (let r = page - 1; r >= 0; r--) {\n      const half = ROW / 2 ** (page - r + 1);\n      centers[r] = rounds[r].matches.map((_, i) => {\n        const parent = centers[r + 1][Math.floor(i / 2)] ?? PAD_Y;\n        return parent + (i % 2 === 0 ? -half : half);\n      });\n    }\n\n    const list: Connector[] = [];\n    for (let r = 1; r < rounds.length; r++) {\n      const feederRight = colX(r - 1, page) + CARD_W;\n      const visible =\n        isInWindow(r, page, visibleCols) &&\n        isInWindow(r - 1, page, visibleCols);\n      rounds[r].matches.forEach((_, k) => {\n        const yTop = centers[r - 1][2 * k] ?? centers[r][k];\n        const yBot = centers[r - 1][2 * k + 1] ?? yTop;\n        list.push({\n          key: `${r}-${k}`,\n          x: feederRight,\n          y: yTop,\n          height: Math.max(0, yBot - yTop),\n          visible,\n        });\n      });\n    }\n\n    // Measured, not derived from the base count: a fallback-stacked round can\n    // run past the base column, and the stage clips its overflow.\n    // Seeded with one card's center so an empty round yields a real height\n    // rather than -Infinity.\n    const lowest = Math.max(\n      PAD_Y + CARD_H / 2,\n      ...centers.slice(page, page + visibleCols).flat(),\n    );\n    return {\n      cy: centers,\n      containerHeight: lowest + CARD_H / 2 + PAD_Y,\n      connectors: list,\n    };\n  }, [rounds, page, visibleCols]);\n\n  const containerWidth =\n    visibleCols * CARD_W + (visibleCols - 1) * GAP_X + 2 * PAD_X;\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      <section\n        aria-label=\"Tournament bracket\"\n        className=\"relative mx-auto\"\n        style={{ width: containerWidth }}\n      >\n        <div className=\"sr-only\" aria-live=\"polite\">\n          {pageStatus}\n        </div>\n\n        {/* Only the gliding round titles are clipped (they enter/exit at the\n            canvas edge); the chevron buttons sit outside that clip. */}\n        <div className=\"relative\" style={{ height: HEADER_H }}>\n          <div className=\"absolute inset-0 overflow-hidden\">\n            {rounds.map((round, r) => (\n              <motion.div\n                key={round.name}\n                aria-hidden={isInWindow(r, page, visibleCols) ? undefined : true}\n                initial={false}\n                animate={{\n                  x: colX(r, page),\n                  opacity: isInWindow(r, page, visibleCols) ? 1 : 0,\n                }}\n                transition={transition}\n                className=\"absolute left-0 top-0 flex h-full items-center justify-center text-sm font-bold text-foreground\"\n                style={{ width: CARD_W }}\n              >\n                {round.name}\n              </motion.div>\n            ))}\n          </div>\n          {page > 0 && (\n            <button\n              type=\"button\"\n              onClick={() => setPage((p) => clamp(p - 1, 0, maxPage))}\n              aria-label=\"Previous round\"\n              // Inset by PAD_X so the hover fill clears the scroll clip; 44px\n              // button is the tap target, the inner circle the visible affordance.\n              style={{ left: PAD_X }}\n              className=\"group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none\"\n            >\n              <span className=\"grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring\">\n                <ChevronLeft className=\"size-5\" />\n              </span>\n            </button>\n          )}\n          {page < maxPage && (\n            <button\n              type=\"button\"\n              onClick={() => setPage((p) => clamp(p + 1, 0, maxPage))}\n              aria-label=\"Next round\"\n              style={{ right: PAD_X }}\n              className=\"group absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full outline-none\"\n            >\n              <span className=\"grid size-9 place-items-center rounded-full text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground group-focus-visible:ring-2 group-focus-visible:ring-ring\">\n                <ChevronRight className=\"size-5\" />\n              </span>\n            </button>\n          )}\n        </div>\n\n        {/* Stage height springs with REFLOW so the bracket collapses as one\n            piece with the cards — layout property is intentional here. */}\n        <motion.div\n          className=\"relative overflow-hidden\"\n          initial={false}\n          animate={{ height: containerHeight }}\n          transition={transition}\n          style={{ width: containerWidth }}\n        >\n          {connectors.map((c) => (\n            <BracketConnector\n              key={c.key}\n              connector={c}\n              transition={transition}\n            />\n          ))}\n\n          {rounds.map((round, r) => {\n            const roundVisible = isInWindow(r, page, visibleCols);\n            return (\n              <ul\n                key={round.name}\n                aria-label={round.name}\n                aria-hidden={roundVisible ? undefined : true}\n                className=\"m-0 list-none p-0\"\n              >\n                {round.matches.map((match, k) => (\n                  <motion.li\n                    key={match.id}\n                    aria-label={matchLabel(round.name, match)}\n                    initial={false}\n                    animate={{\n                      x: colX(r, page),\n                      y: cy[r][k] - CARD_H / 2,\n                      opacity: roundVisible ? 1 : 0,\n                    }}\n                    transition={transition}\n                    className=\"absolute left-0 top-0\"\n                    style={{ willChange: \"transform\" }}\n                  >\n                    <MatchCard match={match} />\n                  </motion.li>\n                ))}\n              </ul>\n            );\n          })}\n        </motion.div>\n\n        {/* Outside the bracket stage — it feeds off the semi-finals rather than\n            into the final, so it gets its own rule instead of a column. */}\n        {thirdPlace && (\n          <div\n            className=\"mt-8 border-t border-border pt-6\"\n            style={{ paddingLeft: PAD_X }}\n          >\n            <ul aria-label={thirdPlaceLabel} className=\"m-0 list-none p-0\">\n              <li aria-label={matchLabel(thirdPlaceLabel, thirdPlace)}>\n                <p className=\"mb-2 text-sm leading-5 text-muted-foreground/70\">\n                  {thirdPlaceLabel}\n                </p>\n                <MatchCard match={thirdPlace} />\n              </li>\n            </ul>\n          </div>\n        )}\n      </section>\n    </div>\n  );\n}\n\n// ── Sample data ──────────────────────────────────────────────────────────────\n// A full World Cup knockout stage, 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 connectors.\n// Any draw works: start at the round you have (Round of 16, quarter-finals),\n// give teams a `logo` instead of a country `code`, or neither for initials.\n\nexport const TEAMS = {\n  southAfrica: { name: \"South Africa\", code: \"za\" },\n  canada: { name: \"Canada\", code: \"ca\" },\n  netherlands: { name: \"Netherlands\", code: \"nl\" },\n  morocco: { name: \"Morocco\", code: \"ma\" },\n  germany: { name: \"Germany\", code: \"de\" },\n  paraguay: { name: \"Paraguay\", code: \"py\" },\n  france: { name: \"France\", code: \"fr\" },\n  sweden: { name: \"Sweden\", code: \"se\" },\n  belgium: { name: \"Belgium\", code: \"be\" },\n  senegal: { name: \"Senegal\", code: \"sn\" },\n  usa: { name: \"USA\", code: \"us\" },\n  bosnia: { name: \"Bosnia and Herzegovina\", code: \"ba\" },\n  spain: { name: \"Spain\", code: \"es\" },\n  austria: { name: \"Austria\", code: \"at\" },\n  portugal: { name: \"Portugal\", code: \"pt\" },\n  croatia: { name: \"Croatia\", code: \"hr\" },\n  brazil: { name: \"Brazil\", code: \"br\" },\n  japan: { name: \"Japan\", code: \"jp\" },\n  ivoryCoast: { name: \"Côte d'Ivoire\", code: \"ci\" },\n  norway: { name: \"Norway\", code: \"no\" },\n  mexico: { name: \"Mexico\", code: \"mx\" },\n  ecuador: { name: \"Ecuador\", code: \"ec\" },\n  england: { name: \"England\", code: \"gb-eng\" },\n  drCongo: { name: \"DR Congo\", code: \"cd\" },\n  switzerland: { name: \"Switzerland\", code: \"ch\" },\n  algeria: { name: \"Algeria\", code: \"dz\" },\n  colombia: { name: \"Colombia\", code: \"co\" },\n  ghana: { name: \"Ghana\", code: \"gh\" },\n  australia: { name: \"Australia\", code: \"au\" },\n  egypt: { name: \"Egypt\", code: \"eg\" },\n  argentina: { name: \"Argentina\", code: \"ar\" },\n  caboVerde: { name: \"Cabo Verde\", code: \"cv\" },\n} satisfies Record<string, Team>;\n\nexport const ROUNDS: Round[] = [\n  {\n    name: \"Round of 32\",\n    matches: [\n      {\n        id: \"r32-1\",\n        date: \"Mon, 29 Jun\",\n        status: \"finished\",\n        home: { team: TEAMS.southAfrica, score: 0 },\n        away: { team: TEAMS.canada, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"r32-2\",\n        date: \"Tue, 30 Jun\",\n        status: \"finished\",\n        home: { team: TEAMS.netherlands, score: 1, penalties: 2 },\n        away: { team: TEAMS.morocco, score: 1, penalties: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"r32-3\",\n        date: \"Tue, 30 Jun\",\n        status: \"finished\",\n        home: { team: TEAMS.germany, score: 1, penalties: 3 },\n        away: { team: TEAMS.paraguay, score: 1, penalties: 4 },\n        winner: \"away\",\n      },\n      {\n        id: \"r32-4\",\n        date: \"Wed, 1 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.france, score: 3 },\n        away: { team: TEAMS.sweden, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-5\",\n        date: \"Thu, 2 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.belgium, score: 3 },\n        away: { team: TEAMS.senegal, score: 2 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-6\",\n        date: \"Thu, 2 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.usa, score: 2 },\n        away: { team: TEAMS.bosnia, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-7\",\n        date: \"Fri, 3 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.spain, score: 3 },\n        away: { team: TEAMS.austria, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-8\",\n        date: \"Fri, 3 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.portugal, score: 2 },\n        away: { team: TEAMS.croatia, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-9\",\n        date: \"Mon, 29 Jun\",\n        status: \"finished\",\n        home: { team: TEAMS.brazil, score: 2 },\n        away: { team: TEAMS.japan, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-10\",\n        date: \"Tue, 30 Jun\",\n        status: \"finished\",\n        home: { team: TEAMS.ivoryCoast, score: 1 },\n        away: { team: TEAMS.norway, score: 2 },\n        winner: \"away\",\n      },\n      {\n        id: \"r32-11\",\n        date: \"Wed, 1 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.mexico, score: 2 },\n        away: { team: TEAMS.ecuador, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-12\",\n        date: \"Wed, 1 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.england, score: 2 },\n        away: { team: TEAMS.drCongo, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-13\",\n        date: \"Fri, 3 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.switzerland, score: 2 },\n        away: { team: TEAMS.algeria, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-14\",\n        date: \"Sat, 4 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.colombia, score: 1 },\n        away: { team: TEAMS.ghana, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"r32-15\",\n        date: \"Fri, 3 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.australia, score: 1, penalties: 2 },\n        away: { team: TEAMS.egypt, score: 1, penalties: 4 },\n        winner: \"away\",\n      },\n      {\n        id: \"r32-16\",\n        date: \"Sat, 4 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.argentina, score: 3 },\n        away: { team: TEAMS.caboVerde, score: 2 },\n        winner: \"home\",\n      },\n    ],\n  },\n  {\n    name: \"Round of 16\",\n    matches: [\n      {\n        id: \"r16-1\",\n        date: \"Sat, 4 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.canada, score: 0 },\n        away: { team: TEAMS.morocco, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-2\",\n        date: \"Sun, 5 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.paraguay, score: 0 },\n        away: { team: TEAMS.france, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-3\",\n        date: \"Mon, 6 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.usa, score: 1 },\n        away: { team: TEAMS.belgium, score: 4 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-4\",\n        date: \"Mon, 6 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.portugal, score: 0 },\n        away: { team: TEAMS.spain, score: 1 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-5\",\n        date: \"Mon, 6 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.brazil, score: 1 },\n        away: { team: TEAMS.norway, score: 2 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-6\",\n        date: \"Mon, 6 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.mexico, score: 2 },\n        away: { team: TEAMS.england, score: 3 },\n        winner: \"away\",\n      },\n      {\n        id: \"r16-7\",\n        date: \"Tue, 7 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.switzerland, score: 0, penalties: 4 },\n        away: { team: TEAMS.colombia, score: 0, penalties: 3 },\n        winner: \"home\",\n      },\n      {\n        id: \"r16-8\",\n        date: \"Tue, 7 Jul\",\n        status: \"finished\",\n        home: { team: TEAMS.argentina, score: 3 },\n        away: { team: TEAMS.egypt, score: 2 },\n        winner: \"home\",\n      },\n    ],\n  },\n  {\n    name: \"Quarter-finals\",\n    matches: [\n      {\n        id: \"qf-1\",\n        date: \"Fri, 10 Jul\",\n        time: \"4:00 am\",\n        status: \"finished\",\n        home: { team: TEAMS.france, score: 2 },\n        away: { team: TEAMS.morocco, score: 0 },\n        winner: \"home\",\n      },\n      {\n        id: \"qf-2\",\n        date: \"Sat, 11 Jul\",\n        time: \"3:00 am\",\n        status: \"finished\",\n        home: { team: TEAMS.spain, score: 2 },\n        away: { team: TEAMS.belgium, score: 1 },\n        winner: \"home\",\n      },\n      {\n        id: \"qf-3\",\n        date: \"Today\",\n        status: \"finished\",\n        home: { team: TEAMS.norway, score: 1 },\n        away: { team: TEAMS.england, score: 2 },\n        winner: \"away\",\n      },\n      {\n        id: \"qf-4\",\n        date: \"Today\",\n        status: \"finished\",\n        home: { team: TEAMS.argentina, score: 3 },\n        away: { team: TEAMS.switzerland, score: 1 },\n        winner: \"home\",\n      },\n    ],\n  },\n  {\n    name: \"Semi-finals\",\n    matches: [\n      {\n        id: \"sf-1\",\n        date: \"Wed, 15 Jul\",\n        time: \"4:00 am\",\n        status: \"upcoming\",\n        home: { team: TEAMS.france, score: null },\n        away: { team: TEAMS.spain, score: null },\n      },\n      {\n        id: \"sf-2\",\n        date: \"Thu, 16 Jul\",\n        time: \"3:00 am\",\n        status: \"upcoming\",\n        home: { team: TEAMS.england, score: null },\n        away: { team: TEAMS.argentina, score: null },\n      },\n    ],\n  },\n  {\n    name: \"Final\",\n    matches: [\n      {\n        id: \"f-1\",\n        date: \"Mon, 20 Jul\",\n        time: \"3:00 am\",\n        status: \"upcoming\",\n        home: { team: null, score: null },\n        away: { team: null, score: null },\n      },\n    ],\n  },\n];\n\n// Both slots stay TBD until the semi-finals resolve, same as the final.\nexport const THIRD_PLACE: Match = {\n  id: \"tp-1\",\n  date: \"Sun, 19 Jul\",\n  time: \"3:00 am\",\n  status: \"upcoming\",\n  home: { team: null, score: null },\n  away: { team: null, score: null },\n};\n"},{"path":"components/motion/knockout-wheel.tsx","type":"component","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 { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\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  // Capture-phase focus props, so a Tooltip cloning the child cannot overwrite\n  // them. Both interaction paths are always attached: iPadOS answers the hover\n  // query with true for a finger, so hanging the tap path off \"cannot hover\"\n  // left it unreachable on the very device it was written for. The event says\n  // which input arrived.\n  const tap = useTapGesture<boolean>();\n  const hover = useHoverGesture();\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        // A key press starts a keyboard activation, which never had a pointer\n        // behind it: a gesture the platform took away must not be read as the\n        // tap behind the click this press synthesizes.\n        tap.drop();\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={(event) => {\n        if (hover.enter(event)) onHover(node.id);\n      }}\n      onPointerLeave={(event) => {\n        if (hover.leave(event)) onHover(null);\n      }}\n      onPointerDown={(event) => {\n        tap.start(event, isPinned);\n      }}\n      onPointerCancel={tap.drop}\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={(event) => {\n        const gesture = tap.take();\n        // A hovering pointer lit the flag on its way in and puts it out on the\n        // way past; only a gesture without a hover pins one.\n        if (!gesture || gesture.pointerType === \"mouse\") return;\n        onToggle(node.id);\n        if (gesture.state) event.currentTarget.blur();\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  const stageRef = useRef<HTMLDivElement>(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, tap and focus are tracked apart. Sharing one slot let a stray mouse\n  // move clear the isolation while a node still held focus, and a tap has to\n  // outlive the pointerleave that a finger fires the moment it lifts.\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [pinned, setPinned] = useState<string | null>(null);\n  const [focused, setFocused] = useState<string | null>(null);\n  const active = hovered ?? pinned ?? 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) => setPinned((current) => (current === id ? null : id)),\n    [],\n  );\n\n  // A tap on another flag hands the isolation over rather than ending it.\n  const onFlag = useCallback(\n    (target: Element) =>\n      Boolean(stageRef.current?.contains(target) && target.closest(\"button\")),\n    [],\n  );\n\n  // A finger never leaves the flag it lit, so the isolation would hold for good\n  // — and bare stage reports no pointer event of its own to end it. The next\n  // pointerdown that isn't on a flag stands in for the pointer leaving; it is\n  // consumed, since a wheel spanning the viewport makes tapping past it the\n  // natural way out and there is no reason for that tap to do anything else.\n  // Only a pinned flag arms this: a mouse ends its own hover, and the browser\n  // drops focus on its own.\n  const unpin = useCallback(() => {\n    setPinned(null);\n    const focus = document.activeElement;\n    if (focus instanceof HTMLElement && stageRef.current?.contains(focus)) {\n      focus.blur();\n    }\n  }, []);\n\n  useDismiss(pinned !== null, unpin, null, {\n    behavior: \"consume\",\n    ignore: onFlag,\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\n        ref={stageRef}\n        className=\"relative mx-auto w-full min-w-[32rem] max-w-[34rem]\"\n      >\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 === pinned}\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":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","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 PointerEvent,\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 { 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  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 hover = useHoverGesture();\n  const reduce = useReducedMotion();\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 (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]);\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  // 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]);\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  const variants = useMemo(\n    () => (reduce ? REDUCED_VARIANTS : buildVariants(side)),\n    [reduce, side],\n  );\n\n  if (!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 = cloneElement(children as ReactElement<Record<string, unknown>>, {\n    \"aria-describedby\": id,\n  });\n\n  return (\n    <>\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: the anchor is not a\n          control — it observes the trigger it wraps. Every event listed reaches\n          it on its own (pointerdown/click/keydown/pointercancel bubble, focus\n          and blur arrive as focusin/focusout, and enter/leave are derived from\n          pointerover/pointerout along a path the anchor is on), so the trigger\n          keeps every handler it came with. */}\n      <span\n        ref={anchorRef}\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      {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/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | 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-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/previews/blocks/knockout-bracket.preview.tsx","type":"preview","content":"\"use client\";\n\nimport {\n  KnockoutBracket,\n  ROUNDS,\n  THIRD_PLACE,\n} from \"@/components/motion/knockout-bracket\";\n\n// `ROUNDS` is the sample World Cup draw that ships with the component. Any other\n// single-elimination tournament renders the same way. Build your own `Round[]`,\n// widest round first, each round holding half the matches of the one before it,\n// and pass it in:\n//\n//   const rounds: Round[] = [\n//     {\n//       name: \"Quarter-finals\",\n//       matches: [\n//         {\n//           id: \"qf-1\",\n//           date: \"Sat, 14 Mar\",\n//           home: { team: { name: \"Cloud9\", logo: \"/logos/c9.svg\" }, score: 2 },\n//           away: { team: { name: \"T1\", logo: \"/logos/t1.svg\" }, score: 1 },\n//           winner: \"home\",\n//           badge: \"BO3\",\n//         },\n//         // qf-2, qf-3, qf-4 …\n//       ],\n//     },\n//     { name: \"Semi-finals\", matches: [/* fed by qf 1+2 and qf 3+4 */] },\n//     { name: \"Grand final\", matches: [/* the one final */] },\n//   ];\n//\n// A team carries a `logo` URL, an ISO country `code` for a flag, or neither, in\n// which case its initials stand in. `date`, `time`, `status` and `badge` are all\n// optional. `thirdPlaceLabel` renames the play-off when a tournament calls it\n// something else (\"Bronze match\").\nexport function KnockoutBracketPreview() {\n  return (\n    <div className=\"w-full py-8\">\n      <KnockoutBracket rounds={ROUNDS} thirdPlace={THIRD_PLACE} />\n    </div>\n  );\n}\n"}]}