{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"knockout-bracket","type":"registry:block","title":"Knockout Bracket","description":"Google-style tournament bracket that pages one round at a time — the leftmost round stacks compactly while later rounds center between their feeder matches, with cards, elbow connectors, headers and container height animating to every new layout.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/knockout-bracket.tsx","type":"registry:component","target":"@components/motion/knockout-bracket.tsx","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  /** ISO 3166-1 alpha-2 code used to load the flag from flagcdn.com (England is gb-eng). */\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  date: string;\n  time?: string;\n  status: \"finished\" | \"upcoming\";\n  home: MatchSide;\n  away: MatchSide;\n  /** Decides the marker and which side dims. */\n  winner?: \"home\" | \"away\";\n};\n\nexport type Round = {\n  name: string;\n  matches: Match[];\n};\n\nexport interface KnockoutBracketProps {\n  /** Ordered rounds; each must hold half as many matches as the one before (16 → 8 → 4 → 2 → 1). */\n  rounds: Round[];\n  /** Round shown as the leftmost column on mount. Defaults to 1, clamped to the valid range. */\n  initialRound?: number;\n  className?: string;\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\nfunction TeamFlag({ code }: { code: string }) {\n  const [failed, setFailed] = useState(false);\n  if (failed) {\n    return (\n      <span className=\"flex h-5 w-7 shrink-0 items-center justify-center\">\n        <Shield className=\"size-5 fill-current text-muted-foreground/50\" />\n      </span>\n    );\n  }\n  return (\n    // Plain <img> served by flagcdn.com — swap this if you need self-hosted assets.\n    // biome-ignore lint/performance/noImgElement: remote flagcdn asset, no next/image benefit\n    <img\n      src={`https://flagcdn.com/w80/${code}.png`}\n      alt=\"\"\n      width={28}\n      height={20}\n      loading=\"lazy\"\n      draggable={false}\n      onError={() => setFailed(true)}\n      className=\"h-5 w-7 shrink-0 rounded-[4px] border border-border/40 object-cover\"\n    />\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      {side.team ? (\n        <TeamFlag code={side.team.code} />\n      ) : (\n        <span className=\"flex h-5 w-7 shrink-0 items-center justify-center\">\n          <Shield className=\"size-5 fill-current text-muted-foreground/50\" />\n        </span>\n      )}\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\nfunction matchLabel(roundName: string, m: Match) {\n  const sides =\n    m.status === \"finished\"\n      ? `${sideLabel(m.home)}, ${sideLabel(m.away)}`\n      : `${sideLabel(m.home)} versus ${sideLabel(m.away)}`;\n  const when =\n    m.status === \"upcoming\"\n      ? `, ${m.date}${m.time ? `, ${m.time}` : \"\"}`\n      : \"\";\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 decided = match.status === \"finished\" && match.winner != null;\n  const shootout =\n    match.home.penalties != null || match.away.penalties != null;\n  const badge =\n    match.status === \"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      <div className=\"mb-3 flex 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}\n          {match.time ? `, ${match.time}` : \"\"}\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  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      centers[r] = rounds[r].matches.map(\n        (_, k) => (centers[r - 1][2 * k] + centers[r - 1][2 * k + 1]) / 2,\n      );\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)];\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];\n        const yBot = centers[r - 1][2 * k + 1];\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    const baseCount = base.matches.length;\n    return {\n      cy: centers,\n      containerHeight: (baseCount - 1) * ROW + 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      </section>\n    </div>\n  );\n}\n\n// ── Mock data ────────────────────────────────────────────────────────────────\n// A full World Cup knockout stage, handy as a starting shape. Each round holds\n// half as many matches as the one before it (16 → 8 → 4 → 2 → 1).\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"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}