{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stratum",
  "type": "registry:ui",
  "title": "Stratum",
  "description": "A scroll-linked vertical timeline: dated entries with sticky pinned titles, a progress beam that draws down the spine, and node dots that ignite with an escapement pop as the beam reaches them.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/scroll/stratum/stratum.tsx",
      "type": "registry:ui",
      "target": "components/crucible/stratum.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useScroll,\n  useTransform,\n  useMotionValueEvent,\n  type TargetAndTransition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/** A single dated entry on the timeline. */\nexport interface StratumEntry {\n  /**\n   * Small label rendered above the title — a date, version tag, or step marker.\n   * Optional; omit for an undated milestone.\n   */\n  date?: React.ReactNode;\n  /** Entry heading. Pins as a sticky title while its content scrolls past. */\n  title: React.ReactNode;\n  /** Body content that scrolls beneath the pinned title. */\n  content: React.ReactNode;\n  /** Stable identity for the list key. Falls back to the entry index. */\n  id?: string;\n}\n\nexport interface StratumProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /**\n   * The timeline entries, top to bottom. Beautiful with zero props — a sample\n   * changelog renders when omitted.\n   */\n  entries?: StratumEntry[];\n  /** Which edge the spine sits on. `center` alternates content side to side. @default \"left\" */\n  rail?: \"left\" | \"center\" | \"right\";\n  /** Live-beam + ignited-node color (the one cool accent). @default \"#60a5fa\" */\n  accentColor?: string;\n  /** Dim track + cold (un-ignited) node color. @default \"#52525b\" */\n  railColor?: string;\n  /** Beam / track stroke width in px. @default 2 */\n  beamWidth?: number;\n  /** Node marker shape. @default \"dot\" */\n  marker?: \"dot\" | \"ring\" | \"diamond\" | \"square\";\n  /** Node marker size in px. @default 14 */\n  markerSize?: number;\n  /** Sticky title offset from the top of the scroll container, in px. @default 16 */\n  stickyOffset?: number;\n  /**\n   * Backdrop painted behind a pinned title so the entry body scrolling beneath\n   * it never collides with the title text. Match it to your surface (any CSS\n   * color); pass `\"transparent\"` to opt out. @default \"rgb(10 10 10 / 0.88)\"\n   */\n  stickyBackground?: string;\n  /** Per-entry content reveal as it enters view. @default \"slide\" */\n  reveal?: \"slide\" | \"fade\" | \"none\";\n  /**\n   * Scroll container to track instead of the window. Pass a ref to an\n   * `overflow-y-auto` ancestor so the beam scrubs against THAT container\n   * (e.g. inside a boxed panel). @default the viewport\n   */\n  root?: React.RefObject<HTMLElement | null>;\n  /** Heading level rendered for each entry title (semantic outline). @default 3 */\n  headingLevel?: 2 | 3 | 4 | 5 | 6;\n}\n\nconst DEFAULT_ACCENT = \"#60a5fa\";\nconst DEFAULT_RAIL = \"#52525b\";\n\nconst DEFAULT_ENTRIES: StratumEntry[] = [\n  {\n    date: \"v2.4 — March\",\n    title: \"Streaming search\",\n    content:\n      \"Results now surface as you type, with fuzzy matching across titles, tags, and body text. The index rebuilds incrementally in a worker, so typing never blocks.\",\n  },\n  {\n    date: \"v2.5 — April\",\n    title: \"Shared views\",\n    content:\n      \"Save a filtered view and share it with the whole workspace. Everyone sees the same board, live, with cursors and selection presence.\",\n  },\n  {\n    date: \"v2.6 — May\",\n    title: \"Offline drafts\",\n    content:\n      \"Edits queue locally and sync the moment you reconnect. Conflicts resolve field by field, so two people rarely clobber each other's work.\",\n  },\n  {\n    date: \"v2.7 — June\",\n    title: \"Granular roles\",\n    content:\n      \"The old admin toggle is gone. Grant access per project, per environment, or per key — and audit every grant from one place.\",\n  },\n  {\n    date: \"v3.0 — July\",\n    title: \"Audit stream\",\n    content:\n      \"Every change is recorded with who, what, and when. Export on demand, or stream the log straight to your SIEM of choice.\",\n  },\n];\n\n/** Reveal target/rest states per preset — used as inline transitions, not variants. */\nfunction revealFrames(preset: NonNullable<StratumProps[\"reveal\"]>): {\n  hidden: TargetAndTransition;\n  shown: TargetAndTransition;\n} {\n  switch (preset) {\n    case \"fade\":\n      return { hidden: { opacity: 0 }, shown: { opacity: 1 } };\n    case \"none\":\n      return { hidden: { opacity: 1 }, shown: { opacity: 1 } };\n    case \"slide\":\n    default:\n      return { hidden: { opacity: 0, y: 14 }, shown: { opacity: 1, y: 0 } };\n  }\n}\n\ninterface NodeMarkerProps {\n  lit: boolean;\n  reduced: boolean;\n  marker: NonNullable<StratumProps[\"marker\"]>;\n  size: number;\n  accent: string;\n  cold: string;\n}\n\n/**\n * A single rail node. Cold slate until the beam reaches it, then it seats with a\n * crisp escapement pop — the hot core springs in with a 2px overshoot and locks,\n * a halo flares once, and it stays lit thereafter.\n */\nfunction NodeMarker({ lit, reduced, marker, size, accent, cold }: NodeMarkerProps) {\n  const radius =\n    marker === \"diamond\" || marker === \"square\" ? \"18%\" : \"9999px\";\n  const hollow = marker === \"ring\";\n\n  // Reduced motion: land lit and static, no springs, no flare.\n  const coreTransition = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 720, damping: 15, mass: 0.5 };\n\n  return (\n    <span\n      className=\"relative inline-flex items-center justify-center\"\n      style={{ width: size, height: size }}\n    >\n      {/* One-shot ignite flare. Mounted only once lit, keyed so it plays exactly\n          once — unrelated re-renders (other nodes igniting) never replay it. */}\n      {!reduced && lit && (\n        <motion.span\n          key=\"flare\"\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0\"\n          style={{ borderRadius: radius, background: accent }}\n          initial={{ opacity: 0.55, scale: 0.6 }}\n          animate={{ opacity: 0, scale: 2.4 }}\n          transition={{ duration: 0.6, ease: [0.2, 0.7, 0.3, 1] }}\n        />\n      )}\n\n      {/* Outer marker: outline that warms from slate to accent. */}\n      <motion.span\n        aria-hidden\n        className={cn(\n          \"absolute inset-0 transition-none\",\n          marker === \"diamond\" && \"rotate-45\"\n        )}\n        style={{\n          borderRadius: radius,\n          borderStyle: \"solid\",\n          borderWidth: Math.max(1.5, size * 0.14),\n          background: hollow ? \"transparent\" : \"#0a0a0a\",\n        }}\n        initial={false}\n        animate={{\n          borderColor: lit ? accent : cold,\n          boxShadow: lit\n            ? `0 0 ${size * 0.9}px ${accent}, 0 0 ${size * 0.35}px ${accent}`\n            : `0 0 0px ${accent}00`,\n        }}\n        transition={reduced ? { duration: 0 } : { duration: 0.45, ease: \"easeOut\" }}\n      />\n\n      {/* Hot core: springs in with overshoot when lit. Absent for the ring marker. */}\n      {!hollow && (\n        <motion.span\n          aria-hidden\n          className={cn(\"relative\", marker === \"diamond\" && \"rotate-45\")}\n          style={{\n            width: \"46%\",\n            height: \"46%\",\n            borderRadius: marker === \"diamond\" || marker === \"square\" ? \"12%\" : \"9999px\",\n            background: lit\n              ? `radial-gradient(circle at 50% 40%, #ffffff, ${accent})`\n              : cold,\n          }}\n          initial={false}\n          animate={{ scale: lit ? 1 : 0 }}\n          transition={coreTransition}\n        />\n      )}\n    </span>\n  );\n}\n\n/**\n * Stratum — a scroll-linked vertical timeline. Dated entries run down a rail\n * with sticky titles that pin while their content scrolls past. A progress beam\n * draws down the spine bound to scroll position (bright above the read point,\n * dim below), its leading edge carrying a faint white meniscus head. Node dots\n * ignite as the beam reaches them — cold slate seating into an accent glow with\n * an escapement pop — and stay lit.\n *\n * Semantic and accessible: renders an ordered list of entries with real\n * headings, so the timeline is navigable and reads correctly to assistive tech.\n * Under `prefers-reduced-motion` the beam is fully drawn, every node is lit, and\n * content sits in normal flow with no scrub or reveal.\n *\n * SSR-safe: no window access at module scope, deterministic markup, node\n * positions measured client-side. Perf: a single scroll-scrubbed transform\n * drives the beam and node ignition (no per-node scroll listeners), one\n * IntersectionObserver drives reveals, and both tear down on unmount.\n */\nexport function Stratum({\n  entries = DEFAULT_ENTRIES,\n  rail = \"left\",\n  accentColor = DEFAULT_ACCENT,\n  railColor = DEFAULT_RAIL,\n  beamWidth = 2,\n  marker = \"dot\",\n  markerSize = 14,\n  stickyOffset = 16,\n  stickyBackground = \"rgb(10 10 10 / 0.88)\",\n  reveal = \"slide\",\n  root,\n  headingLevel = 3,\n  className,\n  style,\n  ...props\n}: StratumProps) {\n  const reduced = useReducedMotion();\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const nodeRefs = React.useRef<(HTMLElement | null)[]>([]);\n  const contentRefs = React.useRef<(HTMLElement | null)[]>([]);\n\n  // Fractional (0..1) rail position of each node center, measured client-side.\n  const fractionsRef = React.useRef<number[]>([]);\n  const peakLitRef = React.useRef(0);\n  const peakRevealedRef = React.useRef<Set<number>>(new Set());\n  const [litCount, setLitCount] = React.useState(0);\n  const [revealed, setRevealed] = React.useState<Set<number>>(() => new Set());\n\n  const Heading = `h${headingLevel}` as \"h2\";\n  const laneWidth = Math.max(markerSize + 20, 40);\n  const laneCenter = laneWidth / 2;\n  const frames = revealFrames(reveal);\n\n  const { scrollYProgress } = useScroll({\n    container: root,\n    target: innerRef,\n    offset: [\"start start\", \"end end\"],\n  });\n\n  // Beam fill + meniscus head position, scrubbed straight off scroll progress.\n  const beamHeight = useTransform(scrollYProgress, [0, 1], [\"0%\", \"100%\"]);\n  const headTop = useTransform(scrollYProgress, [0, 1], [\"0%\", \"100%\"]);\n  const headOpacity = useTransform(scrollYProgress, [0, 0.008, 0.985, 1], [0, 1, 1, 0]);\n\n  const recomputeLit = React.useCallback(\n    (p: number) => {\n      const fracs = fractionsRef.current;\n      let count = 0;\n      for (let i = 0; i < fracs.length; i++) {\n        if (p + 0.001 >= fracs[i]) count++;\n        else break;\n      }\n      if (count > peakLitRef.current) {\n        peakLitRef.current = count;\n        setLitCount(count);\n      }\n    },\n    []\n  );\n\n  // Measure node positions once mounted, and re-measure on resize. Nodes stay\n  // lit once reached, so we only ever grow litCount from the scroll subscription.\n  React.useEffect(() => {\n    const inner = innerRef.current;\n    if (!inner) return;\n\n    const measure = () => {\n      const total = inner.offsetHeight || 1;\n      fractionsRef.current = nodeRefs.current.map((el) =>\n        el ? (el.offsetTop + el.offsetHeight / 2) / total : 1\n      );\n      recomputeLit(scrollYProgress.get());\n    };\n\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(inner);\n    return () => ro.disconnect();\n  }, [entries, rail, recomputeLit, scrollYProgress]);\n\n  // Single scroll subscription drives node ignition. Reduced motion lights all.\n  useMotionValueEvent(scrollYProgress, \"change\", (p) => {\n    if (reduced) return;\n    recomputeLit(p);\n  });\n\n  React.useEffect(() => {\n    if (reduced) {\n      peakLitRef.current = entries.length;\n      setLitCount(entries.length);\n    }\n  }, [reduced, entries.length]);\n\n  // Single IntersectionObserver drives per-entry reveals.\n  React.useEffect(() => {\n    if (reduced || reveal === \"none\") {\n      setRevealed(new Set(entries.map((_, i) => i)));\n      return;\n    }\n    const els = contentRefs.current.filter(Boolean) as HTMLElement[];\n    if (els.length === 0) return;\n\n    const io = new IntersectionObserver(\n      (obsEntries) => {\n        let changed = false;\n        const next = new Set(peakRevealedRef.current);\n        for (const e of obsEntries) {\n          if (e.isIntersecting) {\n            const idx = Number((e.target as HTMLElement).dataset.stratumIndex);\n            if (!Number.isNaN(idx) && !next.has(idx)) {\n              next.add(idx);\n              changed = true;\n            }\n          }\n        }\n        if (changed) {\n          peakRevealedRef.current = next;\n          setRevealed(next);\n        }\n      },\n      { root: root?.current ?? null, threshold: 0.15, rootMargin: \"0px 0px -10% 0px\" }\n    );\n    els.forEach((el) => io.observe(el));\n    return () => io.disconnect();\n  }, [reduced, reveal, entries, root]);\n\n  // Lane geometry: the absolute spine and the node column share a center line.\n  const laneStyle: React.CSSProperties =\n    rail === \"left\"\n      ? { left: laneCenter, transform: \"translateX(-50%)\" }\n      : rail === \"right\"\n        ? { right: laneCenter, transform: \"translateX(50%)\" }\n        : { left: \"50%\", transform: \"translateX(-50%)\" };\n\n  const gridTemplate =\n    rail === \"left\"\n      ? `${laneWidth}px minmax(0,1fr)`\n      : rail === \"right\"\n        ? `minmax(0,1fr) ${laneWidth}px`\n        : `minmax(0,1fr) ${laneWidth}px minmax(0,1fr)`;\n\n  const headSize = Math.max(beamWidth * 3, 7);\n\n  return (\n    <div\n      ref={innerRef}\n      data-crucible=\"stratum\"\n      className={cn(\"relative\", className)}\n      style={style}\n      {...props}\n    >\n      {/* Spine: dim track + bright scrubbed beam + meniscus head. Decorative. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 z-0\"\n        style={{ ...laneStyle, width: beamWidth }}\n      >\n        {/* Dim track (full height). */}\n        <div\n          className=\"absolute inset-y-0 left-0 w-full rounded-full\"\n          style={{ background: railColor, opacity: 0.5 }}\n        />\n        {/* Live beam: fades in from the top, brightest at the leading edge. */}\n        <motion.div\n          className=\"absolute left-0 top-0 w-full rounded-full\"\n          style={{\n            height: reduced ? \"100%\" : beamHeight,\n            background: `linear-gradient(to bottom, ${accentColor}00, ${accentColor})`,\n            boxShadow: `0 0 ${beamWidth * 5}px ${accentColor}`,\n            transformOrigin: \"top\",\n          }}\n        />\n        {/* Meniscus head bead at the beam's leading edge. */}\n        {!reduced && (\n          <motion.div\n            className=\"absolute left-1/2 rounded-full bg-white\"\n            style={{\n              top: headTop,\n              width: headSize,\n              height: headSize,\n              x: \"-50%\",\n              y: \"-50%\",\n              opacity: headOpacity,\n              boxShadow: `0 0 ${headSize * 1.6}px #ffffff, 0 0 ${headSize * 0.7}px ${accentColor}`,\n            }}\n          />\n        )}\n      </div>\n\n      <ol className=\"relative z-10 m-0 list-none p-0\">\n        {entries.map((entry, i) => {\n          const key = entry.id ?? i;\n          const isLit = i < litCount;\n          const isRevealed = revealed.has(i);\n\n          // Center rail alternates content side; left/right are fixed.\n          const dotCol = rail === \"center\" ? 2 : rail === \"left\" ? 1 : 2;\n          let contentCol: number;\n          let contentAlign: \"left\" | \"right\";\n          if (rail === \"left\") {\n            contentCol = 2;\n            contentAlign = \"left\";\n          } else if (rail === \"right\") {\n            contentCol = 1;\n            contentAlign = \"right\";\n          } else {\n            const rightSide = i % 2 === 0;\n            contentCol = rightSide ? 3 : 1;\n            contentAlign = rightSide ? \"left\" : \"right\";\n          }\n\n          return (\n            <li\n              key={key}\n              className=\"grid\"\n              style={{ gridTemplateColumns: gridTemplate, columnGap: 4 }}\n            >\n              {/* Node column — dot aligned to the entry's title line. */}\n              <div\n                className=\"flex justify-center\"\n                style={{ gridColumn: dotCol, paddingTop: stickyOffset }}\n              >\n                <span\n                  ref={(el) => {\n                    nodeRefs.current[i] = el;\n                  }}\n                  className=\"flex h-max items-start\"\n                >\n                  <NodeMarker\n                    lit={isLit}\n                    reduced={reduced}\n                    marker={marker}\n                    size={markerSize}\n                    accent={accentColor}\n                    cold={railColor}\n                  />\n                </span>\n              </div>\n\n              {/* Content column. */}\n              <div\n                className=\"min-w-0 pb-16\"\n                style={{ gridColumn: contentCol, textAlign: contentAlign }}\n              >\n                <div\n                  className=\"mb-4\"\n                  style={\n                    reduced\n                      ? { position: \"static\" }\n                      : {\n                          position: \"sticky\",\n                          top: stickyOffset,\n                          zIndex: 5,\n                          // Opaque-ish backdrop (+ a little breathing room) so the\n                          // body text sliding under the pinned title stays legible.\n                          background: stickyBackground,\n                          backdropFilter: stickyBackground === \"transparent\" ? undefined : \"blur(6px)\",\n                          paddingBlock: 4,\n                        }\n                  }\n                >\n                  {entry.date != null && (\n                    <div className=\"text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500\">\n                      {entry.date}\n                    </div>\n                  )}\n                  <Heading className=\"mt-1 text-lg font-semibold tracking-tight text-white\">\n                    {entry.title}\n                  </Heading>\n                </div>\n\n                <motion.div\n                  ref={(el: HTMLDivElement | null) => {\n                    contentRefs.current[i] = el;\n                  }}\n                  data-stratum-index={i}\n                  className=\"text-sm leading-relaxed text-neutral-400\"\n                  initial={false}\n                  animate={isRevealed ? frames.shown : frames.hidden}\n                  transition={\n                    reduced ? { duration: 0 } : { duration: 0.5, ease: [0.25, 0.4, 0.25, 1] }\n                  }\n                >\n                  {entry.content}\n                </motion.div>\n              </div>\n            </li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n"
    }
  ]
}