{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "github-graph",
  "title": "GitHub Graph",
  "description": "A polished, animated GitHub contribution graph for portfolio sites.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/components/unlumen/github-graph/index.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type GithubGraphVariant = \"github\" | \"graphite\" | \"ocean\" | \"violet\";\nexport type GithubGraphAnimation = \"wave\" | \"scan\" | \"cascade\";\nexport type GithubGraphAmbientEffect = \"none\" | \"tide\" | \"drift\" | \"twinkle\";\n\nexport type GithubContribution = {\n  date: string;\n  count: number;\n  level?: number;\n};\n\nexport type GithubContributionCell = GithubContribution & {\n  level: number;\n};\n\nexport type GithubContributionWeek = GithubContributionCell[];\n\nexport interface GithubGraphProps {\n  /** GitHub username, with or without a leading @. @default \"shadcn\" */\n  account?: string;\n  /** Number of recent calendar months to display. @default 6 */\n  months?: number;\n  /** Color treatment for contribution levels. @default \"github\" */\n  variant?: GithubGraphVariant;\n  /** Entrance choreography for graph cells. @default \"wave\" */\n  animation?: GithubGraphAnimation;\n  /** Animation multiplier; higher values reveal the graph faster. @default 1 */\n  animationSpeed?: number;\n  /** Size of each contribution cell in pixels. @default 18 */\n  cellSize?: number;\n  /** Space between contribution cells in pixels. @default 4 */\n  cellGap?: number;\n  /** Corner radius of contribution cells in pixels. @default 3 */\n  cellRadius?: number;\n  /** Shows the contribution-level legend. @default false */\n  showLegend?: boolean;\n  /** Shows the account name above the graph. @default true */\n  showAccount?: boolean;\n  /** Persistent, subtle motion pattern applied to graph cells. @default \"twinkle\" */\n  ambientEffect?: GithubGraphAmbientEffect;\n  /** Strength of the persistent cell motion. @default 0.65 */\n  ambientIntensity?: number;\n  /** Optional preloaded contributions, which bypass the public fetch. */\n  data?: GithubContribution[];\n  className?: string;\n}\n\ntype ResourceState =\n  | { status: \"loading\" }\n  | { status: \"ready\"; contributions: GithubContribution[] }\n  | { status: \"error\"; message: string };\n\nconst CONTRIBUTIONS_ENDPOINT =\n  \"https://github-contributions-api.jogruber.de/v4\";\n\nconst VARIANTS: Record<\n  GithubGraphVariant,\n  [string, string, string, string, string]\n> = {\n  github: [\"#ebedf0\", \"#9be9a8\", \"#40c463\", \"#30a14e\", \"#216e39\"],\n  graphite: [\"#eeeeee\", \"#cccccc\", \"#969696\", \"#5f5f5f\", \"#171717\"],\n  ocean: [\"#e6f5ff\", \"#b4e2ff\", \"#62bdf5\", \"#2585d8\", \"#124e93\"],\n  violet: [\"#f2eaff\", \"#dcc5ff\", \"#b486ff\", \"#8355df\", \"#52269c\"],\n};\n\nfunction dateFromISO(value: string): Date | null {\n  if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return null;\n  const date = new Date(`${value}T00:00:00.000Z`);\n  return Number.isNaN(date.getTime()) ? null : date;\n}\n\nfunction isoDate(date: Date): string {\n  return date.toISOString().slice(0, 10);\n}\n\nfunction addDays(date: Date, days: number): Date {\n  const result = new Date(date);\n  result.setUTCDate(result.getUTCDate() + days);\n  return result;\n}\n\nfunction fallbackLevel(count: number, maxCount: number): number {\n  if (!Number.isFinite(count) || count <= 0 || maxCount <= 0) return 0;\n  return Math.min(4, Math.max(1, Math.ceil((count / maxCount) * 4)));\n}\n\n/** Returns a valid GitHub handle without its optional @ prefix. */\nexport function normalizeGithubAccount(account: string): string | null {\n  const normalized = account.trim().replace(/^@+/, \"\");\n  return /^(?!-)[a-z\\d](?:[a-z\\d-]{0,37}[a-z\\d])?$/i.test(normalized)\n    ? normalized\n    : null;\n}\n\n/** Builds Sunday-first calendar columns and fills missing dates with level zero. */\nexport function buildContributionWeeks(\n  contributions: GithubContribution[],\n): GithubContributionWeek[] {\n  const valid = contributions\n    .map((item) => ({ ...item, parsedDate: dateFromISO(item.date) }))\n    .filter(\n      (item): item is GithubContribution & { parsedDate: Date } =>\n        item.parsedDate !== null && Number.isFinite(item.count),\n    )\n    .sort((a, b) => a.date.localeCompare(b.date));\n\n  if (valid.length === 0) return [];\n\n  const maxCount = Math.max(0, ...valid.map((item) => item.count));\n  const byDate = new Map(valid.map((item) => [item.date, item]));\n  const firstDate = valid[0]!.parsedDate;\n  const lastDate = valid[valid.length - 1]!.parsedDate;\n  const startDate = addDays(firstDate, -firstDate.getUTCDay());\n  const endDate = addDays(lastDate, 6 - lastDate.getUTCDay());\n  const cells: GithubContributionCell[] = [];\n\n  for (let date = startDate; date <= endDate; date = addDays(date, 1)) {\n    const key = isoDate(date);\n    const contribution = byDate.get(key);\n    const count = Math.max(0, contribution?.count ?? 0);\n    const explicitLevel = contribution?.level;\n    const level =\n      Number.isInteger(explicitLevel) &&\n      explicitLevel! >= 0 &&\n      explicitLevel! <= 4\n        ? count === 0\n          ? 0\n          : explicitLevel!\n        : fallbackLevel(count, maxCount);\n\n    cells.push({ date: key, count, level });\n  }\n\n  return Array.from({ length: Math.ceil(cells.length / 7) }, (_, index) =>\n    cells.slice(index * 7, index * 7 + 7),\n  );\n}\n\nfunction selectRecentContributions(\n  contributions: GithubContribution[],\n  months: number,\n): GithubContribution[] {\n  const parsed = contributions\n    .map((contribution) => ({\n      contribution,\n      date: dateFromISO(contribution.date),\n    }))\n    .filter(\n      (item): item is { contribution: GithubContribution; date: Date } =>\n        item.date !== null,\n    );\n  const latest = parsed.reduce<Date | null>(\n    (current, item) => (!current || item.date > current ? item.date : current),\n    null,\n  );\n\n  if (!latest) return [];\n\n  const start = new Date(latest);\n  start.setUTCMonth(\n    start.getUTCMonth() - Math.max(1, Math.min(12, Math.round(months))),\n  );\n  return parsed\n    .filter((item) => item.date >= start)\n    .map((item) => item.contribution);\n}\n\nfunction formatContributionLabel(contribution: GithubContributionCell): string {\n  const date = new Intl.DateTimeFormat(\"en\", {\n    month: \"short\",\n    day: \"numeric\",\n  }).format(dateFromISO(contribution.date) ?? new Date());\n  const label = contribution.count === 1 ? \"contribution\" : \"contributions\";\n  return `${contribution.count} ${label} · ${date}`;\n}\n\nfunction getCellDelay(\n  animation: GithubGraphAnimation,\n  weekIndex: number,\n  dayIndex: number,\n  speed: number,\n): number {\n  const step =\n    animation === \"wave\"\n      ? weekIndex * 0.026 + dayIndex * 0.016\n      : animation === \"scan\"\n        ? weekIndex * 0.03\n        : (weekIndex + dayIndex * 2) * 0.018;\n  return step / Math.max(speed, 0.1);\n}\n\nfunction getAmbientCellMotion(\n  effect: GithubGraphAmbientEffect,\n  intensity: number,\n  weekIndex: number,\n  dayIndex: number,\n  entranceDelay: number,\n  reducedMotion: boolean | null,\n) {\n  if (reducedMotion || effect === \"none\") {\n    return {\n      animate: { opacity: 1, scale: 1 },\n      transition: {\n        opacity: { duration: 0.14, delay: entranceDelay },\n        scale: { type: \"spring\" as const, stiffness: 900, damping: 32 },\n      },\n    };\n  }\n\n  const strength = Math.min(1, Math.max(0, intensity));\n  const seed = ((weekIndex * 17 + dayIndex * 31) % 11) / 10;\n  const isTide = effect === \"tide\";\n  const isDrift = effect === \"drift\";\n  const duration = isTide ? 3.2 : isDrift ? 3.8 + seed : 2 + seed * 1.4;\n  const delay =\n    entranceDelay +\n    (isTide ? (weekIndex + dayIndex * 1.8) * 0.055 : seed * 0.85);\n  const lowOpacity = 1 - (isTide ? 0.24 : isDrift ? 0.16 : 0.34) * strength;\n  const smallScale = 1 - (isTide ? 0.07 : isDrift ? 0.04 : 0.08) * strength;\n\n  return {\n    animate: {\n      opacity: isDrift\n        ? [1, lowOpacity, 1 - 0.06 * strength, 1]\n        : [1, lowOpacity, 1],\n      scale: isDrift\n        ? [1, smallScale, 1 + 0.025 * strength, 1]\n        : [1, smallScale, 1],\n    },\n    transition: {\n      opacity: {\n        duration,\n        delay,\n        ease: \"easeInOut\" as const,\n        repeat: Infinity,\n      },\n      scale: { duration, delay, ease: \"easeInOut\" as const, repeat: Infinity },\n    },\n  };\n}\n\nfunction LoadingGraph({\n  cellSize,\n  cellGap,\n  cellRadius,\n  months,\n}: Pick<GithubGraphProps, \"cellSize\" | \"cellGap\" | \"cellRadius\" | \"months\">) {\n  const weekCount = Math.ceil((Math.max(1, months ?? 3) * 31 + 6) / 7);\n\n  return (\n    <div className=\"overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n      <div\n        className=\"flex min-w-max\"\n        style={{ gap: cellGap }}\n        aria-label=\"Loading contributions\"\n      >\n        {Array.from({ length: weekCount }, (_, week) => (\n          <div key={week} className=\"grid grid-rows-7\" style={{ gap: cellGap }}>\n            {Array.from({ length: 7 }, (_, day) => (\n              <span\n                key={day}\n                className=\"animate-pulse bg-muted\"\n                style={{\n                  width: cellSize,\n                  height: cellSize,\n                  borderRadius: cellRadius,\n                  animationDelay: `${(week + day) * 12}ms`,\n                }}\n              />\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport function GithubGraph({\n  account = \"shadcn\",\n  months = 6,\n  variant = \"github\",\n  animation = \"wave\",\n  animationSpeed = 1,\n  cellSize = 18,\n  cellGap = 4,\n  cellRadius = 3,\n  showLegend = false,\n  showAccount = true,\n  ambientEffect = \"twinkle\",\n  ambientIntensity = 0.65,\n  data,\n  className,\n}: GithubGraphProps) {\n  const reducedMotion = useReducedMotion();\n  const normalizedAccount = React.useMemo(\n    () => normalizeGithubAccount(account),\n    [account],\n  );\n  const [resource, setResource] = React.useState<ResourceState>({\n    status: \"loading\",\n  });\n  const [hoveredContribution, setHoveredContribution] = React.useState<{\n    contribution: GithubContributionCell;\n    left: number;\n    top: number;\n    originLeft: number;\n    originTop: number;\n    placement: \"above\" | \"below\";\n    weekIndex: number;\n    dayIndex: number;\n  } | null>(null);\n  const colors = VARIANTS[variant];\n  const resolvedCellRadius = Math.max(\n    0,\n    Math.min(cellRadius, Math.max(0, cellSize) / 2),\n  );\n\n  React.useEffect(() => {\n    if (data) {\n      setResource({ status: \"ready\", contributions: data });\n      return;\n    }\n\n    if (!normalizedAccount) {\n      setResource({\n        status: \"error\",\n        message: \"Enter a valid GitHub username.\",\n      });\n      return;\n    }\n\n    const controller = new AbortController();\n    setResource({ status: \"loading\" });\n\n    fetch(`${CONTRIBUTIONS_ENDPOINT}/${normalizedAccount}?y=last`, {\n      signal: controller.signal,\n    })\n      .then(async (response) => {\n        if (!response.ok) throw new Error(\"GitHub account not found.\");\n        const payload = (await response.json()) as {\n          contributions?: GithubContribution[];\n        };\n        if (!Array.isArray(payload.contributions)) {\n          throw new Error(\"No public contributions were returned.\");\n        }\n        return payload.contributions;\n      })\n      .then((contributions) => {\n        if (!controller.signal.aborted) {\n          setResource({ status: \"ready\", contributions });\n        }\n      })\n      .catch((error: unknown) => {\n        if (controller.signal.aborted) return;\n        setResource({\n          status: \"error\",\n          message:\n            error instanceof Error\n              ? error.message\n              : \"Could not load contributions.\",\n        });\n      });\n\n    return () => controller.abort();\n  }, [data, normalizedAccount]);\n\n  const weeks = React.useMemo(() => {\n    if (resource.status !== \"ready\") return [];\n    return buildContributionWeeks(\n      selectRecentContributions(resource.contributions, months),\n    );\n  }, [months, resource]);\n  const animationKey = `${normalizedAccount ?? account}-${months}-${variant}-${animation}-${cellSize}-${cellGap}`;\n\n  const showTooltip = React.useCallback(\n    (\n      element: HTMLButtonElement,\n      contribution: GithubContributionCell,\n      weekIndex: number,\n      dayIndex: number,\n      pointer?: { clientX: number; clientY: number },\n    ) => {\n      const cellRect = element.getBoundingClientRect();\n      const placement = cellRect.top > 56 ? \"above\" : \"below\";\n      const left = Math.min(\n        Math.max(cellRect.left + cellRect.width / 2, 96),\n        window.innerWidth - 96,\n      );\n      setHoveredContribution({\n        contribution,\n        left,\n        top: placement === \"above\" ? cellRect.top - 9 : cellRect.bottom + 9,\n        originLeft: pointer?.clientX ?? left,\n        originTop: pointer?.clientY ?? cellRect.top + cellRect.height / 2,\n        placement,\n        weekIndex,\n        dayIndex,\n      });\n    },\n    [],\n  );\n\n  return (\n    <div\n      className={cn(\"w-fit max-w-full\", className)}\n      aria-busy={resource.status === \"loading\"}\n    >\n      {showAccount && (\n        <p className=\"mb-5 text-lg font-medium tracking-tight text-foreground\">\n          @{normalizedAccount ?? account}\n        </p>\n      )}\n\n      {resource.status === \"loading\" && (\n        <LoadingGraph\n          cellSize={cellSize}\n          cellGap={cellGap}\n          cellRadius={resolvedCellRadius}\n          months={months}\n        />\n      )}\n\n      {resource.status === \"error\" && (\n        <p className=\"text-sm text-muted-foreground\">{resource.message}</p>\n      )}\n\n      {resource.status === \"ready\" && weeks.length > 0 && (\n        <div className=\"overflow-x-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n          <div\n            className=\"relative flex min-w-max\"\n            style={{ gap: cellGap }}\n            role=\"grid\"\n            aria-label={`GitHub contributions for ${normalizedAccount ?? account}`}\n            onMouseLeave={() => setHoveredContribution(null)}\n          >\n            {weeks.map((week, weekIndex) => (\n              <div\n                key={`${animationKey}-${weekIndex}`}\n                className=\"grid grid-rows-7\"\n                style={{ gap: cellGap }}\n                role=\"row\"\n              >\n                {week.map((contribution, dayIndex) => {\n                  const label = formatContributionLabel(contribution);\n                  const entranceDelay = reducedMotion\n                    ? 0\n                    : getCellDelay(\n                        animation,\n                        weekIndex,\n                        dayIndex,\n                        animationSpeed,\n                      );\n                  const ambientMotion = getAmbientCellMotion(\n                    ambientEffect,\n                    ambientIntensity,\n                    weekIndex,\n                    dayIndex,\n                    entranceDelay,\n                    reducedMotion,\n                  );\n                  const distance = hoveredContribution\n                    ? Math.hypot(\n                        weekIndex - hoveredContribution.weekIndex,\n                        dayIndex - hoveredContribution.dayIndex,\n                      )\n                    : Infinity;\n                  const waveStrength = Math.max(0, 1 - distance / 3);\n                  const filter = `brightness(${1 + waveStrength * 0.45}) saturate(${1 + waveStrength * 0.2})`;\n                  return (\n                    <motion.button\n                      key={`${animationKey}-${contribution.date}`}\n                      type=\"button\"\n                      role=\"gridcell\"\n                      aria-label={label}\n                      className=\"relative outline-none ring-offset-2 ring-offset-background transition-shadow focus-visible:ring-2 focus-visible:ring-foreground/60\"\n                      style={{\n                        width: cellSize,\n                        height: cellSize,\n                        borderRadius: resolvedCellRadius,\n                      }}\n                      initial={\n                        reducedMotion\n                          ? false\n                          : { opacity: 0, scale: 0.35, y: 4 }\n                      }\n                      animate={{ opacity: 1, scale: 1, y: 0, filter }}\n                      transition={{\n                        opacity: { duration: 0.14, delay: entranceDelay },\n                        y: {\n                          type: \"spring\",\n                          stiffness: 520,\n                          damping: 28,\n                          delay: entranceDelay,\n                        },\n                        scale: { type: \"spring\", stiffness: 900, damping: 32 },\n                        filter: { duration: 0.08, ease: \"easeOut\" },\n                      }}\n                      onMouseEnter={(event) =>\n                        showTooltip(\n                          event.currentTarget,\n                          contribution,\n                          weekIndex,\n                          dayIndex,\n                          event,\n                        )\n                      }\n                      onFocus={(event) =>\n                        showTooltip(\n                          event.currentTarget,\n                          contribution,\n                          weekIndex,\n                          dayIndex,\n                        )\n                      }\n                      onBlur={() => setHoveredContribution(null)}\n                    >\n                      <motion.span\n                        aria-hidden=\"true\"\n                        className=\"pointer-events-none absolute inset-0\"\n                        style={{\n                          backgroundColor: colors[contribution.level],\n                          borderRadius: resolvedCellRadius,\n                        }}\n                        animate={ambientMotion.animate}\n                        transition={ambientMotion.transition}\n                      />\n                    </motion.button>\n                  );\n                })}\n              </div>\n            ))}\n            <AnimatePresence>\n              {hoveredContribution && (\n                <motion.span\n                  role=\"tooltip\"\n                  className=\"pointer-events-none fixed z-50 whitespace-nowrap rounded-full bg-foreground px-3 py-1.5 text-sm font-medium text-background ring-1 ring-foreground/15\"\n                  initial={{\n                    opacity: 0,\n                    scale: 0.92,\n                    left: hoveredContribution.originLeft,\n                    top: hoveredContribution.originTop,\n                    x: \"-50%\",\n                    y:\n                      hoveredContribution.placement === \"above\"\n                        ? \"-100%\"\n                        : \"0%\",\n                  }}\n                  animate={{\n                    opacity: 1,\n                    scale: 1,\n                    left: hoveredContribution.left,\n                    top: hoveredContribution.top,\n                    x: \"-50%\",\n                    y:\n                      hoveredContribution.placement === \"above\"\n                        ? \"-100%\"\n                        : \"0%\",\n                  }}\n                  exit={{ opacity: 0, scale: 0.92 }}\n                  transition={{\n                    opacity: { duration: 0.12 },\n                    scale: { duration: 0.12 },\n                    left: { type: \"spring\", stiffness: 620, damping: 42 },\n                    top: { type: \"spring\", stiffness: 620, damping: 42 },\n                    y: { duration: 0.12 },\n                  }}\n                >\n                  {formatContributionLabel(hoveredContribution.contribution)}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </div>\n        </div>\n      )}\n\n      {showLegend && resource.status === \"ready\" && (\n        <div\n          className=\"mt-4 flex gap-1.5\"\n          aria-label=\"Contribution activity legend\"\n        >\n          {colors.map((color, level) => (\n            <span\n              key={color}\n              style={{\n                width: cellSize,\n                height: cellSize,\n                backgroundColor: color,\n                borderRadius: resolvedCellRadius,\n              }}\n              aria-label={`Level ${level}`}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/unlumen-ui/github-graph.tsx"
    }
  ],
  "type": "registry:component"
}