{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "growth-ring",
  "title": "Growth Ring",
  "description": "Version history as a dendrochronological cross-section: one ring per save, ring width proportional to change size, a dark line wherever authorship changed hands, and restoring an older version previews the discard as outer rings hinging open before you confirm.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/growth-ring/component.tsx",
      "content": "\"use client\";\n\nimport { useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GrowthRing — version history as a dendrochronological cross-section. The\n// authoritative control is the listbox on the left: every fact (`v14 ·\n// Priya Shah · +340 words · 14:02 · Restore`) lives there as real text, and\n// keyboard/click drive it exactly like any other listbox. The SVG cross-\n// section beside it is pure `aria-hidden` decoration that mirrors the same\n// data geometrically — radius is the version's position in time (index,\n// strictly monotonic; never re-sorted by author), ring width is the\n// version's change magnitude clamped 2-14px, and a tight dark \"latewood\"\n// line marks every boundary where authorship changed hands. Restoring is\n// modeled as a literal cut: arming a version tilts every ring OUTSIDE it\n// open on a shared hinge and dims them, so what a restore discards is shown\n// before it happens; confirming is a distinct second step.\n// ---------------------------------------------------------------------------\n\nexport type GrowthRingVersion = {\n  id: string;\n  /** short version tag, e.g. \"v14\" */\n  label: string;\n  author: string;\n  /** magnitude of the change this version made — the scalar ring width derives from */\n  delta: number;\n  /** pre-formatted display string for the delta, e.g. \"+340 words\" */\n  deltaLabel: string;\n  /** pre-formatted display string for when it saved, e.g. \"14:02\" */\n  time: string;\n};\n\nexport interface GrowthRingProps {\n  /** newest first, like a git log — the ring chart reads it in reverse (oldest at the core) */\n  versions: GrowthRingVersion[];\n  /** called once a restore is actually confirmed, with the version restored to */\n  onRestore?: (version: GrowthRingVersion) => void;\n  /** accessible name for the listbox; default \"Version history\" */\n  ariaLabel?: string;\n  className?: string;\n}\n\nconst INNER_RADIUS = 20;\nconst MIN_RING_WIDTH = 2;\nconst MAX_RING_WIDTH = 14;\n// A raw character/word delta of a few thousand (one giant paste) would\n// otherwise dwarf every other ring and flatten the chart to \"one huge band\n// plus noise\" — dividing before the clamp is what keeps normal edits in a\n// readable 2-14px band while still clamping the outlier rather than trusting\n// the divisor alone.\nconst DELTA_TO_PX = 25;\nconst PADDING_MIN = 14;\n// The discard flap tilts open about its own bottom tangent via a 3D\n// rotateX fold (see the \"discarded\" group below), not a flat in-plane\n// rotate — a flat rotate of a full closed ring just produces a second,\n// off-center ring, which is the actual bug this was chasing. The fold's\n// foreshortening still pushes the far edge outward a bit. A fixed pixel\n// padding runs out once the ring stack gets big enough — a proportional\n// floor keeps the swing inside the viewBox no matter how many versions or\n// how wide their rings get.\nconst HINGE_CLEARANCE_RATIO = 0.2;\nconst HINGE_DEG = 8;\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.max(min, Math.min(max, n));\n}\n\nfunction initials(name: string) {\n  const parts = name.trim().split(/\\s+/).filter(Boolean);\n  if (parts.length === 0) return \"?\";\n  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n}\n\nfunction plural(n: number, word: string) {\n  return `${n} ${word}${n === 1 ? \"\" : \"s\"}`;\n}\n\n// A near-full circular arc, used only as an invisible <textPath> guide for\n// the focused ring's author-initials label — never rendered with a stroke.\nfunction arcGuide(cx: number, cy: number, r: number) {\n  return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.01} ${cy - r}`;\n}\n\ntype Ring = {\n  id: string;\n  version: GrowthRingVersion;\n  chronoIndex: number; // 0 = oldest\n  inner: number;\n  outer: number;\n  mid: number;\n  width: number;\n  handover: boolean; // author differs from the previous (older) version\n};\n\nfunction buildRings(versions: GrowthRingVersion[]): Ring[] {\n  const chrono = [...versions].reverse(); // oldest first — radius follows time, nothing else\n  let r = INNER_RADIUS;\n  return chrono.map((v, i) => {\n    const width = clamp(Math.round(Math.abs(v.delta) / DELTA_TO_PX), MIN_RING_WIDTH, MAX_RING_WIDTH);\n    const inner = r;\n    const outer = r + width;\n    r = outer;\n    const handover = i > 0 && chrono[i - 1].author !== v.author;\n    return { id: v.id, version: v, chronoIndex: i, inner, outer, mid: (inner + outer) / 2, width, handover };\n  });\n}\n\nexport function GrowthRing({\n  versions,\n  onRestore,\n  ariaLabel = \"Version history\",\n  className = \"\",\n}: GrowthRingProps) {\n  const uid = useId();\n  const [items, setItems] = useState(versions);\n  const [activeId, setActiveId] = useState(items[0]?.id ?? \"\");\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [armedId, setArmedId] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const optionRefs = useRef(new Map<string, HTMLLIElement>());\n\n  const rings = useMemo(() => buildRings(items), [items]);\n  const outerMost = rings.length ? rings[rings.length - 1].outer : INNER_RADIUS;\n  const padding = Math.max(PADDING_MIN, Math.round(outerMost * HINGE_CLEARANCE_RATIO));\n  const size = outerMost + padding;\n  const cx = size;\n  const cy = size;\n  const hingeX = cx;\n  const hingeY = cy + outerMost; // bottom tangent of the whole disc\n\n  const armedRing = armedId ? rings.find((r) => r.id === armedId) ?? null : null;\n  const discardedRings = armedRing ? rings.filter((r) => r.chronoIndex > armedRing.chronoIndex) : [];\n  const keptRings = armedRing ? rings.filter((r) => r.chronoIndex <= armedRing.chronoIndex) : rings;\n  const discardedAuthors = new Set(discardedRings.map((r) => r.version.author));\n  const discardCount = discardedRings.length;\n\n  function arm(id: string) {\n    const idx = items.findIndex((v) => v.id === id);\n    if (idx <= 0) return; // the newest version is already current — nothing to preview or restore\n    setArmedId(id);\n    const target = items[idx];\n    const authorsAhead = new Set(items.slice(0, idx).map((v) => v.author));\n    setAnnouncement(\n      `Restoring to ${target.label} removes ${plural(idx, \"newer version\")} by ${plural(authorsAhead.size, \"author\")}.`\n    );\n  }\n\n  function disarm(announce: boolean) {\n    setArmedId(null);\n    if (announce) setAnnouncement(\"Restore cancelled.\");\n  }\n\n  function commit() {\n    if (!armedRing) return;\n    const idx = items.findIndex((v) => v.id === armedRing.id);\n    if (idx < 0) return;\n    const restored = items[idx];\n    const next = items.slice(idx); // the cut: everything newer than the restored version is gone\n    setItems(next);\n    setArmedId(null);\n    setActiveId(restored.id);\n    setAnnouncement(`Restored to ${restored.label}.`);\n    onRestore?.(restored);\n  }\n\n  function focusOption(id: string) {\n    setActiveId(id);\n    optionRefs.current.get(id)?.focus();\n  }\n\n  function handleOptionKeyDown(e: React.KeyboardEvent<HTMLLIElement>, id: string) {\n    const idx = items.findIndex((v) => v.id === id);\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      const next = items[Math.min(idx + 1, items.length - 1)];\n      if (next) focusOption(next.id);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      const prev = items[Math.max(idx - 1, 0)];\n      if (prev) focusOption(prev.id);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      if (items[0]) focusOption(items[0].id);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      const last = items[items.length - 1];\n      if (last) focusOption(last.id);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      arm(id);\n    } else if (e.key === \"Escape\" && armedId) {\n      e.preventDefault();\n      disarm(true);\n    }\n  }\n\n  function ringStroke(chronoIndex: number) {\n    return chronoIndex % 2 === 0 ? \"var(--ns-muted)\" : \"var(--background)\";\n  }\n\n  function renderRingGroup(group: Ring[], isCutFace: boolean) {\n    return group.map((ring) => {\n      const pulsed = ring.id === focusedId || ring.id === hoveredId;\n      const strokeWidth = pulsed ? ring.width + 3 : ring.width;\n      const showLabel = ring.id === focusedId;\n      const guideId = `${uid}-arc-${ring.chronoIndex}`;\n      return (\n        <g key={ring.id}>\n          <circle\n            cx={cx}\n            cy={cy}\n            r={ring.mid}\n            fill=\"none\"\n            stroke={ringStroke(ring.chronoIndex)}\n            strokeWidth={strokeWidth}\n            className=\"ns-growth-ring\"\n          />\n          <circle\n            cx={cx}\n            cy={cy}\n            r={ring.inner}\n            fill=\"none\"\n            stroke={ring.handover ? \"var(--foreground)\" : \"var(--border)\"}\n            strokeWidth={ring.handover ? 1.6 : 1}\n          />\n          {showLabel ? (\n            <>\n              <path id={guideId} d={arcGuide(cx, cy, ring.mid)} fill=\"none\" stroke=\"none\" />\n              <text\n                className=\"font-mono\"\n                fontSize={Math.max(7, Math.min(9, ring.width))}\n                fill=\"var(--foreground)\"\n              >\n                <textPath href={`#${guideId}`} startOffset=\"2%\">\n                  {initials(ring.version.author)}\n                </textPath>\n              </text>\n            </>\n          ) : null}\n        </g>\n      );\n    });\n  }\n\n  return (\n    <div data-growth-root className={className}>\n      <style>{`\n.ns-growth-ring{transition:stroke-width 200ms cubic-bezier(0.16,1,0.3,1)}\n.ns-growth-outer{transition:transform 420ms cubic-bezier(0.16,1,0.3,1),opacity 420ms cubic-bezier(0.16,1,0.3,1)}\n.ns-growth-option{transition:background-color 160ms ease,border-color 160ms ease}\n@media (prefers-reduced-motion: reduce){\n  .ns-growth-ring{transition:none}\n  .ns-growth-outer{transition:opacity 150ms linear !important;transform:none !important}\n  .ns-growth-option{transition:none}\n}\n`}</style>\n\n      <div className=\"flex flex-col gap-6 sm:flex-row sm:items-start\">\n        <ul\n          role=\"listbox\"\n          aria-label={ariaLabel}\n          className=\"min-w-0 flex-1 divide-y divide-border rounded-md border border-border\"\n        >\n          {items.map((v, idx) => {\n            const isArmed = v.id === armedId;\n            const isActive = v.id === activeId;\n            const isFocused = v.id === focusedId;\n            return (\n              <li\n                key={v.id}\n                ref={(el) => {\n                  if (el) optionRefs.current.set(v.id, el);\n                  else optionRefs.current.delete(v.id);\n                }}\n                role=\"option\"\n                aria-selected={isArmed}\n                tabIndex={isActive ? 0 : -1}\n                data-growth-version={v.id}\n                onFocus={() => setFocusedId(v.id)}\n                onBlur={() => setFocusedId((f) => (f === v.id ? null : f))}\n                onMouseEnter={() => setHoveredId(v.id)}\n                onMouseLeave={() => setHoveredId((h) => (h === v.id ? null : h))}\n                onClick={() => {\n                  setActiveId(v.id);\n                  arm(v.id);\n                }}\n                onKeyDown={(e) => handleOptionKeyDown(e, v.id)}\n                className={\n                  \"ns-growth-option flex cursor-pointer flex-wrap items-baseline gap-x-2.5 gap-y-0.5 px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent \" +\n                  (isFocused\n                    ? \"border-l-2 border-ns-accent bg-surface pl-[10px]\"\n                    : \"border-l-2 border-transparent pl-[10px]\") +\n                  (isArmed ? \" bg-surface\" : \"\")\n                }\n              >\n                <span className=\"font-mono text-foreground\">{v.label}</span>\n                <span className=\"text-foreground\">{v.author}</span>\n                <span className=\"font-mono tabular-nums text-ns-muted\">{v.deltaLabel}</span>\n                <span className=\"font-mono tabular-nums text-ns-muted\">{v.time}</span>\n                <span className=\"ml-auto shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-ns-muted\">\n                  {idx === 0 ? \"Current\" : \"Restore\"}\n                </span>\n              </li>\n            );\n          })}\n        </ul>\n\n        <svg\n          aria-hidden=\"true\"\n          focusable=\"false\"\n          viewBox={`0 0 ${size * 2} ${size * 2}`}\n          className=\"h-48 w-48 shrink-0 sm:h-56 sm:w-56\"\n        >\n          <g>{renderRingGroup(keptRings, false)}</g>\n          {armedRing ? (\n            <circle\n              cx={cx}\n              cy={cy}\n              r={armedRing.outer}\n              fill=\"none\"\n              stroke=\"var(--foreground)\"\n              strokeWidth={2}\n            />\n          ) : (\n            <circle cx={cx} cy={cy} r={outerMost} fill=\"none\" stroke=\"var(--border)\" strokeWidth={1} />\n          )}\n          {discardedRings.length ? (\n            <g\n              data-discarded=\"true\"\n              className=\"ns-growth-outer\"\n              style={{\n                transformOrigin: `${hingeX}px ${hingeY}px`,\n                // A flat in-plane rotate() here was the actual bug: these\n                // are full closed rings, and rotating a closed circle about\n                // a point on its own rim in 2D just produces a second,\n                // same-radius circle with a *different center* — it reads\n                // as \"misaligned,\" not \"hinged open,\" because there's no\n                // such thing as a partial-arc 2D rotation of a full ring\n                // that stays concentric. A hinge that lifts away from the\n                // viewer needs the Z axis: rotateX about the hinge line\n                // keeps the near (bottom) edge anchored to the kept rings\n                // and tilts the far edge back/up with real foreshortening,\n                // which is what \"opening like a lid\" actually looks like.\n                transform: `perspective(560px) rotateX(-${HINGE_DEG}deg)`,\n                transformStyle: \"preserve-3d\",\n                opacity: 0.4,\n              }}\n            >\n              {renderRingGroup(discardedRings, true)}\n            </g>\n          ) : null}\n        </svg>\n      </div>\n\n      {armedRing ? (\n        <div data-growth-confirm-panel className=\"mt-4 rounded-md border border-border bg-surface p-3\">\n          <p className=\"text-sm text-foreground\">\n            This removes {plural(discardCount, \"newer version\")} by{\" \"}\n            {plural(discardedAuthors.size, \"author\")}.\n          </p>\n          <div className=\"mt-2.5 flex gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => disarm(true)}\n              className=\"rounded-sm border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n            >\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              data-growth-confirm\n              onClick={commit}\n              className=\"rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n            >\n              Confirm restore to {armedRing.version.label}\n            </button>\n          </div>\n        </div>\n      ) : null}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/growth-ring.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "version-history",
      "restore",
      "listbox",
      "svg",
      "data-viz",
      "confirmation",
      "destructive",
      "mono",
      "accessibility"
    ],
    "instruction": "GrowthRing pairs an authoritative listbox of versions with a decorative SVG cross-section that mirrors the same data as concentric rings. Props: `versions` (`{id,label,author,delta,deltaLabel,time}[]`, newest first — the component reverses this internally so the oldest save sits at the core and radius always grows outward with time, matching the physical read of tree rings), `onRestore` (fires once a restore is actually confirmed, with the restored version), `ariaLabel`, `className`. The listbox (`role=listbox`) is the only interactive and only authoritative surface: each `role=option` renders its facts as real text in document order — label, author, delta, timestamp, then 'Current' on the newest row or 'Restore' on every older one — so an option's accessible name alone reads e.g. 'v9, Jae Lin, +76 words, Yesterday 17:12, Restore'. Roving tabindex drives keyboard nav (Up/Down/Home/End move the tabbable option and DOM focus together); Enter, Space, or a click on any option except the newest arms that version as a restore candidate. The SVG beside the list is `aria-hidden` end to end and derives everything from one governing scalar per version — its index in time. Radius is that index's cumulative position (strictly monotonic; grouping or sorting rings by author is never done, because position-equals-date is the entire point of the metaphor). Ring width is `clamp(|delta| / 25, 2, 14)`px, so an ordinary edit reads as a thin band and a giant paste still clamps to the same 14px ceiling instead of swallowing the rest of the chart. Adjacent rings alternate `--ns-muted`/`--background` fill with a 1px `--border` boundary circle between them; wherever the boundary's two neighboring versions have different authors, that boundary renders instead as a tight `--foreground` line (1.6px) — a latewood mark, readable independently of the alternating fill. The option currently holding real DOM focus gets a `--ns-accent` left rail on its row (the component's one non-button use of that token) and its matching ring gets an author-initials label in Geist Mono set along an invisible circular guide path; hovering *or* focusing a row pulses its ring by increasing `stroke-width` alone, on a 200ms transition, with no color, scale, or opacity change. Arming a version (everything but the newest) is the restore preview: an announcement text and Cancel/Confirm panel appear, the armed version's own outer edge is redrawn as a 2px `--foreground` cut line, and every ring chronologically after it — the versions a restore would discard — is regrouped into one `<g>` that folds open 8deg around a shared hinge line at the bottom of the disc — a 3D `perspective()/rotateX()` fold, not a flat 2D `rotate()`, because rotating a full closed ring in-plane just produces a second, off-center ring rather than a hinged lid — and drops to 40% opacity, so the discard is shown as a physical flap opening before anything is lost. Re-arming (clicking a different older row) just re-targets the same preview; Cancel or Escape drops it with no side effect. Confirm restore — the only other `--ns-accent` surface in the component, and a genuine second step — truncates the version list down to the armed version and everything older, calls `onRestore`, and returns to the resting, unarmed cross-section with the restored version now on top. Every arm/cancel/confirm transition writes a plain-language sentence ('Restoring to v10 removes 4 newer versions by 2 authors.', 'Restore cancelled.', 'Restored to v10.') into a single `role=status`/`aria-live=polite` region, so the destructive scope is announced in words, not left to the geometry. Under `prefers-reduced-motion: reduce` the hinge rotation is dropped entirely (the discarded rings' transform stays `none`) and only the opacity dim plays, and the hover/focus stroke-width pulse loses its transition and applies instantly — both still fully legible. No canvas, no dependency; every color used anywhere in the component is one of `--background`, `--foreground`, `--ns-muted`, `--border`, or `--ns-accent`."
  },
  "type": "registry:ui"
}