{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"activity-stats","type":"registry:block","title":"Activity Stats","description":"Usage-summary widgets: a connected stats bar for headline metrics, a GitHub-style contribution heatmap with a color-mix intensity scale and column-cascade entrance, and a text period switch.","author":"UI Lab","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/activity-stats/index.tsx","type":"registry:component","target":"@components/motion/activity-stats/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/activity-stats\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface StatsBarProps {\n  className?: string;\n  children?: ReactNode;\n}\n\n/** Connected row of `StatsItem`s, hairline-divided between siblings. */\nexport function StatsBar({ className, children }: StatsBarProps) {\n  return (\n    <div\n      className={cn(\n        \"flex overflow-hidden rounded-[20px] border border-black/10 dark:border-white/[0.06]\",\n        \"divide-x-[1px] divide-black/5 dark:divide-white/[0.06]\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface StatsItemProps {\n  /** Typically an `AnimatedNumber`/`NumberTicker` node so it counts in on mount. */\n  value: ReactNode;\n  label: ReactNode;\n  className?: string;\n}\n\n/** One metric cell inside a `StatsBar` — a value line over a muted label line, both truncating equally. */\nexport function StatsItem({ value, label, className }: StatsItemProps) {\n  return (\n    <div\n      className={cn(\n        \"flex min-w-px flex-1 flex-col items-center justify-center px-3 py-2.5 text-center\",\n        className,\n      )}\n    >\n      <div className=\"w-full truncate text-sm leading-5 text-foreground\">{value}</div>\n      <div className=\"w-full truncate text-sm leading-5 text-muted-foreground\">{label}</div>\n    </div>\n  );\n}\n\n/** Opacity bands for level 1–4, keyed by `value / max` thresholds (<=.25/.5/.75/1). */\nconst HEAT_ALPHA: Record<1 | 2 | 3 | 4, number> = { 1: 0.25, 2: 0.45, 3: 0.7, 4: 1 };\n\nfunction heatLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 {\n  if (value <= 0 || max <= 0) return 0;\n  const ratio = value / max;\n  if (ratio <= 0.25) return 1;\n  if (ratio <= 0.5) return 2;\n  if (ratio <= 0.75) return 3;\n  return 4;\n}\n\nexport interface ActivityHeatmapProps {\n  /**\n   * Column-major cell values: index `week * 7 + day`, top to bottom then\n   * left to right. Shorter arrays are zero-padded up to `weeks * 7`.\n   */\n  data: number[];\n  weeks?: number;\n  /** Ceiling for the color scale; defaults to the highest value in `data`. */\n  max?: number;\n  /** Month-axis labels under the grid, `week` is the column index they sit above. */\n  months?: { label: string; week: number }[];\n  /** Native `title` tooltip text; defaults to the raw value. */\n  cellTitle?: (index: number, value: number) => string;\n  className?: string;\n}\n\n/**\n * GitHub-style contribution grid. Colors come from a single `--heat-accent`\n * CSS variable (blue, tuned separately for each color scheme) mixed at four\n * opacity bands via `color-mix`, so cells never need a per-scheme class list.\n * Entrance is a one-time column-cascade fade: each of the (up to) 52 columns\n * is its own `motion.div` fading in with a small stagger, while the 7 cells\n * inside a column mount statically — animating per-column instead of per-cell\n * keeps this to `weeks` motion nodes instead of `weeks * 7`.\n * `useReducedMotion()` skips the cascade and renders everything in place.\n */\nexport function ActivityHeatmap({\n  data,\n  weeks = 52,\n  max,\n  months,\n  cellTitle,\n  className,\n}: ActivityHeatmapProps) {\n  const reduce = useReducedMotion() ?? false;\n  const total = weeks * 7;\n\n  const padded = useMemo(\n    () => Array.from({ length: total }, (_, i) => data[i] ?? 0),\n    [data, total],\n  );\n  const effectiveMax = useMemo(() => max ?? Math.max(1, ...padded), [max, padded]);\n  const columns = useMemo(\n    () => Array.from({ length: weeks }, (_, w) => padded.slice(w * 7, w * 7 + 7)),\n    [padded, weeks],\n  );\n\n  return (\n    <div className={cn(\"[--heat-accent:#339CFF] dark:[--heat-accent:#83C3FF]\", className)}>\n      <div className=\"flex gap-[3px]\">\n        {columns.map((column, weekIndex) => (\n          <motion.div\n            // biome-ignore lint/suspicious/noArrayIndexKey: columns are a fixed-length, position-derived grid — index is a stable identity here.\n            key={weekIndex}\n            initial={reduce ? false : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={\n              reduce ? undefined : { duration: 0.2, ease: EASE_OUT, delay: weekIndex * 0.006 }\n            }\n            className=\"grid grid-rows-7 gap-[3px]\"\n          >\n            {column.map((value, day) => {\n              const index = weekIndex * 7 + day;\n              const level = heatLevel(value, effectiveMax);\n              return (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: day is a fixed 0-6 row position within the column.\n                  key={day}\n                  title={cellTitle ? cellTitle(index, value) : `${value}`}\n                  style={\n                    level !== 0\n                      ? {\n                          backgroundColor: `color-mix(in srgb, var(--heat-accent) ${HEAT_ALPHA[level] * 100}%, transparent)`,\n                        }\n                      : undefined\n                  }\n                  className={cn(\n                    \"block h-[11px] w-[11px] rounded-[4px] transition-transform hover:scale-125\",\n                    level === 0 && \"bg-black/[0.06] dark:bg-white/[0.043]\",\n                  )}\n                />\n              );\n            })}\n          </motion.div>\n        ))}\n      </div>\n      {months && months.length > 0 ? (\n        <div className=\"relative mt-1 h-4 text-xs text-muted-foreground\">\n          {months.map((month) => (\n            <span\n              key={`${month.label}-${month.week}`}\n              className=\"absolute\"\n              style={{ left: month.week * 14 }}\n            >\n              {month.label}\n            </span>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport interface StatsPeriodTabsProps {\n  options: { value: string; label: ReactNode }[];\n  value: string;\n  onChange: (value: string) => void;\n  className?: string;\n}\n\n/** Plain-text period switch (e.g. \"Daily / Weekly / Total\") — no pill, just color contrast on the active option. */\nexport function StatsPeriodTabs({ options, value, onChange, className }: StatsPeriodTabsProps) {\n  return (\n    <div className={cn(\"flex items-center gap-3 text-sm\", className)}>\n      {options.map((option) => (\n        <button\n          key={option.value}\n          type=\"button\"\n          aria-pressed={option.value === value}\n          onClick={() => onChange(option.value)}\n          className={cn(\n            \"transition-colors\",\n            option.value === value\n              ? \"text-foreground\"\n              : \"text-muted-foreground hover:text-foreground\",\n          )}\n        >\n          {option.label}\n        </button>\n      ))}\n    </div>\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"}]}