{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-heatmap",
  "type": "registry:ui",
  "title": "Activity heatmap",
  "description": "See a year of activity at a glance, one square per day.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/activity-heatmap/activity-heatmap.tsx",
      "type": "registry:component",
      "target": "@components/xzero/activity-heatmap/activity-heatmap.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./activity-heatmap.module.css\";\n\nexport interface ActivityDay {\n  /** Calendar day as YYYY-MM-DD. */\n  date: string;\n  count: number;\n}\n\n/**\n * A contribution style calendar: one square per day, weeks as columns, with four accent tints for how busy a day was.\n * Use it when rhythm, streaks, and quiet weeks matter more than exact comparisons; use a bar chart when the exact\n * comparison is the point. Cells wave in once on view, a tooltip glides between cells on hover or keyboard focus, and a\n * new range recolors the grid in a sweep instead of redrawing it. Arrow keys move by day and week, Enter selects.\n */\nexport interface ActivityHeatmapProps {\n  /** One entry per day, oldest first. Days missing inside the range count as zero. */\n  days: ActivityDay[];\n  /** Accessible name for the grid, such as \"Contributions in 2025\". */\n  label: string;\n  /** Finishes the summary line: \"1,284 contributions in {period}\". */\n  period: string;\n  /** Nouns for the count. */\n  unit?: { one: string; other: string };\n  /** Upper bounds for levels one to three; anything above the last is level four. Defaults to quarters of the busiest day. */\n  thresholds?: [number, number, number];\n  weekStartsOn?: 0 | 1;\n  selectedDate?: string | null;\n  onSelectDate?: (date: string) => void;\n  /** Controls beside the summary, such as a range switch. */\n  actions?: ReactNode;\n  /** Formatting locale. Fixed by default so server and client render the same labels. */\n  locale?: string;\n  className?: string;\n}\n\ntype Model = {\n  start: number; length: number; lead: number; weeks: number; total: number;\n  counts: number[]; levels: number[]; thresholds: [number, number, number];\n  months: { month: number; col: number; label: string }[];\n  /** How many days in the range sit at each level. */\n  perLevel: number[];\n};\ntype Tip = { key: string; primary: string; secondary: string; value: number; anchor: HTMLElement };\n/** How the tooltip text changes: rolls up or down with the count, crossfades on a tie, or appears at once when the bubble opens. */\ntype Change = 1 | -1 | 0 | \"instant\";\n\nconst DAY = 86_400_000;\nconst LEVELS = [0, 1, 2, 3, 4] as const;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n/** Total wave travel in ms, kept under the system's stagger budget however many weeks the range spans. */\nconst WAVE = 420;\nconst toUtc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);\nconst toIso = (time: number) => new Date(time).toISOString().slice(0, 10);\n\nfunction buildModel(days: ActivityDay[], weekStartsOn: 0 | 1, thresholds: [number, number, number] | undefined, locale: string): Model {\n  const dates = days.map(day => toUtc(day.date)).filter(Number.isFinite);\n  const start = dates.length ? Math.min(...dates) : toUtc(\"2025-01-01\");\n  const length = dates.length ? Math.round((Math.max(...dates) - start) / DAY) + 1 : 0;\n  const counts = new Array<number>(length).fill(0);\n  days.forEach(day => { const index = Math.round((toUtc(day.date) - start) / DAY); if (index >= 0 && index < length) counts[index]! += Math.max(0, day.count); });\n  const max = counts.reduce((a, b) => Math.max(a, b), 0);\n  const bounds = thresholds ?? [Math.max(1, Math.ceil(max * .25)), Math.max(2, Math.ceil(max * .5)), Math.max(3, Math.ceil(max * .75))] as [number, number, number];\n  const levels = counts.map(count => count <= 0 ? 0 : count <= bounds[0] ? 1 : count <= bounds[1] ? 2 : count <= bounds[2] ? 3 : 4);\n  const lead = (new Date(start).getUTCDay() - weekStartsOn + 7) % 7;\n  const weeks = Math.ceil((lead + length) / 7);\n  const monthName = new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" });\n  const months: Model[\"months\"] = [];\n  for (let index = 0; index < length; index++) {\n    const date = new Date(start + index * DAY);\n    if (index === 0 || date.getUTCDate() === 1) months.push({ month: date.getUTCFullYear() * 12 + date.getUTCMonth(), col: Math.floor((lead + index) / 7), label: monthName.format(date) });\n  }\n  // A partial first month keeps its label only when there is room before the next one.\n  if (months.length > 1 && months[1]!.col - months[0]!.col < 3) months.shift();\n  const perLevel = [0, 0, 0, 0, 0];\n  levels.forEach(level => perLevel[level]!++);\n  return { start, length, lead, weeks, total: counts.reduce((a, b) => a + b, 0), counts, levels, thresholds: bounds, months, perLevel };\n}\n\nconst CONTRIBUTIONS = { one: \"contribution\", other: \"contributions\" };\nconst noun = (count: number, unit: { one: string; other: string }) => count === 1 ? unit.one : unit.other;\nconst noopSubscribe = () => () => {};\n/** Reduced motion only after hydration, so the server and the first client render agree. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(noopSubscribe, () => true, () => false);\n  const reduced = useReducedMotion();\n  return hydrated && !!reduced;\n}\n\nconst tipText = (reduced: boolean): Variants => ({\n  from: (change: Change) => change === \"instant\" ? { opacity: 1, y: \"0em\", filter: \"blur(0px)\" } : reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * (change || 1)}em`, filter: `blur(${change ? motionTokens.blur.soft : motionTokens.blur.subtle}px)` },\n  to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n  gone: (change: Change) => change === \"instant\" || reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * (change || 1)}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },\n});\n\n/** A total that rolls digit by digit in the direction it moved. Places keep their identity, so only changed digits turn,\n *  and when the number gains or loses a digit the width follows on a spring instead of shifting the sentence in one frame. */\nfunction RollingNumber({ value, locale, reduced }: { value: number; locale: string; reduced: boolean }) {\n  const [state, setState] = useState({ value, direction: 1 });\n  if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });\n  const inner = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const armedUntil = useRef(0), lastValue = useRef(value);\n  useLayoutEffect(() => {\n    if (lastValue.current === value) return;\n    lastValue.current = value;\n    armedUntil.current = performance.now() + 600;\n  }, [value]);\n  useEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = node.getBoundingClientRect().width;\n      if (reduced || width.get() === \"auto\" || performance.now() > armedUntil.current) width.jump(next);\n      else animate(width, next, motionTokens.spring.morph);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  const chars = [...new Intl.NumberFormat(locale).format(value)];\n  const variants: Variants = {\n    from: (direction: number) => reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` },\n    to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n    gone: (direction: number) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },\n  };\n  return <motion.span className={styles.rollingFrame} style={{ width }} aria-hidden=\"true\"><span ref={inner} className={styles.rolling}>\n    {chars.map((char, index) => {\n      const place = chars.length - index;\n      return <span key={place} className={styles.place}>\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={state.direction}>\n          <motion.span key={char} className={styles.placeChar} custom={state.direction} variants={variants} initial=\"from\" animate=\"to\" exit=\"gone\"\n            transition={reduced ? { duration: motionTokens.duration.fast } : { duration: motionTokens.duration.standard, ease: enter, delay: Math.min(place * .018, .09) }}>{char}</motion.span>\n        </AnimatePresence>\n      </span>;\n    })}\n  </span></motion.span>;\n}\n\n/** A short word that changes rises in from a soft blur while the old one lifts away a little faster. */\nfunction RiseText({ text, reduced, children }: { text: string; reduced: boolean; children?: ReactNode }) {\n  return <span className={styles.rise} aria-hidden=\"true\">\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span key={text} className={styles.riseLine} initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: \"0em\", filter: \"blur(0px)\" }}\n        exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } }}\n        transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>{children ?? text}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\nexport function ActivityHeatmap({ days, label, period, unit: unitProp = CONTRIBUTIONS, thresholds, weekStartsOn = 0, selectedDate = null, onSelectDate, actions, locale = \"en-US\", className }: ActivityHeatmapProps) {\n  const reduced = useReducedMotionSafe();\n  const id = useId();\n  // Keyed by its words, so an inline unit object does not rebuild every cell on each render.\n  const unit = useMemo(() => ({ one: unitProp.one, other: unitProp.other }), [unitProp.one, unitProp.other]);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const plotRef = useRef<HTMLDivElement>(null);\n  const gridRef = useRef<HTMLDivElement>(null);\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const ringRef = useRef<HTMLSpanElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(plotRef, { once: true, amount: .35 });\n\n  const model = useMemo(() => buildModel(days, weekStartsOn, thresholds, locale), [days, weekStartsOn, thresholds, locale]);\n  const formats = useMemo(() => ({\n    long: new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }),\n    short: new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }),\n    weekday: new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }),\n    number: new Intl.NumberFormat(locale),\n  }), [locale]);\n\n  // A new range sweeps forward when it is later than the last one and back when it is earlier.\n  const [range, setRange] = useState({ start: model.start, direction: 1 });\n  if (range.start !== model.start) setRange({ start: model.start, direction: model.start > range.start ? 1 : -1 });\n\n  const [reveal, setReveal] = useState<\"hidden\" | \"revealing\" | \"done\">(\"hidden\");\n  useEffect(() => {\n    if (reveal !== \"hidden\" || !(inView || reduced)) return;\n    const frame = requestAnimationFrame(() => setReveal(reduced ? \"done\" : \"revealing\"));\n    return () => cancelAnimationFrame(frame);\n  }, [inView, reduced, reveal]);\n  useEffect(() => {\n    if (reveal !== \"revealing\") return;\n    const timer = window.setTimeout(() => setReveal(\"done\"), WAVE + 700);\n    return () => window.clearTimeout(timer);\n  }, [reveal]);\n\n  const selectedIndex = selectedDate ? Math.round((toUtc(selectedDate) - model.start) / DAY) : -1;\n  const hasSelection = selectedIndex >= 0 && selectedIndex < model.length;\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n  const tabIndexDay = focusIndex !== null && focusIndex < model.length ? focusIndex : hasSelection ? selectedIndex : model.length - 1;\n\n  // Legend: hovering or focusing a level previews it on the grid, clicking pins it. The caption says how many days sit at that level.\n  const [preview, setPreview] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const [legendFocus, setLegendFocus] = useState(0);\n  const previewTimer = useRef(0);\n  const highlight = preview ?? pinned;\n  const previewLevel = (level: number | null) => { window.clearTimeout(previewTimer.current); if (level !== null) setPreview(level); else previewTimer.current = window.setTimeout(() => setPreview(null), 90); };\n  useEffect(() => () => window.clearTimeout(previewTimer.current), []);\n\n  // Tooltip: one bubble for the whole grid. It jumps into place when it opens and glides on a spring between cells after that.\n  const [tip, setTip] = useState<Tip | null>(null);\n  const [open, setOpen] = useState(false);\n  const [change, setChange] = useState<Change>(\"instant\");\n  const openRef = useRef(false);\n  const hideTimer = useRef(0);\n  const tipX = useMotionValue(0), tipY = useMotionValue(0), tipWidth = useMotionValue<number | \"auto\">(\"auto\");\n\n  function contentFor(index: number, anchor: HTMLElement): Tip {\n    const count = model.counts[index]!;\n    const date = new Date(model.start + index * DAY);\n    return { key: `day-${toIso(date.getTime())}`, primary: count ? `${formats.number.format(count)} ${noun(count, unit)}` : `No ${unit.other}`, secondary: formats.short.format(date), value: count, anchor };\n  }\n  const [a, b, c] = model.thresholds;\n  const ranges = [`no ${unit.other}`, a === 1 ? `1 ${unit.one}` : `1 to ${a} ${unit.other}`, `${a + 1} to ${b} ${unit.other}`, `${b + 1} to ${c} ${unit.other}`, `${c + 1} or more ${unit.other}`];\n  const shortRanges = [`no ${unit.other}`, a === 1 ? `1 ${unit.one}` : `1–${a} ${unit.other}`, `${a + 1}–${b} ${unit.other}`, `${b + 1}–${c} ${unit.other}`, `${c + 1}+ ${unit.other}`];\n  const dayCount = highlight === null ? \"\" : `${formats.number.format(model.perLevel[highlight]!)} ${model.perLevel[highlight] === 1 ? \"day\" : \"days\"}`;\n  const caption = highlight === null ? \"\" : `${dayCount} with ${shortRanges[highlight]}`;\n  function show(next: Tip) {\n    window.clearTimeout(hideTimer.current);\n    setChange(tip && openRef.current ? Math.sign(next.value - tip.value) as Change : \"instant\");\n    setTip(next);\n    setOpen(true);\n  }\n  function hideSoon(delay = 110) {\n    window.clearTimeout(hideTimer.current);\n    hideTimer.current = window.setTimeout(() => setOpen(false), delay);\n  }\n  useEffect(() => () => window.clearTimeout(hideTimer.current), []);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current, measure = measureRef.current;\n    if (!tip || !root || !measure) return;\n    const place = (glide: boolean) => {\n      const box = root.getBoundingClientRect(), cell = tip.anchor.getBoundingClientRect();\n      const width = Math.ceil(measure.getBoundingClientRect().width);\n      const half = width / 2 + 13;\n      const x = Math.min(Math.max(cell.left - box.left + cell.width / 2, half + 2), box.width - half - 2);\n      const y = cell.top - box.top;\n      if (glide) {\n        animate(tipX, x, motionTokens.spring.snappy);\n        animate(tipY, y, motionTokens.spring.snappy);\n        animate(tipWidth, width, motionTokens.spring.morph);\n      } else { tipX.jump(x); tipY.jump(y); tipWidth.jump(width); }\n    };\n    place(open && openRef.current && !reduced);\n    openRef.current = open;\n    if (!open) return;\n    // The grid scrolls sideways on narrow screens; the bubble stays pinned to its cell while it does.\n    const scroller = scrollerRef.current;\n    const follow = () => place(false);\n    scroller?.addEventListener(\"scroll\", follow, { passive: true });\n    return () => scroller?.removeEventListener(\"scroll\", follow);\n  }, [tip, open, reduced, tipX, tipY, tipWidth]);\n\n  const indexOf = (element: Element | null) => {\n    const cell = element?.closest<HTMLElement>(\"[data-index]\");\n    return cell && gridRef.current?.contains(cell) ? { cell, index: Number(cell.dataset.index) } : null;\n  };\n  const focusDay = (index: number) => gridRef.current?.querySelector<HTMLElement>(`[data-index=\"${index}\"]`)?.focus();\n\n  function onGridPointerOver(event: PointerEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target as Element);\n    if (hit) show(contentFor(hit.index, hit.cell));\n  }\n  function onGridPointerLeave(event: PointerEvent<HTMLDivElement>) {\n    // A keyboard user keeps their bubble on the focused day. A tap focuses its day too, so the bubble stays until focus moves on;\n    // where a tap does not focus, it lingers long enough to read.\n    const focused = indexOf(document.activeElement);\n    if (focused?.cell.matches(\":focus-visible\")) show(contentFor(focused.index, focused.cell));\n    else hideSoon(event.pointerType === \"touch\" ? 2400 : 110);\n  }\n  function onGridFocus(event: FocusEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target);\n    if (!hit) return;\n    setFocusIndex(hit.index);\n    show(contentFor(hit.index, hit.cell));\n  }\n  function onGridBlur(event: FocusEvent<HTMLDivElement>) {\n    if (!gridRef.current?.contains(event.relatedTarget as Node | null)) hideSoon(0);\n  }\n  function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const hit = indexOf(event.target as Element);\n    if (!hit) return;\n    const moves: Record<string, number> = { ArrowUp: hit.index - 1, ArrowDown: hit.index + 1, ArrowLeft: hit.index - 7, ArrowRight: hit.index + 7, Home: 0, End: model.length - 1 };\n    if (event.key in moves) {\n      event.preventDefault();\n      focusDay(Math.min(Math.max(moves[event.key]!, 0), model.length - 1));\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      onSelectDate?.(toIso(model.start + hit.index * DAY));\n    } else if (event.key === \"Escape\" && open) {\n      event.preventDefault();\n      setOpen(false);\n    }\n  }\n\n  function onLegendKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const moves: Record<string, number> = { ArrowLeft: legendFocus - 1, ArrowRight: legendFocus + 1, Home: 0, End: 4 };\n    if (event.key === \"Escape\") { previewLevel(null); return; }\n    if (!(event.key in moves)) return;\n    event.preventDefault();\n    const next = Math.min(Math.max(moves[event.key]!, 0), 4);\n    rootRef.current?.querySelector<HTMLElement>(`[data-level-key=\"${next}\"]`)?.focus();\n  }\n\n  // The selection ring glides between cells on CSS. When it first appears it should fade in where it lands, not fly in from its last spot.\n  const ringShown = hasSelection && reveal !== \"hidden\";\n  const ringWasShown = useRef(ringShown);\n  useLayoutEffect(() => {\n    const ring = ringRef.current;\n    if (ring && ringShown && !ringWasShown.current) {\n      ring.style.transitionProperty = \"opacity\";\n      void ring.offsetWidth;\n      ring.style.transitionProperty = \"\";\n    }\n    ringWasShown.current = ringShown;\n  }, [ringShown]);\n\n  const maxDiagonal = Math.max(1, model.weeks - 1 + 6);\n  const step = Math.min(12, WAVE / maxDiagonal);\n  const cells = useMemo(() => Array.from({ length: 7 }, (_, row) => <div key={row} role=\"row\" className={styles.row}>\n    {Array.from({ length: model.weeks }, (_, col) => {\n      const index = col * 7 + row - model.lead;\n      const inRange = index >= 0 && index < model.length;\n      const wave = { \"--wave\": `${Math.round((col + row) * step)}ms`, \"--wave-back\": `${Math.round((maxDiagonal - col - row) * step)}ms` } as CSSProperties;\n      if (!inRange) return <span key={col} className={styles.cell} data-empty=\"\" style={wave} aria-hidden=\"true\" />;\n      const count = model.counts[index]!;\n      const date = new Date(model.start + index * DAY);\n      return <span key={col} role=\"gridcell\" className={styles.cell} style={wave} data-index={index} data-level={model.levels[index]} tabIndex={index === tabIndexDay ? 0 : -1}\n        aria-selected={onSelectDate ? index === selectedIndex : undefined} aria-label={`${count ? formats.number.format(count) : \"No\"} ${noun(count, unit)}, ${formats.long.format(date)}`}\n        onClick={() => onSelectDate?.(toIso(date.getTime()))} />;\n    })}\n  </div>), [model, step, maxDiagonal, tabIndexDay, selectedIndex, formats, unit, onSelectDate]);\n\n  const weekdayLabels = useMemo(() => Array.from({ length: 7 }, (_, row) => {\n    const weekday = (row + weekStartsOn) % 7;\n    // January 4, 1970 was a Sunday.\n    return weekday % 2 === 1 ? formats.weekday.format(new Date((3 + weekday) * DAY)) : \"\";\n  }), [formats, weekStartsOn]);\n\n  const selectedCol = hasSelection ? Math.floor((selectedIndex + model.lead) / 7) : 0;\n  const selectedRow = hasSelection ? (selectedIndex + model.lead) % 7 : 0;\n  const summary = `${formats.number.format(model.total)} ${noun(model.total, unit)} in ${period}`;\n\n  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")}>\n    <div className={styles.header}>\n      <p className={styles.summary}>\n        <span className={styles.total}><RollingNumber value={model.total} locale={locale} reduced={reduced} /> {noun(model.total, unit)}</span>{\" \"}\n        <span className={styles.period}>in <RiseText text={period} reduced={reduced} /></span>\n        <span className={styles.srOnly} role=\"status\">{summary}</span>\n      </p>\n      {actions && <div className={styles.actions}>{actions}</div>}\n    </div>\n\n    <div ref={scrollerRef} className={styles.scroller}>\n      <div className={styles.canvas} style={{ \"--weeks\": model.weeks } as CSSProperties}>\n        <div className={styles.months} aria-hidden=\"true\">\n          {model.months.map((month, order) => <span key={`${month.month % 12}-${model.months.findIndex(item => item.month % 12 === month.month % 12) === order ? 0 : 1}`} className={styles.month} style={{ \"--col\": month.col } as CSSProperties}>{month.label}</span>)}\n        </div>\n        <div className={styles.weekdays} aria-hidden=\"true\">{weekdayLabels.map((text, row) => <span key={row} className={styles.weekday}>{text}</span>)}</div>\n        <div ref={plotRef} className={styles.plot}>\n          <div ref={gridRef} role=\"grid\" aria-label={label} aria-readonly=\"true\" aria-describedby={`${id}-legend`} className={styles.grid}\n            data-reveal={reveal} data-direction={range.direction < 0 ? \"back\" : undefined} data-highlight={highlight ?? undefined}\n            onPointerOver={onGridPointerOver} onPointerLeave={onGridPointerLeave} onFocus={onGridFocus} onBlur={onGridBlur} onKeyDown={onGridKeyDown}>\n            {cells}\n          </div>\n          <span ref={ringRef} className={styles.ring} data-shown={ringShown || undefined} style={{ \"--col\": selectedCol, \"--row\": selectedRow } as CSSProperties} aria-hidden=\"true\" />\n        </div>\n      </div>\n    </div>\n\n    <div className={styles.legend}>\n      <span id={`${id}-legend`} className={styles.srOnly}>Darker squares mean more {unit.other}. Levels: {ranges.join(\", \")}.</span>\n      <span className={styles.caption} aria-live=\"polite\">\n        <RiseText text={caption} reduced={reduced}>{caption && <><span className={styles.captionLong}>{caption}</span><span className={styles.captionShort}>{dayCount}</span></>}</RiseText>\n        <span className={styles.srOnly}>{caption}</span>\n      </span>\n      <span className={styles.scale}>\n      <span className={styles.legendText} aria-hidden=\"true\">Less</span>\n      <div className={styles.swatches} role=\"group\" aria-label=\"Highlight days by level\" onKeyDown={onLegendKeyDown}>\n        {LEVELS.map(level => <button key={level} type=\"button\" className={styles.swatch} data-level-key={level} data-level={level} tabIndex={level === legendFocus ? 0 : -1}\n          aria-pressed={pinned === level} aria-label={`Highlight days with ${ranges[level]}`}\n          onClick={() => setPinned(current => current === level ? null : level)}\n          onPointerEnter={() => previewLevel(level)} onPointerLeave={() => previewLevel(null)}\n          onFocus={() => { setLegendFocus(level); previewLevel(level); }} onBlur={() => previewLevel(null)} />)}\n      </div>\n      <span className={styles.legendText} aria-hidden=\"true\">More</span>\n      </span>\n    </div>\n\n    <motion.div className={styles.tip} style={{ x: tipX, y: tipY }} aria-hidden=\"true\">\n      <motion.div className={styles.bubble} style={{ x: \"-50%\" }} initial={false} animate={open && tip ? { opacity: 1, scale: 1 } : { opacity: 0, scale: reduced ? 1 : .96 }}\n        transition={reduced ? { duration: open ? motionTokens.duration.fast : motionTokens.duration.instant } : open ? { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } } : { duration: motionTokens.duration.instant, ease: standard }}>\n        <motion.span className={styles.tipBody} style={{ width: tipWidth }}>\n          <span ref={measureRef} className={styles.tipMeasure}><span className={styles.tipPrimary}>{tip?.primary}</span><span className={styles.tipSecondary}>{tip?.secondary}</span></span>\n          <AnimatePresence mode=\"popLayout\" initial={false} custom={change}>\n            {tip && <motion.span key={tip.key} className={styles.tipLines} custom={change} variants={tipText(reduced)} initial=\"from\" animate=\"to\" exit=\"gone\" transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>\n              <span className={styles.tipPrimary}>{tip.primary}</span>\n              <span className={styles.tipSecondary}>{tip.secondary}</span>\n            </motion.span>}\n          </AnimatePresence>\n        </motion.span>\n      </motion.div>\n    </motion.div>\n  </div>;\n}\n\nexport default ActivityHeatmap;\n"
    },
    {
      "path": "registry/components/activity-heatmap/activity-heatmap.module.css",
      "type": "registry:component",
      "target": "@components/xzero/activity-heatmap/activity-heatmap.module.css",
      "content": "/* Levels are tints of the accent over a neutral empty square, so the grid follows the selected accent and both themes. */\n.root { --level-0: color-mix(in oklab, var(--foreground) 6.5%, var(--surface)); --level-1: color-mix(in oklab, var(--accent) 26%, var(--level-0)); --level-2: color-mix(in oklab, var(--accent) 50%, var(--level-0)); --level-3: color-mix(in oklab, var(--accent) 76%, var(--level-0)); --level-4: var(--accent); container-type: inline-size; position: relative; display: grid; min-width: 0; gap: var(--space-4); color: var(--foreground); }\n.header { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-4); }\n.summary { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; column-gap: .3em; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.total { color: var(--foreground); font-weight: 500; white-space: nowrap; }\n.period { white-space: nowrap; }\n.actions { display: flex; flex: none; align-items: center; }\n.rollingFrame { display: inline-flex; justify-content: flex-end; overflow: clip; overflow-clip-margin: .2em; }\n.rolling { display: inline-flex; flex: none; }\n/* Each place is its own window, so a turning digit clips at the line instead of brushing the next line of text. */\n.place { position: relative; display: inline-flex; overflow: clip; overflow-clip-margin: .15em; }\n.placeChar, .riseLine { display: inline-block; }\n.rise { position: relative; display: inline-flex; }\n\n/* The scroller starts at its right edge on narrow screens (the most recent weeks) without script, through its writing direction. */\n.scroller { direction: rtl; overflow-x: auto; overflow-y: hidden; margin: -6px; padding: 6px; overscroll-behavior-x: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }\n.canvas { --gap: 3px; --label: 30px; --cell: clamp(9px, calc((100cqi - var(--label) - var(--gap) * 53) / 53), 15px); --step: calc(var(--cell) + var(--gap)); direction: ltr; display: grid; width: max-content; grid-template-columns: var(--label) auto; grid-template-rows: auto auto; column-gap: var(--gap); row-gap: var(--space-2); }\n.months { position: relative; grid-row: 1; grid-column: 2; height: 1rem; color: var(--text-muted); font-size: var(--text-xs); line-height: 1rem; }\n/* Month labels glide to their new week when a range starts on a different weekday. */\n.month { position: absolute; top: 0; left: 0; white-space: nowrap; transform: translateX(calc(var(--col) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring); }\n/* Weekday labels stay pinned while a narrow grid scrolls under them. Set --heatmap-surface when the heatmap sits on another surface. */\n.weekdays { position: sticky; z-index: 1; left: -6px; display: grid; grid-row: 1 / 3; grid-column: 1; grid-template-rows: repeat(7, var(--cell)); align-content: end; row-gap: var(--gap); margin: 0 calc(var(--gap) * -1) 0 -6px; padding: 0 var(--gap) 0 6px; background: var(--heatmap-surface, var(--surface)); color: var(--text-muted); font-size: var(--text-xs); }\n.weekday { display: flex; height: var(--cell); align-items: center; white-space: nowrap; line-height: 1; }\n.plot { position: relative; grid-row: 2; grid-column: 2; }\n.grid { display: grid; gap: var(--gap); outline: none; }\n.row { display: flex; gap: var(--gap); }\n\n/* One square per day. The wave delay rides a custom property so a reveal and a recolor sweep the same diagonal. */\n.cell { --delay: var(--wave); display: block; width: var(--cell); height: var(--cell); flex: none; border-radius: calc(var(--cell) * .28); background: var(--level-0); outline: 1.5px solid transparent; outline-offset: 1px; cursor: default; -webkit-tap-highlight-color: transparent; transition: background-color 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity var(--duration-fast) var(--ease-standard); }\n.grid[data-direction=\"back\"] .cell { --delay: var(--wave-back); }\n.cell[data-level=\"1\"] { background: var(--level-1); }\n.cell[data-level=\"2\"] { background: var(--level-2); }\n.cell[data-level=\"3\"] { background: var(--level-3); }\n.cell[data-level=\"4\"] { background: var(--level-4); }\n/* Days outside the range shrink away in the sweep and grow back when a range needs them. */\n.cell[data-empty] { transform: scale(0); background: var(--level-0); }\n.grid[data-reveal=\"hidden\"] .cell { opacity: 0; transform: scale(.5); transition: none; }\n.grid[data-reveal=\"hidden\"] .cell[data-empty] { transform: scale(0); }\n.grid[data-reveal=\"revealing\"] .cell { transition: background-color 320ms var(--ease-standard) var(--delay), transform var(--duration-spring) var(--ease-spring) var(--delay), opacity 360ms var(--ease-enter) var(--delay); }\n.grid[data-highlight] .cell:not([data-empty]) { opacity: .2; }\n.grid[data-highlight=\"0\"] .cell[data-level=\"0\"], .grid[data-highlight=\"1\"] .cell[data-level=\"1\"], .grid[data-highlight=\"2\"] .cell[data-level=\"2\"], .grid[data-highlight=\"3\"] .cell[data-level=\"3\"], .grid[data-highlight=\"4\"] .cell[data-level=\"4\"] { opacity: 1; }\n@media (hover: hover) and (pointer: fine) { .cell:not([data-empty]):hover { outline-color: color-mix(in oklab, var(--foreground) 45%, transparent); } }\n\n/* The selection ring glides between days, and follows its day when the range changes. */\n.ring { position: absolute; top: -3px; left: -3px; width: calc(var(--cell) + 6px); height: calc(var(--cell) + 6px); border: 1.5px solid var(--foreground); border-radius: calc(var(--cell) * .28 + 3px); opacity: 0; pointer-events: none; transform: translate(calc(var(--col) * var(--step)), calc(var(--row) * var(--step))); transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }\n.ring[data-shown] { opacity: 1; }\n\n.legend { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-3); color: var(--text-muted); font-size: var(--text-xs); }\n/* The caption names the previewed level; it sits apart from the scale so its changing width never nudges the swatches. */\n.caption { position: relative; min-width: 0; overflow: hidden; color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; text-overflow: ellipsis; }\n.scale { display: flex; flex: none; align-items: center; gap: var(--space-2); }\n.captionShort { display: none; }\n/* Narrow cards keep the day count and let the highlighted squares say which level it is. */\n@container (max-width: 440px) { .captionLong { display: none; } .captionShort { display: inline; } }\n.legendText { line-height: 1; }\n.swatches { display: flex; }\n/* The visible square stays the size of a day while the button keeps a comfortable target around it. */\n.swatch { position: relative; display: grid; width: 15px; height: 20px; place-items: center; padding: 0; border: 0; border-radius: 4px; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.swatch::before { content: \"\"; width: 11px; height: 11px; border-radius: 3px; background: var(--level-0); }\n.swatch[data-level=\"1\"]::before { background: var(--level-1); }\n.swatch[data-level=\"2\"]::before { background: var(--level-2); }\n.swatch[data-level=\"3\"]::before { background: var(--level-3); }\n.swatch[data-level=\"4\"]::before { background: var(--level-4); }\n.swatch[aria-pressed=\"true\"] { box-shadow: inset 0 0 0 1.5px var(--foreground); }\n\n/* One bubble for the whole grid. The outer layer glides between cells; the bubble fades and scales from its tail. */\n.tip { position: absolute; z-index: 5; top: 0; left: 0; width: 0; height: 0; pointer-events: none; }\n.bubble { position: absolute; bottom: 8px; left: 0; display: block; padding: 7px 12px 8px; border: 1px solid color-mix(in oklab, var(--background) 14%, var(--foreground)); border-radius: var(--radius-control); background: var(--foreground); color: var(--background); box-shadow: var(--shadow-raised); transform-origin: 50% 100%; will-change: transform, opacity; }\n.tipBody { position: relative; display: block; overflow: clip; overflow-clip-margin: 8px; }\n.tipMeasure { position: absolute; top: 0; left: 0; display: grid; width: max-content; visibility: hidden; }\n.tipLines { display: grid; width: max-content; }\n.tipMeasure > span, .tipLines > span { white-space: nowrap; }\n.tipPrimary { font-size: var(--text-sm); font-weight: 500; line-height: 1.3; font-variant-numeric: tabular-nums; }\n.tipSecondary { color: color-mix(in oklab, var(--background) 68%, var(--foreground)); font-size: var(--text-xs); line-height: 1.35; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .cell, .grid[data-reveal=\"revealing\"] .cell { transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n  .grid[data-reveal=\"hidden\"] .cell { transform: none; }\n  .cell[data-empty], .grid[data-reveal=\"hidden\"] .cell[data-empty] { opacity: 0; transform: none; }\n  .month, .ring { transition: opacity var(--duration-fast) var(--ease-standard); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/activity-heatmap",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart",
      "calendar"
    ],
    "docs": "https://ui.xzero.co.uk/components/activity-heatmap",
    "markdown": "https://ui.xzero.co.uk/components/activity-heatmap/markdown"
  }
}