{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-migrate-slip",
  "title": "Ripple Migrate Slip",
  "description": "A full-bleed dune-ripple field built on real aeolian bedform migration: each ripple's gentle windward face keeps piling higher until it crosses the angle of repose, its steep lee face avalanches, and the crest kicks one step downwind. Many ripples do this on independent, staggered clocks, so the whole train visibly creeps at rest with no two slips in sync.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/ripple-migrate-slip/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RippleMigrateSlip — a full-bleed hero built on a real granular-bedform\n// mechanic: aeolian sand-ripple migration. Wind-blown grains move by\n// saltation and reptation; reptating grains preferentially pile up on a\n// ripple's gentle STOSS (windward) face because a grain landing on an\n// existing slope is more likely to be trapped there than one landing on\n// flat ground — the positive feedback that grows and sustains a ripple\n// rather than smoothing it flat. Once the stoss pile's local slope exceeds\n// the angle of repose, the steep LEE (downwind) face avalanches — a small\n// grain-flow slip that resets the face to a stable angle and kicks the\n// ripple crest one step further downwind. Many ripples doing this on\n// independent, staggered clocks is what makes the whole train visibly\n// creep downwind at rest, with no two ripples slipping in sync.\n//\n// EACH RIPPLE IS ONE OBJECT, NOT A PARTICLE FIELD. amp is the current\n// stoss-pile height; it grows continuously (modulated by a slow global\n// gust sine) until it crosses the repose threshold, at which point a\n// 260ms avalanche interpolates amp down to a stable residual and the\n// crest forward by a few pixels — the interpolation IS the migration\n// step, not a separate transform. The whole silhouette is an exact\n// piecewise-linear polygon through each ripple's crest and post-lee\n// trough, not a sampled heightfield, so there is no aliasing between the\n// physics and what gets drawn.\n// ---------------------------------------------------------------------------\n\ntype Vec3 = [number, number, number];\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.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 = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction relLuminance([r, g, b]: Vec3): number {\n  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n}\n\nfunction mixColor(a: Vec3, b: Vec3, t: number): Vec3 {\n  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n  return a + (b - a) * t;\n}\n\nfunction easeOutCubic(t: number): number {\n  return 1 - Math.pow(1 - t, 3);\n}\n\n// -- real numbers (documented in docs/specs/r12/ripple-migrate-slip.md) -----\n// Spatial constants below are in \"visual\" canvas px: the spec's real-world\n// figures (28-42px wavelength, 34deg repose on a 9px lee run) describe a\n// sand ripple's own scale, which is far too small to read at full-bleed\n// hero size — every spatial constant here is that same geometry scaled up\n// ~5x for legibility (VISUAL_SCALE), while every RATE and TIMING constant\n// (accretion rate target, avalanche duration, gust/migration cadence)\n// stays tuned directly to the spec's stated cadences, since those are what\n// the round 9 legibility rule actually governs.\nconst WAVELENGTH_MIN = 150; // px between a ripple and the next\nconst WAVELENGTH_MAX = 210;\nconst LEE_RUN = 40; // px: fixed run length of the steep lee face\nconst REPOSE_DEG = 34; // angle of repose: avalanche trigger\nconst STABLE_DEG = 20; // post-slip stable angle: leaves room to regrow\nconst THRESHOLD_AMP = LEE_RUN * Math.tan((REPOSE_DEG * Math.PI) / 180); // ~27\nconst STABLE_AMP = LEE_RUN * Math.tan((STABLE_DEG * Math.PI) / 180); // ~14.6\n// Growth tuned so a ripple crosses threshold roughly every 9-12s (spec's\n// \"roughly one avalanche per ripple every ~9-12s\"), and at ~9-14 ripples\n// visible across a typical full-bleed width, the field-wide avalanche rate\n// lands close to the spec's \"~1 avalanche event per 1.1-1.6s somewhere in\n// the visible field\".\nconst STOSS_RATE_BASE = (THRESHOLD_AMP - STABLE_AMP) / 10; // px/s baseline\nconst GUST_PERIOD_S = 11;\nconst GUST_AMP_FRAC = 0.25;\nconst AVALANCHE_DURATION_S = 0.26;\nconst MIGRATE_MIN = 6; // px the crest advances per avalanche\nconst MIGRATE_MAX = 10;\nconst AMP_DISPLAY_SCALE = 4; // amp -> rendered ridge height\nconst DPR_CAP = 1.5;\n// Reduced-motion freeze: run a deterministic 14s of settling (several\n// ripples through at least one full cycle, asymmetric profile clearly\n// established), then force the field's centre ripple into a mid-avalanche\n// pose (t=0.5) so the frozen frame shows both the pre-slip overhang and\n// the post-slip stable line at once — the single most structured moment\n// per the spec.\nconst STATIC_TIME_S = 14;\nconst STATIC_SEED = 20260828;\n\ninterface Stipple {\n  u: number; // 0..1 along the stoss segment, trough to crest\n  dy: number; // px scatter off the slope line\n  r: number; // dot radius\n}\n\ninterface Ripple {\n  x: number;\n  amp: number;\n  rateMul: number;\n  avalanche: { t: number; fromAmp: number; toAmp: number; fromX: number; toX: number } | null;\n  stipple: Stipple[];\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed;\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\nfunction buildStipple(rand: () => number): Stipple[] {\n  const count = 4 + Math.floor(rand() * 3); // 4-6\n  const out: Stipple[] = [];\n  for (let i = 0; i < count; i++) {\n    out.push({\n      u: 0.2 + rand() * 0.65,\n      dy: (rand() - 0.5) * 10,\n      r: 0.8 + rand() * 0.9,\n    });\n  }\n  return out;\n}\n\nfunction buildRipples(w: number, rand: () => number): Ripple[] {\n  const ripples: Ripple[] = [];\n  let x = -WAVELENGTH_MAX;\n  while (x < w + WAVELENGTH_MAX) {\n    ripples.push({\n      x,\n      amp: rand() * THRESHOLD_AMP * 0.85,\n      rateMul: 0.85 + rand() * 0.3,\n      avalanche: null,\n      stipple: buildStipple(rand),\n    });\n    x += WAVELENGTH_MIN + rand() * (WAVELENGTH_MAX - WAVELENGTH_MIN);\n  }\n  return ripples;\n}\n\nexport interface RippleMigrateSlipProps {\n  /** content rendered over the field, e.g. a headline + CTA */\n  children?: React.ReactNode;\n  className?: string;\n}\n\nexport function RippleMigrateSlip({ children, className = \"\" }: RippleMigrateSlipProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // -- token-derived ink, read at mount and re-derived on theme flip ------\n    let bg: Vec3 = [10, 10, 10];\n    let fg: Vec3 = [237, 237, 237];\n    let ridgeFill = \"rgb(120,120,120)\";\n    let stippleFill = \"rgb(150,150,150)\";\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      bg = parseColor(cs.getPropertyValue(\"--background\")) ?? bg;\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      const light = relLuminance(bg) > 0.5;\n      const ridgeMix = mixColor(bg, fg, 0.55);\n      // Light theme is the harder case for the stipple to hold contrast\n      // against the ridge fill, so its delta is widened rather than the\n      // dark-theme value reused.\n      const stippleDelta = light ? 0.22 : 0.12;\n      const stippleMix = mixColor(bg, fg, Math.min(1, 0.55 + stippleDelta));\n      ridgeFill = `rgb(${ridgeMix[0] | 0},${ridgeMix[1] | 0},${ridgeMix[2] | 0})`;\n      stippleFill = `rgb(${stippleMix[0] | 0},${stippleMix[1] | 0},${stippleMix[2] | 0})`;\n    };\n    derive();\n\n    // -- hot-path state: locals only, never React state ---------------------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let paused = false;\n    let reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let simTime = 0;\n    let ripples: Ripple[] = [];\n    let rand = Math.random;\n\n    const stepRipple = (r: Ripple, dt: number) => {\n      if (r.avalanche) {\n        const av = r.avalanche;\n        av.t += dt / AVALANCHE_DURATION_S;\n        if (av.t >= 1) {\n          r.amp = av.toAmp;\n          r.x = av.toX;\n          r.avalanche = null;\n        } else {\n          const e = easeOutCubic(av.t);\n          r.amp = lerp(av.fromAmp, av.toAmp, e);\n          r.x = lerp(av.fromX, av.toX, e);\n        }\n        return;\n      }\n      const gust = 1 + GUST_AMP_FRAC * Math.sin((simTime / GUST_PERIOD_S) * Math.PI * 2);\n      r.amp += STOSS_RATE_BASE * r.rateMul * Math.max(0.2, gust) * dt;\n      if (r.amp >= THRESHOLD_AMP) {\n        const migrate = MIGRATE_MIN + rand() * (MIGRATE_MAX - MIGRATE_MIN);\n        r.avalanche = { t: 0, fromAmp: r.amp, toAmp: STABLE_AMP, fromX: r.x, toX: r.x + migrate };\n      }\n    };\n\n    const step = (dt: number) => {\n      simTime += dt;\n      for (const r of ripples) stepRipple(r, dt);\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      if (ripples.length < 2) return;\n      const y0 = h * 0.68;\n\n      // exact piecewise-linear ridge silhouette through each ripple's crest\n      // and post-lee trough — no heightfield sampling, so it can't alias\n      // against the physics driving it.\n      ctx.beginPath();\n      ctx.moveTo(ripples[0]!.x - 40, h);\n      ctx.lineTo(ripples[0]!.x - 40, y0);\n      for (let i = 0; i < ripples.length; i++) {\n        const r = ripples[i]!;\n        const next = ripples[i + 1];\n        const gap = next ? next.x - r.x : LEE_RUN * 2;\n        const troughX = r.x + Math.min(LEE_RUN, gap);\n        ctx.lineTo(r.x, y0 - r.amp * AMP_DISPLAY_SCALE);\n        ctx.lineTo(troughX, y0);\n      }\n      const lastR = ripples[ripples.length - 1]!;\n      ctx.lineTo(lastR.x + LEE_RUN + 40, y0);\n      ctx.lineTo(lastR.x + LEE_RUN + 40, h);\n      ctx.closePath();\n      ctx.fillStyle = ridgeFill;\n      ctx.fill();\n\n      // stoss-face stipple: a handful of fixed dots per ripple, riding the\n      // stoss segment between the previous ripple's trough and this\n      // ripple's crest, cheap and never per-pixel.\n      ctx.fillStyle = stippleFill;\n      for (let i = 1; i < ripples.length; i++) {\n        const r = ripples[i]!;\n        const prev = ripples[i - 1]!;\n        const gap = r.x - prev.x;\n        const prevTroughX = prev.x + Math.min(LEE_RUN, gap);\n        const crestY = y0 - r.amp * AMP_DISPLAY_SCALE;\n        for (const s of r.stipple) {\n          const px = prevTroughX + (r.x - prevTroughX) * s.u;\n          const py = y0 + (crestY - y0) * s.u + s.dy;\n          ctx.beginPath();\n          ctx.arc(px, py, s.r, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n    };\n\n    const renderStatic = () => {\n      rand = mulberry32(STATIC_SEED);\n      simTime = 0;\n      ripples = buildRipples(w, rand);\n      const stepDt = 1 / 60;\n      let t = 0;\n      while (t < STATIC_TIME_S) {\n        const d = Math.min(stepDt, STATIC_TIME_S - t);\n        step(d);\n        t += d;\n      }\n      // Force the mechanic's signature mid-slip pose onto the field's\n      // centre ripple so the frozen frame always shows the overhang.\n      const target = ripples[Math.floor(ripples.length / 2)];\n      if (target) {\n        const origX = target.x;\n        const migrate = MIGRATE_MIN + rand() * (MIGRATE_MAX - MIGRATE_MIN);\n        const e = easeOutCubic(0.5);\n        target.avalanche = null;\n        target.amp = lerp(THRESHOLD_AMP, STABLE_AMP, e);\n        target.x = lerp(origX, origX + migrate, e);\n      }\n      draw();\n    };\n\n    const frame = (now: number) => {\n      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (now - last) / 1000);\n      last = now;\n      step(dt);\n      draw();\n    };\n\n    const rafLoop = (now: number) => {\n      frame(now);\n      if (!paused) raf = requestAnimationFrame(rafLoop);\n    };\n\n    const stopLoop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const startLoop = () => {\n      stopLoop();\n      if (paused) return;\n      if (reduced) {\n        renderStatic();\n        return;\n      }\n      rand = Math.random;\n      simTime = 0;\n      ripples = buildRipples(w, rand);\n      raf = requestAnimationFrame(rafLoop);\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(DPR_CAP, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      canvas.style.width = \"100%\";\n      canvas.style.height = \"100%\";\n      startLoop();\n    };\n\n    resize();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const onThemeChange = () => derive();\n    const mo = new MutationObserver(onThemeChange);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\"],\n    });\n    const colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    colorScheme.addEventListener(\"change\", onThemeChange);\n\n    const reducedMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onReducedChange = () => {\n      reduced = reducedMq.matches;\n      startLoop();\n    };\n    reducedMq.addEventListener(\"change\", onReducedChange);\n\n    const onVisibility = () => {\n      paused = document.hidden;\n      if (paused) stopLoop();\n      else startLoop();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    let io: IntersectionObserver | undefined;\n    if (\"IntersectionObserver\" in window) {\n      io = new IntersectionObserver((entries) => {\n        const entry = entries[0];\n        if (!entry) return;\n        paused = !entry.isIntersecting || document.hidden;\n        if (paused) stopLoop();\n        else startLoop();\n      });\n      io.observe(root);\n    }\n\n    return () => {\n      stopLoop();\n      ro.disconnect();\n      mo.disconnect();\n      colorScheme.removeEventListener(\"change\", onThemeChange);\n      reducedMq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      io?.disconnect();\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative h-full w-full overflow-hidden bg-background ${className}`}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 h-full w-full\"\n      />\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/ripple-migrate-slip.tsx"
    }
  ],
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "sand",
      "dune",
      "physics",
      "granular",
      "mono",
      "ambient"
    ],
    "instruction": "Build <RippleMigrateSlip children? className?> as a full-bleed 2D-canvas hero rendering AEOLIAN SAND-RIPPLE MIGRATION as an exact piecewise-linear ridge silhouette, not a particle field or a sampled heightfield. THE MECHANISM: each ripple is one object carrying a scalar stoss-pile height (amp) that grows continuously — modulated by a slow global gust sine, GUST_PERIOD_S=11, amplitude fraction 0.25 — until it crosses an angle-of-repose threshold (34deg over a fixed 40px lee run, THRESHOLD_AMP = 40*tan(34deg) ~= 27px), at which point a 260ms avalanche eases amp down to a stable residual (20deg over the same run, ~14.6px) and eases the crest x forward by 6-10px in the same interpolation — the avalanche interpolation IS the migration step, there is no separate transform. Growth rate is tuned (per-ripple, ~9-12s to threshold with a 0.85-1.15 per-ripple multiplier for staggering) so that across the several ripples visible at full-bleed width, avalanche events land roughly 1.1-1.6s apart somewhere in the field, never synchronized — this is what the round 9 legibility rule requires for a discrete-event mechanic. RENDERING: ripples are built left-to-right with randomized 150-210px (visual) gaps between crests, extending past both canvas edges by one wavelength so there is never a visible seam; the silhouette is drawn as ONE exact polygon through every ripple's crest point and its post-lee trough point (piecewise-linear lineTo calls, no bucketed heightfield sampling — this avoids any aliasing between the physics and the drawn shape), filled from a y0 baseline at 68% of container height down to the canvas bottom, with rendered ridge height = amp * 4 (a display-only scale-up from the sand-grain-scale physics amplitude to a legible full-bleed ridge height; the physics thresholds/rates themselves are NOT scaled). A handful of small fixed dots per ripple (4-6, precomputed offsets stored on the ripple object at creation) ride each ripple's stoss segment as a grain-texture stipple, in a slightly higher foreground-mix tone than the base ridge fill — cheap, never per-pixel, never a real grain simulation (that territory belongs to winnow-chaff-drift). ALIVE AT REST: t0 shows several ripples mid-accretion at different phases with no avalanche active; by 2.5s at least one avalanche has fired and completed somewhere in the field (a visible profile shift); by 5s cumulative crest drift is visible when compared to t0 and 2-3 avalanche events will have fired across the field, each individually legible at the moment it happens. Colors: ridge fill is a fixed --background/--foreground luminance mix (0.55) via getComputedStyle re-derived on a documentElement class/attribute MutationObserver and a prefers-color-scheme listener; stipple dots mix further toward --foreground (delta 0.12 dark theme, 0.22 light theme, since light theme is the harder case for a low-contrast stipple to hold) — zero color literals, zero hue, no --ns-accent anywhere (fully ambient, no pointer interaction to justify it). prefers-reduced-motion runs a deterministic seeded (mulberry32) 14s settle from a fixed seed, then forces the field's centre ripple into a manually-set t=0.5 avalanche pose (pre-slip overhang blended toward the post-slip stable line) before a single draw() and no further animation, so the frozen frame always shows the mechanic's signature mid-slip moment rather than an arbitrary resting pose. DPR capped at 1.5 (full-bleed cost dominates). Standard canvas host lifecycle: ResizeObserver on the root (rebuilds the ripple field on resize), IntersectionObserver (threshold default) and visibilitychange both pausing the rAF loop, full teardown on unmount. Zero dependencies, DOM + Canvas 2D only, no WebGL. Canvas is pointer-events-none and aria-hidden; children render as an ordinary absolutely-positioned DOM overlay on top, same pattern as winnow-chaff-drift and dye-whorl."
  },
  "type": "registry:ui"
}