{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "heatmap-year-stipple",
  "title": "Heatmap Year Stipple",
  "description": "A GitHub-style year activity calendar where intensity is stipple density, not color: every day cell holds a handful of deterministically jittered ink dots, denser for more activity, like an engraved print. Hovering or arrow-keying through days opens a zoomed loupe with the exact count, and each new day's dots spring into the loupe with a brief ink-settle scatter, like fresh ink landing on paper.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/heatmap-year-stipple/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// StippleYear — a GitHub-style year activity calendar where intensity is\n// STIPPLE DENSITY, not color: every day cell holds 0-8 tiny ink dots placed\n// by a deterministic, date-seeded jitter (an engraving/print metaphor, pure\n// var(--foreground) on var(--background)). Dot positions are generated once\n// in NORMALIZED [0,1] space per date (`stippleUnits`) and only scaled to\n// pixels at render time — the loupe renders the exact same normalized\n// points at 2x, so it's a literal zoomed copy, not a re-roll.\n//\n// Every cell is a focusable role=\"button\" (roving tabindex — one cell has\n// tabIndex 0 at a time, arrow keys move which one) so the automatic\n// hover/press/focus screenshot pass and the a11y audit exercise the same\n// interaction keyboard users get. Hover or focus opens a fixed loupe panel\n// plus a Geist Mono tooltip; the cell's own aria-label already carries the\n// same fact in text for anyone not using either.\n// ---------------------------------------------------------------------------\n\nexport interface StippleYearProps {\n  /** Map of ISO date (\"YYYY-MM-DD\") to an activity count. */\n  values?: Record<string, number>;\n  /** Last day of the 371-day window. Defaults to today. */\n  endDate?: Date;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst CELL = 16;\nconst GAP = 4;\nconst STEP = CELL + GAP;\nconst MARGIN = 1.9;\nconst MAX_DOTS = 8;\nconst DOT_R = 1.35;\nconst LOUPE_CELL = 68;\nconst LOUPE_MARGIN = 6;\nconst LOUPE_DOT_R = 3.2;\n/** Fixed footprint of the side panel that holds the loupe — always reserved\n *  (even when nothing is hovered) so it never overlaps the grid's own cells\n *  and never shifts the component's box on hover. Wide enough for the\n *  longest realistic one-line caption (\"20 contributions - Dec 25\"). */\nconst LOUPE_PANEL_W = 172;\nconst LEFT_LABEL_W = 30;\nconst TOP_LABEL_H = 20;\n\n/** Loupe ink-settle — every time a new cell is magnified into the loupe,\n *  its dots don't just appear: each one starts flung a short distance from\n *  its resting position (offset derived from the same per-dot mulberry32\n *  stream that placed it, so it's deterministic and replays identically for\n *  a given date, never Math.random()) and springs into place on an\n *  ease-out-expo curve, staggered a few ms apart per dot so the settle\n *  reads as ink landing on paper rather than one uniform pop. Kept well\n *  clear of the loupe's own 160ms zoom-in (LOUPE_SETTLE_MS is longer and\n *  starts from the same frame) so the two read as two distinct events, not\n *  one blurred bloom. */\nconst LOUPE_SETTLE_MS = 460;\nconst LOUPE_SETTLE_MAX = 22;\nconst LOUPE_SETTLE_STAGGER = 18;\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst WEEKDAY_LABELS = [\"\", \"Mon\", \"\", \"Wed\", \"\", \"Fri\", \"\"];\n\nfunction startOfDay(d: Date): Date {\n  const c = new Date(d);\n  c.setHours(0, 0, 0, 0);\n  return c;\n}\nfunction addDays(d: Date, n: number): Date {\n  const c = new Date(d);\n  c.setDate(c.getDate() + n);\n  return c;\n}\nfunction isoDate(d: Date): string {\n  const y = d.getFullYear();\n  const m = String(d.getMonth() + 1).padStart(2, \"0\");\n  const day = String(d.getDate()).padStart(2, \"0\");\n  return `${y}-${m}-${day}`;\n}\n\nfunction hashStr(s: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\nfunction mulberry32(seed: number) {\n  let a = seed;\n  return function () {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction dotCountFor(value: number, max: number): number {\n  if (value <= 0) return 0;\n  return Math.min(MAX_DOTS, 1 + Math.round((value / max) * (MAX_DOTS - 1)));\n}\n\n/** Normalized [0,1] dot positions for a date, deterministic and reused at any scale. */\nfunction stippleUnits(iso: string, count: number): { x: number; y: number }[] {\n  const rng = mulberry32(hashStr(iso));\n  return Array.from({ length: count }, () => ({ x: rng(), y: rng() }));\n}\n\n/** Per-dot starting offset for the loupe's ink-settle entrance — a distinct\n *  PRNG stream (own hash suffix) so it never correlates with the dot's own\n *  resting position, but is still fully deterministic for a given date. */\nfunction loupeSettleOffsets(iso: string, count: number): { dx: number; dy: number }[] {\n  const rng = mulberry32(hashStr(`${iso}:settle`));\n  return Array.from({ length: count }, () => {\n    const angle = rng() * Math.PI * 2;\n    const dist = LOUPE_SETTLE_MAX * (0.4 + rng() * 0.6);\n    return { dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist };\n  });\n}\n\ninterface DayCell {\n  date: Date;\n  iso: string;\n  inRange: boolean;\n  col: number;\n  row: number;\n}\n\nexport function StippleYear({ values = {}, endDate, className = \"\" }: StippleYearProps) {\n  const today = useMemo(() => startOfDay(endDate ?? new Date()), [endDate]);\n  const loupeClipId = useId();\n\n  const { cells, weeks, monthLabels } = useMemo(() => {\n    const roughStart = addDays(today, -364);\n    const start = addDays(roughStart, -roughStart.getDay());\n    const totalDays = Math.round((today.getTime() - start.getTime()) / 86400000) + 1;\n    const weeksN = Math.ceil(totalDays / 7);\n    const list: DayCell[] = [];\n    for (let i = 0; i < weeksN * 7; i++) {\n      const date = addDays(start, i);\n      list.push({ date, iso: isoDate(date), inRange: date <= today, col: Math.floor(i / 7), row: i % 7 });\n    }\n    const labels: { col: number; text: string }[] = [];\n    let lastMonth = -1;\n    for (const c of list) {\n      if (c.row === 0 && c.date.getDate() <= 7 && c.date.getMonth() !== lastMonth) {\n        labels.push({ col: c.col, text: MONTHS[c.date.getMonth()]! });\n        lastMonth = c.date.getMonth();\n      }\n    }\n    return { cells: list, weeks: weeksN, monthLabels: labels };\n  }, [today]);\n\n  const maxValue = useMemo(\n    () => Math.max(1, ...cells.filter((c) => c.inRange).map((c) => values[c.iso] ?? 0)),\n    [cells, values]\n  );\n\n  const [activeIndex, setActiveIndex] = useState(() => {\n    const idx = cells.findIndex((c) => c.inRange && c.iso === isoDate(today));\n    return idx === -1 ? 0 : idx;\n  });\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const cellRefs = useRef<(SVGRectElement | null)[]>([]);\n\n  const reducedMotionRef = useRef(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedMotionRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  // Column/row-aware, not flat-index ±1/±7 — at a row boundary (top/bottom\n  // of a week column) a flat index just wraps into the adjacent week's\n  // opposite row, which reads as a diagonal jump instead of stopping.\n  const focusCell = (col: number, row: number) => {\n    if (row < 0 || row > 6 || col < 0 || col >= weeks) return;\n    const next = col * 7 + row;\n    if (!cells[next]?.inRange) return;\n    setActiveIndex(next);\n    cellRefs.current[next]?.focus();\n  };\n\n  const loupeIdx = hoverIndex;\n  const loupeCell = loupeIdx !== null ? cells[loupeIdx] : null;\n  const loupeValue = loupeCell ? (values[loupeCell.iso] ?? 0) : 0;\n  const loupeUnits = loupeCell ? stippleUnits(loupeCell.iso, dotCountFor(loupeValue, maxValue)) : [];\n  const loupeSettle = loupeCell ? loupeSettleOffsets(loupeCell.iso, loupeUnits.length) : [];\n\n  const labelFor = (c: DayCell) => {\n    const v = values[c.iso] ?? 0;\n    const noun = v === 1 ? \"contribution\" : \"contributions\";\n    return `${v} ${noun}, ${MONTHS[c.date.getMonth()]} ${c.date.getDate()}`;\n  };\n\n  const viewW = LEFT_LABEL_W + weeks * STEP;\n  const viewH = TOP_LABEL_H + 7 * STEP;\n\n  return (\n    <div className={`ns-sy-grid inline-flex items-start gap-3 ${className}`}>\n      <style>{CSS}</style>\n      <svg\n        className=\"ns-sy-canvas\"\n        viewBox={`0 0 ${viewW} ${viewH}`}\n        width={viewW}\n        style={{ maxWidth: \"100%\" }}\n        focusable=\"false\"\n      >\n        <g aria-hidden=\"true\" className=\"font-mono\" style={{ fontSize: 8.5 }}>\n          {monthLabels.map((m, i) => (\n            <text key={i} x={LEFT_LABEL_W + m.col * STEP} y={TOP_LABEL_H - 4} fill=\"var(--ns-muted)\">\n              {m.text}\n            </text>\n          ))}\n          {WEEKDAY_LABELS.map((label, row) =>\n            label ? (\n              <text key={row} x={0} y={TOP_LABEL_H + row * STEP + CELL - 2} fill=\"var(--ns-muted)\">\n                {label}\n              </text>\n            ) : null\n          )}\n        </g>\n\n        {cells.map((c, i) => {\n          if (!c.inRange) return null;\n          const value = values[c.iso] ?? 0;\n          const count = dotCountFor(value, maxValue);\n          const units = stippleUnits(c.iso, count);\n          const x = LEFT_LABEL_W + c.col * STEP;\n          const y = TOP_LABEL_H + c.row * STEP;\n          const isActive = i === activeIndex;\n          const isHovered = i === hoverIndex;\n          return (\n            <g key={c.iso}>\n              <rect\n                ref={(el) => {\n                  cellRefs.current[i] = el;\n                }}\n                role=\"button\"\n                tabIndex={isActive ? 0 : -1}\n                aria-label={labelFor(c)}\n                x={x}\n                y={y}\n                width={CELL}\n                height={CELL}\n                rx={2}\n                fill=\"transparent\"\n                stroke={isHovered ? \"var(--foreground)\" : \"var(--border)\"}\n                strokeWidth={isHovered ? 1.25 : 0.9}\n                strokeOpacity={isHovered ? 0.7 : 0.6}\n                className=\"ns-sy-cell\"\n                style={{ outlineOffset: 1 }}\n                onPointerEnter={() => setHoverIndex(i)}\n                onPointerLeave={() => setHoverIndex((cur) => (cur === i ? null : cur))}\n                onFocus={() => {\n                  setActiveIndex(i);\n                  setHoverIndex(i);\n                }}\n                onBlur={() => setHoverIndex((cur) => (cur === i ? null : cur))}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowUp\") {\n                    e.preventDefault();\n                    focusCell(c.col, c.row - 1);\n                  } else if (e.key === \"ArrowDown\") {\n                    e.preventDefault();\n                    focusCell(c.col, c.row + 1);\n                  } else if (e.key === \"ArrowLeft\") {\n                    e.preventDefault();\n                    focusCell(c.col - 1, c.row);\n                  } else if (e.key === \"ArrowRight\") {\n                    e.preventDefault();\n                    focusCell(c.col + 1, c.row);\n                  }\n                }}\n              />\n              <g className=\"ns-sy-dots\">\n                {units.map((u, k) => (\n                  <circle\n                    key={k}\n                    cx={x + MARGIN + u.x * (CELL - 2 * MARGIN)}\n                    cy={y + MARGIN + u.y * (CELL - 2 * MARGIN)}\n                    r={DOT_R}\n                    fill=\"var(--foreground)\"\n                    aria-hidden=\"true\"\n                  />\n                ))}\n              </g>\n            </g>\n          );\n        })}\n      </svg>\n\n      {/* Fixed-footprint side panel, always rendered so the component's own\n          box never resizes on hover — only its contents toggle. It sits\n          beside the grid (not layered on top), so it can never occlude the\n          cell that's actually being hovered. */}\n      <div\n        className=\"pointer-events-none flex shrink-0 flex-col items-center justify-center gap-1 rounded-[6px] border border-border bg-background p-1.5 shadow-sm\"\n        style={{ width: LOUPE_PANEL_W, height: viewH, visibility: loupeCell ? \"visible\" : \"hidden\" }}\n      >\n        {loupeCell && (\n          <>\n            <svg\n              key={loupeCell.iso}\n              viewBox={`0 0 ${LOUPE_CELL} ${LOUPE_CELL}`}\n              width={LOUPE_CELL}\n              height={LOUPE_CELL}\n              className=\"ns-sy-loupe\"\n              aria-hidden=\"true\"\n            >\n              <defs>\n                <clipPath id={loupeClipId}>\n                  <rect x={1} y={1} width={LOUPE_CELL - 2} height={LOUPE_CELL - 2} rx={4} />\n                </clipPath>\n              </defs>\n              <rect x={0} y={0} width={LOUPE_CELL} height={LOUPE_CELL} rx={4} fill=\"var(--background)\" stroke=\"var(--border)\" strokeWidth={1} />\n              <g clipPath={`url(#${loupeClipId})`}>\n                {loupeUnits.map((u, k) => {\n                  const settle = loupeSettle[k] ?? { dx: 0, dy: 0 };\n                  return (\n                    <circle\n                      key={k}\n                      cx={LOUPE_MARGIN + u.x * (LOUPE_CELL - 2 * LOUPE_MARGIN)}\n                      cy={LOUPE_MARGIN + u.y * (LOUPE_CELL - 2 * LOUPE_MARGIN)}\n                      r={LOUPE_DOT_R}\n                      fill=\"var(--foreground)\"\n                      className=\"ns-sy-loupe-dot\"\n                      style={\n                        {\n                          \"--sx\": `${settle.dx.toFixed(2)}px`,\n                          \"--sy\": `${settle.dy.toFixed(2)}px`,\n                          animationDelay: `${k * LOUPE_SETTLE_STAGGER}ms`,\n                        } as CSSProperties\n                      }\n                    />\n                  );\n                })}\n              </g>\n            </svg>\n            <span className=\"whitespace-nowrap font-mono text-[10px] text-foreground\">\n              {loupeValue} {loupeValue === 1 ? \"contribution\" : \"contributions\"} - {MONTHS[loupeCell.date.getMonth()]}{\" \"}\n              {loupeCell.date.getDate()}\n            </span>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-sy-cell { cursor: pointer; outline: none; transition: stroke 120ms ease-out; }\n.ns-sy-cell:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 1px; }\n@keyframes ns-sy-zoom-in { from { transform: scale(0.55); opacity: 0; } to { transform: scale(1); opacity: 1; } }\n.ns-sy-loupe { transform-origin: center; animation: ns-sy-zoom-in 160ms cubic-bezier(0.16, 1, 0.3, 1); }\n@keyframes ns-sy-ink-settle {\n  from { transform: translate(var(--sx), var(--sy)); opacity: 0.15; }\n  to { transform: translate(0, 0); opacity: 1; }\n}\n.ns-sy-loupe-dot { animation: ns-sy-ink-settle ${LOUPE_SETTLE_MS}ms cubic-bezier(0.16, 1, 0.3, 1) backwards; }\n@media (prefers-reduced-motion: reduce) {\n  .ns-sy-cell { transition: none; }\n  .ns-sy-loupe { animation: none; }\n  .ns-sy-loupe-dot { animation: none; }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/heatmap-year-stipple.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "calendar",
      "activity",
      "heatmap",
      "svg",
      "keyboard-navigation",
      "tooltip",
      "accessibility",
      "data-visualization"
    ],
    "instruction": "Build a GitHub-style year contribution calendar where activity intensity is communicated by STIPPLE DENSITY rather than color — a monochrome ink-on-paper engraving metaphor using only var(--foreground) dots on var(--background). Layout: a week-columns x weekday-rows grid (Sunday-aligned like GitHub, 16px cells, 4px gaps), built from a `values` prop (Record<ISO date string, activity count>) and an `endDate` prop (defaults to today) covering the trailing ~365 days rounded out to full weeks (so 52-53 columns depending on alignment). Render everything in one SVG for the whole grid: month abbreviations in Geist Mono above the column where each new month's first week starts, and 'Mon'/'Wed'/'Fri' row labels to the left (GitHub's convention of skipping Sun/Tue/Thu/Sat labels). Each day's dot count is derived from its value via `count = value<=0 ? 0 : min(8, 1 + round((value/max)*7))` where max is the largest value in the visible range. Dot POSITIONS are generated in normalized [0,1] space by a small deterministic PRNG (an FNV-1a-style string hash of the ISO date feeding a mulberry32 generator) — same date always produces the same pattern — and only scaled to actual pixel coordinates at render time (position = margin + unit * (cellSize - 2*margin)). This normalized-first design is what lets the loupe be a literal zoomed copy: it re-renders the SAME normalized units at a larger cell size (68px vs 16px) rather than regenerating a new pattern. Every day cell is a focusable SVG rect (role=\"button\", roving tabindex — exactly one cell has tabIndex 0 at a time, matching whichever cell is currently 'active'; every other cell is -1) with an aria-label stating the fact directly as text: 'N contributions, Mon D' (or 'No contributions, Mon D' style pluralization for 0/1). Arrow keys move the active cell and call .focus() on the new one: Left/Right move to the same weekday in the adjacent week (column ±1), Up/Down move to the adjacent weekday within the same week (row ±1), resolved by column/row rather than flat-index arithmetic so a move at the top/bottom edge of a week column stops instead of wrapping diagonally into the neighboring week — both clamped to the valid date range and to cells that actually exist (no wrapping onto out-of-range trailing cells in the final partial week). Hovering OR focusing a cell fills a fixed-footprint loupe panel in a permanently reserved slot BESIDE the grid (never overlaid on top of it — an overlay large enough to be legible also covers the cell it's magnifying, which reads as the hover jumping elsewhere) showing that day's stipple pattern at ~4x scale plus a Geist Mono caption reading '14 contributions - Mar 4' (or the exact singular/plural + date for whatever cell is active). The panel occupies the same box whether or not anything is hovered — only its contents toggle via `visibility` — so the component's own size never changes on hover and no cell ever shifts under the cursor. The loupe's entrance is a real animation — scale 0.55->1 and opacity 0->1 over 160ms with an ease-out-expo-style curve, replayed on every new cell via a React `key` keyed to the date so the keyframe restarts each time. Hovered/focused cells also get a slightly heavier cell-border stroke than the resting hairline var(--border), and keyboard focus additionally gets a var(--ns-accent) focus-visible outline distinct from the hover stroke. The main grid's stipple pattern itself is never animated — only the loupe moves. The loupe has its own second, distinct entrance: every time it re-magnifies a new cell, its dots don't just appear at rest — each one starts flung a short distance from its resting position (a per-dot offset drawn from its own deterministic PRNG stream, seeded off the same ISO date so it's stable and never Math.random()) and springs into place on an ease-out-expo curve, staggered a few milliseconds apart per dot, reading as ink landing and settling on paper rather than a uniform pop-in. This settle runs longer than the loupe box's own 160ms zoom-in (roughly 460ms, vs the box's scale/opacity entrance) so the two are legible as two separate events instead of one blurred bloom, and it fires on every hover AND every keyboard-focus change alike (both drive the same `hoverIndex`), so keyboard users see identical motion to pointer users. The scattered dots are clipped to the loupe box's own rounded-rect bounds so none can visually escape it even at their widest starting offset. Implemented with a CSS `@keyframes` animation driving `transform: translate()` from a per-dot `--sx`/`--sy` custom-property starting offset to `(0, 0)`, `animation-delay` staggered per dot index, and `animation-fill-mode: backwards` so a delayed dot sits at its scattered start position rather than its resting one during the delay — no React state, no per-frame JS, the browser's own compositor drives it. `prefers-reduced-motion: reduce` removes both the loupe's zoom-in and its ink-settle animation entirely (dots simply render at their resting position) while leaving the stipple pattern and every non-motion interaction untouched. No color scale anywhere, no canvas — pure SVG, zero dependencies."
  },
  "type": "registry:ui"
}