{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline-changelog-wave",
  "title": "Timeline Changelog Wave",
  "description": "Changelog timeline drawn as a beach strand: each release arrives as a foam crest sweeping an arc in from the now edge, breaks at its chronological position, pops a marker in with a spring, and recedes leaving an etched tide-ring. Later waves wash past older marks so ring density reads as event history.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/timeline-changelog-wave/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// Strandline — a changelog drawn as a beach strand. Each event arrives as a\n// foam crest sweeping a quadratic arc in from the \"now\" edge (right), breaks\n// at its chronological position, deposits a DOM marker with a spring pop, and\n// recedes leaving an etched tide-ring. Later waves reach further up-strand and\n// wash past earlier markers, adding rings — ring density reads as history.\n// Canvas 2D behind DOM markers; residue rings live in pruned per-marker\n// segment lists re-stroked after a full clear every frame (never\n// destination-in decay). Waves launch on a short stagger and travel\n// concurrently, so a long history strands quickly instead of one dot at a\n// time. Direct-DOM rAF hot path; the loop sleeps when no crest is traveling,\n// the fleck array is empty, and all marker springs have settled.\n// ---------------------------------------------------------------------------\n\nexport interface StrandlineEvent {\n  /** short mono date label, e.g. \"JAN 12\" */\n  date: string;\n  /** version tag, e.g. \"v1.2.0\" */\n  version: string;\n  /** one-line release title */\n  title: string;\n  /** one-line body for the detail card */\n  body: string;\n}\n\nconst DEFAULT_EVENTS: StrandlineEvent[] = [\n  {\n    date: \"JAN 12\",\n    version: \"v1.0.0\",\n    title: \"First public release\",\n    body: \"Core engine, CLI, and the initial component registry.\",\n  },\n  {\n    date: \"FEB 27\",\n    version: \"v1.1.0\",\n    title: \"Streaming responses\",\n    body: \"Token-level streaming across every endpoint.\",\n  },\n  {\n    date: \"APR 09\",\n    version: \"v1.2.0\",\n    title: \"Edge runtime\",\n    body: \"Sub-50ms cold starts on the edge network.\",\n  },\n  {\n    date: \"MAY 30\",\n    version: \"v2.0.0\",\n    title: \"Engine rewrite\",\n    body: \"New scheduler, 3x throughput, breaking API cleanup.\",\n  },\n  {\n    date: \"JUN 24\",\n    version: \"v2.1.0\",\n    title: \"Batch imports\",\n    body: \"Bulk ingestion with resumable uploads.\",\n  },\n  {\n    date: \"JUL 15\",\n    version: \"v2.2.0\",\n    title: \"Access controls\",\n    body: \"Row-level permissions and full audit trails.\",\n  },\n];\n\ntype RGB = { r: number; g: number; b: number };\n\nfunction parseColor(raw: string): RGB | null {\n  const v = raw.trim();\n  if (v.startsWith(\"#\")) {\n    const hex = v.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0]! + hex[0]!, 16);\n      const g = parseInt(hex[1]! + hex[1]!, 16);\n      const b = parseInt(hex[2]! + hex[2]!, 16);\n      return Number.isNaN(r + g + b) ? null : { r, g, b };\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : { r, g, b };\n    }\n    return null;\n  }\n  const m = v.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/i);\n  return m ? { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) } : null;\n}\n\nfunction rgba(c: RGB, a: number): string {\n  return `rgba(${c.r},${c.g},${c.b},${a})`;\n}\n\n// deterministic prng — jitter, ring geometry, fleck spray\nfunction mulberry32(a: number) {\n  return () => {\n    a |= 0;\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\nconst easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));\nconst easeOutQuad = (t: number) => 1 - (1 - t) * (1 - t);\n\nconst WAVE_SPEED = 420; // px/s along the arc\nconst RECEDE_MS = 550;\n// waves overlap: a new one launches this long after the previous, instead of\n// waiting for a full travel+recede cycle — with many events (the changelog\n// has 10) a strictly sequential tide took ~12s, and mid-intro the strand read\n// as a clump of dots at the now edge instead of a distributed timeline\nconst LAUNCH_STAGGER_MS = 160;\nconst TRAIL_ALPHA = 0.26; // wetted-arc ink — 0.1 read as a faint disconnected curve\nconst CREST_SEGS = 48; // 40-60 short stroked segments\nconst CREST_WINDOW = 60; // px of arc the foam comb trails over\nconst FLECK_LIFE = 600; // ms\nconst FLECK_BURST = 250; // ms emission window on break\nconst GRAVITY = 300; // px/s²\nconst SPRING_K = 250; // s⁻² — overshoot ~1.06, settles < 500ms\nconst SPRING_ZETA = 0.5;\nconst SPRING_DEADLINE = 800; // ms forced-settle\nconst MAX_RINGS = 3; // per-marker residue cap, oldest pruned\n\ntype Ring = { r: number; rot: number; span: number; wave: number };\n\ntype Wave = {\n  target: number; // event index\n  pts: Float32Array; // x,y pairs along the arc\n  cum: Float32Array; // cumulative arc length\n  len: number;\n  t0: number;\n  dur: number;\n  phase: \"travel\" | \"recede\";\n  rt0: number;\n  sTip: number;\n  recedeP: number;\n  deposits: boolean; // false for hover replays — no residue ring\n  deposited: boolean; // true once breakWave() has actually landed the marker\n  done: boolean;\n  segD: Float32Array; // crest comb: distance behind tip\n  segP: Float32Array; // perpendicular jitter ±3px\n  segL: Float32Array; // segment length\n  segA: Float32Array; // alpha falloff toward the back of the comb\n};\n\ntype Fleck = { x: number; y: number; vx: number; vy: number; birth: number };\n\ntype Spring = { s: number; v: number; t0: number; active: boolean };\n\nexport function Strandline({\n  events = DEFAULT_EVENTS,\n  autoplay = 3,\n  className = \"h-80\",\n  \"aria-label\": ariaLabel = \"Release timeline strand\",\n}: {\n  /** chronological events, oldest first — oldest breaks nearest the now edge */\n  events?: StrandlineEvent[];\n  /** waves auto-launched on mount (0 disables the intro tide) */\n  autoplay?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the timeline. Default \"Release timeline strand\". */\n  \"aria-label\"?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const outerRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const cardTitleRef = useRef<HTMLParagraphElement>(null);\n  const cardMetaRef = useRef<HTMLParagraphElement>(null);\n  const cardBodyRef = useRef<HTMLParagraphElement>(null);\n  const counterRef = useRef<HTMLSpanElement>(null);\n  const prevRef = useRef<HTMLButtonElement>(null);\n  const nextRef = useRef<HTMLButtonElement>(null);\n  const apiRef = useRef<{\n    advance: () => void;\n    retreat: () => void;\n    hoverIn: (i: number) => void;\n    hoverOut: () => void;\n  } | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const card = cardRef.current;\n    if (!root || !canvas || !card) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const n = events.length;\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const rand = mulberry32(0x57a4d);\n\n    // -- token inks: derived at mount, re-derived on documentElement class ---\n    let borderC: RGB = { r: 46, g: 46, b: 46 };\n    let fgC: RGB = { r: 237, g: 237, b: 237 };\n    let mutedC: RGB = { r: 143, g: 143, b: 143 };\n    let accentC: RGB = { r: 0, g: 107, b: 255 };\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      borderC = parseColor(cs.getPropertyValue(\"--border\")) ?? borderC;\n      fgC = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fgC;\n      mutedC = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? mutedC;\n      accentC = parseColor(cs.getPropertyValue(\"--ns-accent\")) ?? accentC;\n    };\n    derive();\n\n    // -- geometry ------------------------------------------------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let axisY = 0;\n    let sized = false;\n    const xs = new Float64Array(Math.max(1, n)); // marker x per event\n\n    // -- hot-path state: locals only, never React state ----------------------\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    const waves: Wave[] = []; // concurrent depositing waves, launch-staggered\n    let lastLaunch = -Infinity;\n    let replayWave: Wave | null = null;\n    // counts only waves still in flight that haven't landed yet — once\n    // breakWave() fires, the marker is already stranded so it must not be\n    // double-counted as \"reserved\" for the rest of the recede tail.\n    const reservedCount = () => {\n      let c = 0;\n      for (const wv of waves) if (wv.deposits && !wv.deposited) c++;\n      return c;\n    };\n    const queue: number[] = [];\n    const flecks: Fleck[] = [];\n    let strandedCount = 0;\n    const rings: Ring[][] = Array.from({ length: n }, () => []);\n    const springs: Spring[] = Array.from({ length: n }, () => ({\n      s: 0.6,\n      v: 0,\n      t0: 0,\n      active: false,\n    }));\n    let cardVisible = false;\n    let disposed = false;\n\n    const placeMarker = (i: number) => {\n      const outer = outerRefs.current[i];\n      if (!outer) return;\n      outer.style.transform = `translate3d(${xs[i]! - 12}px, ${axisY - 12}px, 0)`;\n    };\n\n    const resize = (): boolean => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false; // zero-size container guard — loop no-ops until real\n        return false;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      // canvas is a replaced element — inset never sizes it; explicit CSS\n      // size + dpr backing store, or it renders at dpr scale\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      axisY = h * 0.62;\n      const padL = Math.max(28, w * 0.07);\n      const padR = Math.max(30, w * 0.075);\n      const usable = Math.max(1, w - padL - padR);\n      for (let i = 0; i < n; i++) {\n        // oldest nearest the now edge (right); each later wave reaches\n        // further up-strand — that is what lets it wash earlier markers\n        xs[i] = n === 1 ? padL + usable / 2 : w - padR - (i / (n - 1)) * usable;\n      }\n      // DOM markers take OFFSET transforms from the container origin — never\n      // absolute canvas coords\n      for (let i = 0; i < n; i++) placeMarker(i);\n      sized = true;\n      return true;\n    };\n\n    // -- arc construction ----------------------------------------------------\n    const buildArc = (\n      x0: number,\n      y0: number,\n      cx: number,\n      cy: number,\n      x1: number,\n      y1: number\n    ) => {\n      const N = Math.max(24, Math.min(150, Math.round(Math.abs(x0 - x1) / 5)));\n      const pts = new Float32Array((N + 1) * 2);\n      const cum = new Float32Array(N + 1);\n      let px = 0;\n      let py = 0;\n      for (let i = 0; i <= N; i++) {\n        const t = i / N;\n        const u = 1 - t;\n        const x = u * u * x0 + 2 * u * t * cx + t * t * x1;\n        const y = u * u * y0 + 2 * u * t * cy + t * t * y1;\n        pts[i * 2] = x;\n        pts[i * 2 + 1] = y;\n        cum[i] = i === 0 ? 0 : cum[i - 1]! + Math.hypot(x - px, y - py);\n        px = x;\n        py = y;\n      }\n      return { pts, cum, len: cum[N]! };\n    };\n\n    const makeWave = (target: number, deposits: boolean): Wave => {\n      const x1 = xs[target]!;\n      let x0: number;\n      let y0: number;\n      let lift: number;\n      let x1End: number;\n      let y1End: number;\n      if (deposits) {\n        x0 = w + 10;\n        y0 = axisY + 12;\n        lift = Math.min(h * 0.3, 26 + (x0 - x1) * 0.14);\n        x1End = x1;\n        y1End = axisY;\n      } else {\n        // hover/focus replay: a short local lap that breaks AT the dot — a\n        // few px of incoming water from the seaward side, no long excursion.\n        // (A 110px up-strand arc here read as \"an arrow appears and goes\n        // forward\" on hover; the replay must stay anchored to the dot.)\n        x0 = Math.min(w - 2, x1 + 14);\n        y0 = axisY + 4;\n        lift = 10;\n        x1End = x1;\n        y1End = axisY;\n      }\n      const arc = buildArc(x0, y0, (x0 + x1End) / 2, axisY - lift, x1End, y1End);\n      const segs = deposits ? CREST_SEGS : 28;\n      const win = deposits ? CREST_WINDOW : 34;\n      const segD = new Float32Array(segs);\n      const segP = new Float32Array(segs);\n      const segL = new Float32Array(segs);\n      const segA = new Float32Array(segs);\n      for (let k = 0; k < segs; k++) {\n        segD[k] = rand() * win;\n        segP[k] = (rand() * 2 - 1) * 3; // ±3px perpendicular jitter\n        segL[k] = 3 + rand() * 6;\n        segA[k] = Math.max(0.15, 1 - segD[k]! / (win + 12));\n      }\n      return {\n        target,\n        pts: arc.pts,\n        cum: arc.cum,\n        len: arc.len,\n        t0: performance.now(),\n        dur: Math.max(120, (arc.len / WAVE_SPEED) * 1000),\n        phase: \"travel\",\n        rt0: 0,\n        sTip: 0,\n        recedeP: 0,\n        deposits,\n        deposited: false,\n        done: false,\n        segD,\n        segP,\n        segL,\n        segA,\n      };\n    };\n\n    // position + unit tangent at arc length s (binary search on cum)\n    const posTan = (\n      wv: Wave,\n      s: number,\n      out: { x: number; y: number; tx: number; ty: number }\n    ) => {\n      const cum = wv.cum;\n      const N = cum.length - 1;\n      const cs = Math.min(wv.len, Math.max(0, s));\n      let lo = 0;\n      let hi = N;\n      while (lo < hi - 1) {\n        const mid = (lo + hi) >> 1;\n        if (cum[mid]! <= cs) lo = mid;\n        else hi = mid;\n      }\n      const seg = cum[hi]! - cum[lo]!;\n      const f = seg > 0 ? (cs - cum[lo]!) / seg : 0;\n      const ax = wv.pts[lo * 2]!;\n      const ay = wv.pts[lo * 2 + 1]!;\n      const bx = wv.pts[hi * 2]!;\n      const by = wv.pts[hi * 2 + 1]!;\n      const dx = bx - ax;\n      const dy = by - ay;\n      const d = Math.hypot(dx, dy) || 1;\n      out.x = ax + dx * f;\n      out.y = ay + dy * f;\n      out.tx = dx / d;\n      out.ty = dy / d;\n    };\n    const pt = { x: 0, y: 0, tx: 0, ty: 0 };\n\n    // -- residue rings: pruned segment lists, re-stroked from a full clear ---\n    const pushRing = (marker: number, wave: number) => {\n      const list = rings[marker]!;\n      const r = 11 + list.length * 5 + rand() * 3;\n      const span = Math.PI * (1.0 + rand() * 0.35);\n      const rot = -span / 2 + (rand() * 2 - 1) * 0.35; // crescent bows seaward\n      list.push({ r, rot, span, wave });\n      if (list.length > MAX_RINGS) list.shift(); // oldest pruned — no decay\n    };\n\n    const spawnFlecks = (bx: number, by: number, count: number, now: number) => {\n      for (let i = 0; i < count; i++) {\n        const speed = 60 + rand() * 80;\n        const ang = Math.PI * (1.05 + rand() * 0.8); // spray up + up-strand\n        flecks.push({\n          x: bx + (rand() * 2 - 1) * 5,\n          y: by + (rand() * 2 - 1) * 3,\n          vx: Math.cos(ang) * speed,\n          vy: Math.sin(ang) * speed,\n          birth: now + rand() * FLECK_BURST,\n        });\n      }\n    };\n\n    const syncControls = () => {\n      const launched = strandedCount + queue.length + reservedCount();\n      if (counterRef.current)\n        counterRef.current.textContent = `${reduced ? n : strandedCount} / ${n}`;\n      if (nextRef.current) nextRef.current.disabled = reduced || launched >= n;\n      if (prevRef.current) prevRef.current.disabled = reduced || launched <= 0;\n    };\n\n    const depositMarker = (i: number, now: number) => {\n      const outer = outerRefs.current[i];\n      const btn = btnRefs.current[i];\n      if (outer) outer.style.visibility = \"visible\";\n      if (btn) btn.style.transform = \"scale(0.6)\";\n      const sp = springs[i]!;\n      sp.s = 0.6;\n      sp.v = 0;\n      sp.t0 = now;\n      sp.active = true;\n      strandedCount = Math.max(strandedCount, i + 1);\n      pushRing(i, i); // its own tide-ring\n      // this wash passes every already-stranded marker on its way up-strand\n      for (let j = 0; j < i; j++) {\n        if (j < strandedCount - 1 && xs[j]! > xs[i]!) pushRing(j, i);\n      }\n      syncControls();\n    };\n\n    const undeposit = () => {\n      const i = strandedCount - 1;\n      if (i < 0) return;\n      const outer = outerRefs.current[i];\n      if (outer) outer.style.visibility = \"hidden\";\n      springs[i]!.active = false;\n      springs[i]!.s = 0.6;\n      strandedCount = i;\n      for (let j = 0; j < n; j++) {\n        rings[j] = rings[j]!.filter((r) => r.wave < i);\n      }\n    };\n\n    // -- detail card ---------------------------------------------------------\n    const showCard = (i: number) => {\n      const ev = events[i];\n      if (!ev || !sized) return;\n      if (cardTitleRef.current) cardTitleRef.current.textContent = ev.title;\n      if (cardMetaRef.current)\n        cardMetaRef.current.textContent = `${ev.version} · ${ev.date}`;\n      if (cardBodyRef.current) cardBodyRef.current.textContent = ev.body;\n      // anchor to the dot: centered on it, clamped only enough to stay inside\n      // the root (half the w-56 card + a small margin). Vertically the card's\n      // bottom sits just above the dot — but real release bodies can be long,\n      // so measure the card AFTER the text is set and, if it would poke past\n      // the root's top (overflow-hidden clips it there), slide it down until\n      // it fits rather than letting the top get cut off.\n      const halfW = card.offsetWidth / 2 || 112;\n      const cx = Math.min(w - halfW - 6, Math.max(halfW + 6, xs[i]!));\n      const bottomY = Math.max(axisY - 26, card.offsetHeight + 6);\n      const tf = `translate3d(${cx}px, ${bottomY}px, 0) translate(-50%, -100%)`;\n      if (!cardVisible) {\n        // first show: no fly-in from the previous transform\n        card.style.transition = \"none\";\n        card.style.transform = tf;\n        void card.offsetWidth;\n        card.style.transition = \"\";\n      } else {\n        card.style.transform = tf;\n      }\n      card.style.opacity = \"1\";\n      cardVisible = true;\n    };\n    const hideCard = () => {\n      card.style.opacity = \"0\";\n      cardVisible = false;\n    };\n\n    // -- render: full clear, then everything re-stroked from lists -----------\n    const render = (now: number) => {\n      if (!sized) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineCap = \"round\";\n\n      // axis — 1px --border at 62% height\n      ctx.strokeStyle = rgba(borderC, 1);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      ctx.moveTo(Math.max(10, w * 0.03), axisY + 0.5);\n      ctx.lineTo(w, axisY + 0.5);\n      ctx.stroke();\n\n      // chronological ticks — stranded solid, future faint\n      for (let i = 0; i < n; i++) {\n        ctx.strokeStyle = rgba(borderC, i < strandedCount ? 0.9 : 0.35);\n        ctx.beginPath();\n        ctx.moveTo(xs[i]!, axisY + 3);\n        ctx.lineTo(xs[i]!, axisY + 8);\n        ctx.stroke();\n      }\n\n      // now edge — short vertical bar where the water lives\n      ctx.strokeStyle = rgba(borderC, 0.9);\n      ctx.beginPath();\n      ctx.moveTo(w - 1.5, axisY - 14);\n      ctx.lineTo(w - 1.5, axisY + 14);\n      ctx.stroke();\n\n      // residue tide-rings — one batched stroke, --ns-muted alpha 0.12\n      ctx.strokeStyle = rgba(mutedC, 0.12);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      for (let i = 0; i < strandedCount; i++) {\n        const list = rings[i]!;\n        for (let k = 0; k < list.length; k++) {\n          const rg = list[k]!;\n          ctx.moveTo(\n            xs[i]! + rg.r * Math.cos(rg.rot),\n            axisY + rg.r * Math.sin(rg.rot)\n          );\n          ctx.arc(xs[i]!, axisY, rg.r, rg.rot, rg.rot + rg.span);\n        }\n      }\n      ctx.stroke();\n\n      // active waves\n      for (const wv of waves) renderWave(wv);\n      if (replayWave) renderWave(replayWave);\n\n      // foam flecks\n      ctx.fillStyle = rgba(fgC, 1);\n      for (let i = 0; i < flecks.length; i++) {\n        const f = flecks[i]!;\n        if (now < f.birth) continue;\n        const a = 0.5 * (1 - (now - f.birth) / FLECK_LIFE);\n        if (a <= 0) continue;\n        ctx.globalAlpha = a;\n        ctx.fillRect(f.x - 1, f.y - 1, 2, 2);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const trail = (wv: Wave, sEnd: number, alpha: number) => {\n      if (sEnd <= 0) return;\n      const cum = wv.cum;\n      const N = cum.length - 1;\n      ctx.beginPath();\n      ctx.moveTo(wv.pts[0]!, wv.pts[1]!);\n      for (let i = 1; i <= N && cum[i]! <= sEnd; i++) {\n        ctx.lineTo(wv.pts[i * 2]!, wv.pts[i * 2 + 1]!);\n      }\n      posTan(wv, sEnd, pt);\n      ctx.lineTo(pt.x, pt.y);\n      ctx.strokeStyle = rgba(fgC, alpha * 0.45);\n      ctx.lineWidth = 2.5;\n      ctx.stroke();\n      ctx.strokeStyle = rgba(fgC, alpha);\n      ctx.lineWidth = 1;\n      ctx.stroke();\n    };\n\n    const renderWave = (wv: Wave) => {\n      if (wv.phase === \"travel\") {\n        trail(wv, wv.sTip, TRAIL_ALPHA); // glowing wetted arc behind the crest\n        // crest comb — short segments jittered perpendicular to the arc\n        ctx.lineWidth = 1;\n        for (let k = 0; k < wv.segD.length; k++) {\n          const s = wv.sTip - wv.segD[k]!;\n          if (s < 0) continue;\n          posTan(wv, s, pt);\n          const nx = -pt.ty;\n          const ny = pt.tx;\n          const ox = pt.x + nx * wv.segP[k]!;\n          const oy = pt.y + ny * wv.segP[k]!;\n          const half = wv.segL[k]! / 2;\n          ctx.strokeStyle = rgba(fgC, 0.5 * wv.segA[k]!);\n          ctx.beginPath();\n          ctx.moveTo(ox - pt.tx * half, oy - pt.ty * half);\n          ctx.lineTo(ox + pt.tx * half, oy + pt.ty * half);\n          ctx.stroke();\n        }\n        // leading 1px accent line — the ONLY accent ink: active incoming\n        // crest of a REAL wave. Hover replays skip it: a traveling accent\n        // tick on hover reads as an arrow advancing, not a swash.\n        if (wv.deposits) {\n          posTan(wv, Math.max(0, wv.sTip - 10), pt);\n          const lx = pt.x;\n          const ly = pt.y;\n          posTan(wv, wv.sTip, pt);\n          ctx.strokeStyle = rgba(accentC, 0.95);\n          ctx.lineWidth = 1;\n          ctx.beginPath();\n          ctx.moveTo(lx, ly);\n          ctx.lineTo(pt.x, pt.y);\n          ctx.stroke();\n        }\n      } else {\n        // recede: the wetted arc dries back toward the arc's start — the\n        // now edge for a real wave, the dot itself for a hover replay\n        const e = 1 - easeOutQuad(wv.recedeP);\n        trail(wv, wv.len * e, TRAIL_ALPHA * (1 - wv.recedeP));\n      }\n    };\n\n    // -- update --------------------------------------------------------------\n    const breakWave = (wv: Wave, now: number) => {\n      // break point is wherever this arc actually ends — the target dot for\n      // a real deposit, but the far end of the up-strand swash for a replay\n      // (which now starts at the dot rather than ending there).\n      const lastI = wv.pts.length - 2;\n      const bx = wv.deposits ? xs[wv.target]! : wv.pts[lastI]!;\n      const by = wv.deposits ? axisY : wv.pts[lastI + 1]!;\n      spawnFlecks(bx, by, wv.deposits ? 24 : 8, now);\n      if (wv.deposits) {\n        depositMarker(wv.target, now);\n        wv.deposited = true;\n      }\n    };\n\n    const stepWave = (wv: Wave, now: number) => {\n      if (wv.phase === \"travel\") {\n        const p = (now - wv.t0) / wv.dur;\n        wv.sTip = easeOutExpo(Math.min(1, p)) * wv.len;\n        // forced-settle deadline — arrival never waits on an epsilon\n        if (p >= 1 || now - wv.t0 > wv.dur + 150) {\n          wv.sTip = wv.len;\n          breakWave(wv, now);\n          wv.phase = \"recede\";\n          wv.rt0 = now;\n        }\n      } else {\n        wv.recedeP = Math.min(1, (now - wv.rt0) / RECEDE_MS);\n        if (wv.recedeP >= 1) wv.done = true;\n      }\n    };\n\n    const springC = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n    let springsActive = false;\n\n    const update = (now: number, dt: number) => {\n      if (\n        queue.length > 0 &&\n        (waves.length === 0 || now - lastLaunch >= LAUNCH_STAGGER_MS)\n      ) {\n        waves.push(makeWave(queue.shift()!, true));\n        lastLaunch = now;\n      }\n      for (let i = waves.length - 1; i >= 0; i--) {\n        stepWave(waves[i]!, now);\n        if (waves[i]!.done) waves.splice(i, 1);\n      }\n      if (replayWave) {\n        stepWave(replayWave, now);\n        if (replayWave.done) replayWave = null;\n      }\n      // marker pop springs — 0.6 → 1.0, overshoot ~1.06, 800ms hard deadline\n      springsActive = false;\n      for (let i = 0; i < n; i++) {\n        const sp = springs[i]!;\n        if (!sp.active) continue;\n        sp.v += (-SPRING_K * (sp.s - 1) - springC * sp.v) * dt;\n        sp.s += sp.v * dt;\n        if (\n          now - sp.t0 > SPRING_DEADLINE ||\n          (Math.abs(sp.s - 1) < 0.002 && Math.abs(sp.v) < 0.02)\n        ) {\n          sp.s = 1;\n          sp.v = 0;\n          sp.active = false;\n        } else {\n          springsActive = true;\n        }\n        const btn = btnRefs.current[i];\n        if (btn) btn.style.transform = `scale(${sp.s})`;\n      }\n      // flecks — pruned array, no residue\n      for (let i = flecks.length - 1; i >= 0; i--) {\n        const f = flecks[i]!;\n        if (now < f.birth) continue;\n        if (now - f.birth > FLECK_LIFE) {\n          flecks.splice(i, 1);\n          continue;\n        }\n        f.vy += GRAVITY * dt;\n        f.x += f.vx * dt;\n        f.y += f.vy * dt;\n      }\n    };\n\n    const needsFrame = () =>\n      waves.length > 0 ||\n      replayWave !== null ||\n      queue.length > 0 ||\n      flecks.length > 0 ||\n      springsActive;\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!visible || !sized) {\n        last = 0;\n        return; // paused offscreen / zero-size — observers wake us\n      }\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      update(now, dt);\n      render(now);\n      if (needsFrame()) raf = requestAnimationFrame(loop);\n      else last = 0; // genuinely asleep — last frame stays on the canvas\n    };\n\n    const wake = () => {\n      if (!raf && !disposed && visible && sized) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    // -- public api (React handlers call through the ref) --------------------\n    apiRef.current = {\n      advance: () => {\n        if (reduced) return;\n        const launched = strandedCount + queue.length + reservedCount();\n        if (launched >= n) return;\n        queue.push(launched);\n        syncControls();\n        wake();\n      },\n      retreat: () => {\n        if (reduced) return;\n        let cancelled: Wave | null = null;\n        // cancel the most recently launched still-incoming wave, if any\n        for (let i = waves.length - 1; i >= 0; i--) {\n          const wv = waves[i]!;\n          if (wv.deposits && !wv.deposited) {\n            cancelled = wv;\n            break;\n          }\n        }\n        if (queue.length > 0) queue.pop();\n        else if (cancelled) {\n          // cancel the incoming wave: it recedes without depositing\n          cancelled.deposits = false;\n          if (cancelled.phase === \"travel\") {\n            cancelled.phase = \"recede\";\n            cancelled.rt0 = performance.now();\n            cancelled.recedeP = 0;\n          }\n        } else if (strandedCount > 0) undeposit();\n        syncControls();\n        wake();\n        if (!raf && sized) render(performance.now()); // asleep → one redraw\n      },\n      hoverIn: (i: number) => {\n        showCard(i);\n        // replay the swash AT this dot — no new residue ring. If a swash\n        // from a previously-hovered dot is still lapping/receding, replace\n        // it now rather than letting it finish: otherwise moving the pointer\n        // from dot A to dot B leaves A's lap on screen while B is what's\n        // hovered. Same-target re-hover is left alone.\n        if (\n          !reduced &&\n          i < strandedCount &&\n          sized &&\n          (!replayWave || replayWave.target !== i)\n        ) {\n          replayWave = makeWave(i, false);\n          wake();\n        }\n      },\n      hoverOut: () => hideCard(),\n    };\n\n    // -- reduced motion: pre-stranded, ringed, static ------------------------\n    const strandStatic = () => {\n      strandedCount = n;\n      for (let i = 0; i < n; i++) {\n        const outer = outerRefs.current[i];\n        const btn = btnRefs.current[i];\n        if (outer) outer.style.visibility = \"visible\";\n        if (btn) btn.style.transform = \"scale(1)\";\n        rings[i] = [];\n        const washes = Math.min(MAX_RINGS, n - i);\n        for (let k = 0; k < washes; k++) pushRing(i, i + k);\n      }\n      syncControls();\n    };\n\n    resize();\n\n    if (reduced) {\n      strandStatic();\n      if (sized) render(0);\n      const ro = new ResizeObserver(() => {\n        if (resize()) render(0);\n      });\n      ro.observe(root);\n      const mo = new MutationObserver(() => {\n        derive();\n        if (sized) render(0); // re-derive inks, re-stroke everything\n      });\n      mo.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"class\"],\n      });\n      return () => {\n        disposed = true;\n        ro.disconnect();\n        mo.disconnect();\n        apiRef.current = null;\n      };\n    }\n\n    syncControls();\n    if (sized) render(performance.now());\n\n    // intro tide — first waves launch themselves\n    for (let i = 0; i < Math.min(autoplay, n); i++) queue.push(i);\n    syncControls();\n    wake();\n\n    const ro = new ResizeObserver(() => {\n      if (resize()) {\n        render(performance.now());\n        wake();\n      }\n    });\n    ro.observe(root);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(root);\n    const mo = new MutationObserver(() => {\n      derive();\n      if (sized && !raf) render(performance.now()); // asleep → single re-stroke\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      apiRef.current = null;\n    };\n  }, [events, autoplay]);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={ariaLabel}\n      className={`relative w-full overflow-hidden ${className}`}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"absolute left-0 top-0\"\n      />\n\n      {/* now-edge label */}\n      <span\n        aria-hidden\n        className=\"absolute right-2 top-[calc(62%-1.6rem)] font-mono text-[9px] tracking-widest text-ns-muted\"\n      >\n        NOW\n      </span>\n\n      {/* stranded event markers — offset transforms from the container origin */}\n      {events.map((ev, i) => (\n        <div\n          key={`${ev.version}-${i}`}\n          ref={(el) => {\n            outerRefs.current[i] = el;\n          }}\n          className=\"absolute left-0 top-0 will-change-transform\"\n          style={{ visibility: \"hidden\" }}\n        >\n          <button\n            ref={(el) => {\n              btnRefs.current[i] = el;\n            }}\n            type=\"button\"\n            aria-label={`${ev.version} — ${ev.title}, ${ev.date}`}\n            onPointerEnter={() => apiRef.current?.hoverIn(i)}\n            onPointerLeave={() => apiRef.current?.hoverOut()}\n            onFocus={() => apiRef.current?.hoverIn(i)}\n            onBlur={() => apiRef.current?.hoverOut()}\n            className=\"group block rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60\"\n            style={{ transform: \"scale(0.6)\" }}\n          >\n            <span className=\"flex h-6 w-6 items-center justify-center rounded-full border border-border bg-surface text-ns-muted transition-colors duration-200 group-hover:border-foreground group-hover:text-foreground group-focus-visible:border-foreground group-focus-visible:text-foreground\">\n              <svg viewBox=\"0 0 10 10\" className=\"h-2.5 w-2.5\" aria-hidden>\n                <circle\n                  cx=\"5\"\n                  cy=\"5\"\n                  r=\"2\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                />\n                <path\n                  d=\"M5 0v1.6M5 8.4V10\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.2\"\n                />\n              </svg>\n            </span>\n          </button>\n          <div className=\"pointer-events-none absolute left-1/2 top-[30px] -translate-x-1/2 whitespace-nowrap text-center\">\n            <p className=\"font-mono text-[10px] leading-tight text-foreground\">\n              {ev.version}\n            </p>\n            <p className=\"font-mono text-[9px] leading-tight tracking-wider text-ns-muted\">\n              {ev.date}\n            </p>\n          </div>\n        </div>\n      ))}\n\n      {/* detail card — raised on hover / keyboard focus of a marker */}\n      <div\n        ref={cardRef}\n        aria-hidden\n        className=\"pointer-events-none absolute left-0 top-0 z-10 w-56 rounded-md border border-border bg-surface p-3 shadow-lg\"\n        style={{\n          opacity: 0,\n          transform: \"translate3d(-9999px, -9999px, 0)\",\n          transition:\n            \"opacity 160ms ease-out, transform 260ms cubic-bezier(0.22, 1, 0.36, 1)\",\n        }}\n      >\n        <p\n          ref={cardMetaRef}\n          className=\"font-mono text-[10px] tracking-wider text-ns-muted\"\n        />\n        <p\n          ref={cardTitleRef}\n          className=\"mt-1 text-xs font-medium text-foreground\"\n        />\n        {/* real release bodies run long — clamp so the card always fits in\n            the strip above the strand instead of clipping at the root's top */}\n        <p\n          ref={cardBodyRef}\n          className=\"mt-1 line-clamp-4 text-[11px] leading-relaxed text-ns-muted\"\n        />\n      </div>\n\n      {/* scrub controls — advance / retreat the tide */}\n      <div className=\"absolute bottom-3 right-3 z-10 flex items-center gap-2\">\n        <button\n          ref={prevRef}\n          type=\"button\"\n          aria-label=\"Previous release\"\n          onClick={() => apiRef.current?.retreat()}\n          className=\"flex h-7 w-7 items-center justify-center rounded-sm border border-border bg-surface text-ns-muted transition-colors duration-200 hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60\"\n        >\n          <svg viewBox=\"0 0 12 12\" className=\"h-3 w-3\" aria-hidden>\n            <path\n              d=\"M7.5 2.5 4 6l3.5 3.5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n        <span\n          ref={counterRef}\n          className=\"min-w-[2.6rem] text-center font-mono text-[10px] text-ns-muted\"\n        >\n          0 / {events.length}\n        </span>\n        <button\n          ref={nextRef}\n          type=\"button\"\n          aria-label=\"Next release\"\n          onClick={() => apiRef.current?.advance()}\n          className=\"flex h-7 w-7 items-center justify-center rounded-sm border border-border bg-surface text-ns-muted transition-colors duration-200 hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/60\"\n        >\n          <svg viewBox=\"0 0 12 12\" className=\"h-3 w-3\" aria-hidden>\n            <path\n              d=\"M4.5 2.5 8 6l-3.5 3.5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/timeline-changelog-wave.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "timeline",
      "changelog",
      "wave",
      "particles",
      "scroll-story",
      "data-viz",
      "ambient"
    ],
    "instruction": "Timeline drawn as a beach strand via wave-deposition. RENDERING: a Canvas 2D layer sits behind DOM marker nodes inside a padded surface card; the horizontal timeline axis is stroked 1px --border at 62% of canvas height, with the 'now' edge at the right and chronological positions laid out so the OLDEST event breaks nearest the water — each later wave must reach further up-strand, which is exactly what lets it wash past earlier markers. WAVE: the active crest travels a quadratic-bezier arc from the right edge toward the target event x at 420px/s with ease-out-expo arrival (duration = arcLength/420, hard forced-settle deadline at dur+150ms, never epsilon-based); the crest front is 48 short stroked segments (3-9px long) jittered ±3px perpendicular to the arc tangent in --foreground at alpha 0.5 with falloff over a 60px trailing comb, and the crest's leading 1px line is stroked in --ns-accent — the ONLY accent ink, the active incoming crest; a faint --foreground wetted-arc trail glows behind it. BREAK: on arrival, 24 foam flecks spawn with initial speed 60-140px/s, upward spray angles, gravity 300px/s², life 600ms, emission staggered over a 250ms burst window, held in a pruned splice-on-death array; the event's 24px DOM marker (border ring on surface, small commit icon) pops in with an underdamped spring (k=250, zeta=0.5) from scale 0.6 to 1.0 overshooting ~1.06, settling under 500ms with a forced-settle deadline of 800ms. RESIDUE: each wash appends one arc stroke (--ns-muted, alpha 0.12, crescent bowed seaward with jittered rotation and span) to a per-marker segment list capped at 3 with the oldest shifted out; rings are re-stroked from the lists every frame after a FULL clearRect — no destination-in decay, avoiding the quantize-forever trap. Later waves passing an existing marker append a ring without touching the marker. After the break the wetted arc recedes back toward the now edge over 550ms. INTERACTION: a next/prev control pair (plus counter) advances or retreats 'now' — next queues the next event's wave (queue drains sequentially), prev pops the queue, cancels an in-flight wave into a non-depositing recede, or undeposits the last marker and prunes its rings; hover or keyboard focus on a stranded marker replays its swash once via a short 110px mini-arc (no new residue ring) and raises a DOM detail card (title, version+date, one-line body) positioned by offset transform from the container origin above the marker's settled coordinate, with a no-fly-in first show. On mount an autoplay intro queues the first 3 waves so the ambient tide is the default look. REDUCED MOTION: all markers render pre-stranded with synthesized ring histories (min(3, n-i) rings each), no waves, controls disabled, hover/focus still raises the card statically. ACCEPTANCE: all inks (--border axis, --foreground foam/flecks, --ns-muted rings, --ns-accent crest) derived via getComputedStyle at mount and re-derived by a MutationObserver on documentElement class that re-strokes the scene; rAF sleeps when there is no active or queued wave, the fleck array is empty, and all marker springs are settled; IntersectionObserver pauses offscreen; zero-size containers guard the loop; canvas sized with explicit style.width/height plus a dpr-clamped backing store; every listener, observer, and frame torn down on unmount. DOM markers and the detail card take offset transforms from the container origin, never absolute canvas coords."
  },
  "type": "registry:ui"
}