{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "leaven-crest-fall",
  "title": "Leaven Crest Fall",
  "description": "A sourdough starter jar status indicator: the surface dome rises with a Poisson-nucleated bubble texture to a peak, then collapses as the bubble population thins in step with the falling height, fed and risen again on a repeating 12.6s cycle.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/leaven-crest-fall/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// LeavenCrestFall — a sourdough starter jar rendered as a live status\n// gauge: the surface dome rises to a blistered peak on CO2 from\n// fermentation, then collapses as gas escapes, fed again, and rises again —\n// forever. Card-scale SVG only, no canvas.\n//\n// TIMING. One cycle is 12.6s: an 8s logistic RISE from a residual settled\n// height up to 2.4x that baseline, a 4s exponential-decay FALL back down to\n// ~1.05x baseline (real starter never returns fully flat — a thin residual\n// film/height is what's left before the next feed), then a 600ms FEED pulse\n// (a brief brightness flash standing in for fresh flour/water going in)\n// before the next rise starts. `n(t)` in [0,1] is the normalised rise\n// fraction; the dome's height multiplier is `1 + 1.4*n`, so n=0 is baseline\n// (1.0x) and n=1 is peak (2.4x). N_SETTLE = 0.05/1.4 ~= 0.0357 is the floor\n// n never goes below after a fall, which is exactly what keeps the fall's\n// exponential decay landing at 1.05x rather than 1.0x.\n//\n// DOME SHAPE. The surface is sampled at DOME_SAMPLES x-positions across the\n// jar's interior width every frame; each sample's y-offset above the\n// baseline is `riseAmplitude(t) * bump(x)` (a raised-cosine bump, zero at\n// the jar walls, 1 at the centre) plus a small two-harmonic ripple that\n// slowly rotates phase over wall-clock time — the ripple's job is purely to\n// keep the surface visibly non-static during any *flat-ish* stretch of the\n// rise/fall curve (e.g. right at the peak hold), independent of the\n// rise/fall amplitude itself. Samples are joined into one smooth path with\n// successive quadratic curves through each pair's midpoint (a cheap\n// Catmull-Rom-ish smoothing that needs no extra library), then closed down\n// to the jar's fixed floor to fill the whole starter body.\n//\n// BUBBLES. Surface blistering is a real Poisson arrival process, not\n// decoration bolted on top: during the rise phase only, each frame has a\n// `LAMBDA * dt` chance of nucleating one new bubble at a random x along the\n// dome, with radius growing as `R0 * age^0.3` up to MAX_R (the real\n// power-law growth shape of a gas bubble breaking a viscous surface, capped\n// for legibility). During the fall phase the live population is walked DOWN\n// to a target count computed directly from n(t) — `target =\n// peakCount * (n - N_SETTLE) / (1 - N_SETTLE)` — so the number of bubbles on\n// screen is causally tied to the height curve's own decay, not an\n// independently-timed decoration; bubbles at or below the target are told to\n// pop (radius eased to 0 over 120ms, smallest first) exactly as fast as the\n// dome itself is falling. A fixed-size pool of BUBBLE_POOL <circle> elements\n// is pre-rendered once and reused (r set to 0 when inactive) so the DOM node\n// count never changes across a cycle.\n//\n// Every per-frame numeric write (the dome path's `d`, each bubble's cx/cy/r,\n// the feed-flash overlay's opacity) goes straight to the DOM via refs inside\n// one rAF loop — no per-frame setState, so React never re-renders during\n// the animation. A single low-frequency (~1Hz) status-text update is the\n// only state write once mounted.\n//\n// prefers-reduced-motion freezes at t=8000ms of the 12.6s cycle (the crest\n// exactly at its peak: maximum dome height, maximum bubble count) — the\n// most structured, information-dense frame, deliberately not t0. That exact\n// state is precomputed once from a small seeded PRNG so a reduced-motion\n// render is deterministic rather than \"whatever the rAF loop happened to be\n// doing when it got told to stop.\"\n//\n// Colours are read straight off CSS custom properties in every fill/stroke\n// (var(--foreground)/var(--ns-muted)/var(--border)/var(--background)) —\n// zero literals, zero getComputedStyle: a theme flip repaints for free.\n// Geometry lives in one fixed SVG viewBox scaled by preserveAspectRatio\n// (\"xMidYMid meet\"), which is what makes it read at card scale — the\n// coordinate system is uniformly fit to whichever of the container's two\n// dimensions is smaller, the same mechanism `cambium-lay` uses.\n// ---------------------------------------------------------------------------\n\nexport interface LeavenCrestFallProps {\n  className?: string;\n}\n\nconst VB_W = 200;\nconst VB_H = 220;\n\nconst JAR_LEFT = 46;\nconst JAR_RIGHT = 154;\nconst JAR_TOP = 20;\nconst JAR_BOTTOM = 196;\nconst JAR_CX = (JAR_LEFT + JAR_RIGHT) / 2;\n\nconst BASE_Y = 108; // baseline (1.0x) dome surface height\nconst CREST_MAX_RISE = 48; // viewBox units of headspace the crest can climb into at n=1\n\nconst RISE_MS = 8000;\nconst FALL_MS = 4000;\nconst FEED_MS = 600;\nconst CYCLE_MS = RISE_MS + FALL_MS + FEED_MS; // 12600\n\nconst N_SETTLE = 0.05 / 1.4; // ~0.0357 — floor n never falls below\nconst RISE_SIGMOID_K = 9; // logistic steepness\n\nconst DOME_SAMPLES = 22;\nconst RIPPLE_AMPL = 1.1;\nconst RIPPLE_OMEGA = 0.5; // rad/s — slow phase rotation, keeps the surface alive even mid-plateau\n\nconst LAMBDA = 0.8; // bubbles/sec nucleation rate during rise\nconst R0 = 1.4;\nconst MAX_R = 3.4;\nconst POP_MS = 120;\nconst BUBBLE_POOL = 30;\n\ntype Bubble = {\n  active: boolean;\n  x: number;\n  birth: number; // ms, performance.now() timestamp\n  r: number;\n  poppingAt: number | null;\n  poppedFromR: number;\n};\n\nfunction clamp(n: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, n));\n}\n\n// normalised sigmoid: sig(0)=0, sig(1)=1\nfunction sigmoidNorm(u: number, k: number): number {\n  const s0 = 1 / (1 + Math.exp(k * 0.5));\n  const s1 = 1 / (1 + Math.exp(-k * 0.5));\n  const s = 1 / (1 + Math.exp(-k * (u - 0.5)));\n  return (s - s0) / (s1 - s0);\n}\n\n// n(t) for t = ms elapsed within one cycle, in [0, CYCLE_MS)\nfunction riseFraction(cycleT: number): number {\n  if (cycleT < RISE_MS) {\n    const u = cycleT / RISE_MS;\n    return N_SETTLE + (1 - N_SETTLE) * sigmoidNorm(u, RISE_SIGMOID_K);\n  }\n  if (cycleT < RISE_MS + FALL_MS) {\n    const u = (cycleT - RISE_MS) / FALL_MS;\n    return N_SETTLE + (1 - N_SETTLE) * Math.exp(-3 * u);\n  }\n  return N_SETTLE;\n}\n\n// derivative-ish phase flag: are we in the fall window right now\nfunction isFalling(cycleT: number): boolean {\n  return cycleT >= RISE_MS && cycleT < RISE_MS + FALL_MS;\n}\n\nfunction isFeeding(cycleT: number): boolean {\n  return cycleT >= RISE_MS + FALL_MS;\n}\n\nfunction bumpFn(u: number): number {\n  // raised cosine (Hann window), u in [0,1]: 0 at both edges, 1 at centre\n  return (1 - Math.cos(u * Math.PI * 2)) / 2;\n}\n\nfunction domeAmplitude(n: number): number {\n  return CREST_MAX_RISE * n;\n}\n\nfunction buildDomePath(sampleYs: number[], sampleXs: number[]): string {\n  const n = sampleYs.length;\n  let d = `M ${sampleXs[0]},${sampleYs[0]}`;\n  for (let i = 0; i < n - 1; i++) {\n    const mx = (sampleXs[i] + sampleXs[i + 1]) / 2;\n    const my = (sampleYs[i] + sampleYs[i + 1]) / 2;\n    d += ` Q ${sampleXs[i]},${sampleYs[i]} ${mx},${my}`;\n  }\n  d += ` L ${sampleXs[n - 1]},${sampleYs[n - 1]} L ${JAR_RIGHT},${JAR_BOTTOM} L ${JAR_LEFT},${JAR_BOTTOM} Z`;\n  return d;\n}\n\n// Deterministic seeded PRNG (mulberry32) so the reduced-motion frame is stable.\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 buildReducedFrame(): { domeD: string; bubbles: { x: number; y: number; r: number }[] } {\n  const n = riseFraction(RISE_MS); // exactly the peak, n -> 1\n  const amp = domeAmplitude(n);\n  const xs: number[] = [];\n  const ys: number[] = [];\n  for (let i = 0; i < DOME_SAMPLES; i++) {\n    const u = i / (DOME_SAMPLES - 1);\n    const x = JAR_LEFT + u * (JAR_RIGHT - JAR_LEFT);\n    const y = BASE_Y - amp * bumpFn(u);\n    xs.push(x);\n    ys.push(y);\n  }\n  const domeD = buildDomePath(ys, xs);\n\n  const rnd = mulberry32(1337);\n  const bubbles: { x: number; y: number; r: number }[] = [];\n  const count = 14; // representative peak-population count for the frozen frame\n  for (let i = 0; i < count; i++) {\n    const u = 0.08 + rnd() * 0.84;\n    const x = JAR_LEFT + u * (JAR_RIGHT - JAR_LEFT);\n    const y = BASE_Y - amp * bumpFn(u) - 1.5;\n    const r = R0 + rnd() * (MAX_R - R0);\n    bubbles.push({ x, y, r });\n  }\n  return { domeD, bubbles };\n}\n\nexport function LeavenCrestFall({ className = \"\" }: LeavenCrestFallProps) {\n  const [reduced, setReduced] = useState(false);\n  const [statusText, setStatusText] = useState(\"sourdough starter rising\");\n\n  const domeRef = useRef<SVGPathElement | null>(null);\n  const flashRef = useRef<SVGPathElement | null>(null);\n  const bubbleRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const bubblesRef = useRef<Bubble[]>(\n    Array.from({ length: BUBBLE_POOL }, () => ({\n      active: false,\n      x: 0,\n      birth: 0,\n      r: 0,\n      poppingAt: null,\n      poppedFromR: 0,\n    })),\n  );\n  const peakCountRef = useRef(0);\n  const wasFallingRef = useRef(false);\n  const startRef = useRef(0);\n  const rafRef = useRef(0);\n  const lastStatusRef = useRef(0);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    if (reduced) return;\n\n    startRef.current = performance.now();\n    lastStatusRef.current = 0;\n\n    const step = (now: number) => {\n      const elapsed = now - startRef.current;\n      const cycleT = elapsed % CYCLE_MS;\n      const n = riseFraction(cycleT);\n      const amp = domeAmplitude(n);\n      const falling = isFalling(cycleT);\n      const feeding = isFeeding(cycleT);\n\n      if (falling && !wasFallingRef.current) {\n        peakCountRef.current = bubblesRef.current.filter((b) => b.active && b.poppingAt === null).length;\n      }\n      wasFallingRef.current = falling;\n\n      // -- dome surface --\n      const xs: number[] = [];\n      const ys: number[] = [];\n      const t = elapsed / 1000;\n      for (let i = 0; i < DOME_SAMPLES; i++) {\n        const u = i / (DOME_SAMPLES - 1);\n        const x = JAR_LEFT + u * (JAR_RIGHT - JAR_LEFT);\n        const ripple =\n          RIPPLE_AMPL *\n          (0.6 * Math.sin(3 * u * Math.PI * 2 + t * RIPPLE_OMEGA) +\n            0.4 * Math.sin(5 * u * Math.PI * 2 - t * RIPPLE_OMEGA * 1.3)) *\n          bumpFn(u);\n        const y = BASE_Y - amp * bumpFn(u) + ripple;\n        xs.push(x);\n        ys.push(y);\n      }\n      if (domeRef.current) {\n        domeRef.current.setAttribute(\"d\", buildDomePath(ys, xs));\n      }\n\n      // -- feed flash --\n      if (flashRef.current) {\n        let flashOpacity = 0;\n        if (feeding) {\n          const u = (cycleT - RISE_MS - FALL_MS) / FEED_MS;\n          flashOpacity = Math.sin(Math.PI * clamp(u, 0, 1)) * 0.35;\n        }\n        flashRef.current.setAttribute(\"opacity\", flashOpacity.toFixed(3));\n      }\n\n      // -- bubbles: nucleate during rise --\n      const bubbles = bubblesRef.current;\n      if (!falling && !feeding) {\n        const dtSpawn = 1 / 60; // approximate per-frame window for the Poisson check\n        if (Math.random() < LAMBDA * dtSpawn) {\n          const free = bubbles.findIndex((b) => !b.active);\n          if (free !== -1) {\n            const u = 0.06 + Math.random() * 0.88;\n            bubbles[free] = {\n              active: true,\n              x: JAR_LEFT + u * (JAR_RIGHT - JAR_LEFT),\n              birth: now,\n              r: 0,\n              poppingAt: null,\n              poppedFromR: 0,\n            };\n          }\n        }\n      }\n\n      // -- bubbles: pop down toward target during fall --\n      if (falling) {\n        const target = Math.round(\n          peakCountRef.current * clamp((n - N_SETTLE) / (1 - N_SETTLE), 0, 1),\n        );\n        const live = bubbles\n          .map((b, i) => ({ b, i }))\n          .filter((x) => x.b.active && x.b.poppingAt === null)\n          .sort((a, c) => a.b.r - c.b.r); // smallest first\n        const excess = live.length - target;\n        for (let k = 0; k < excess; k++) {\n          const entry = live[k];\n          entry.b.poppingAt = now;\n          entry.b.poppedFromR = entry.b.r;\n        }\n      }\n\n      // -- bubbles: advance radius / render --\n      for (let i = 0; i < bubbles.length; i++) {\n        const b = bubbles[i];\n        const el = bubbleRefs.current[i];\n        if (!b.active) {\n          if (el) el.setAttribute(\"r\", \"0\");\n          continue;\n        }\n        if (b.poppingAt !== null) {\n          const u = clamp((now - b.poppingAt) / POP_MS, 0, 1);\n          b.r = b.poppedFromR * (1 - u);\n          if (u >= 1) {\n            b.active = false;\n            b.poppingAt = null;\n          }\n        } else {\n          const ageSec = (now - b.birth) / 1000;\n          b.r = Math.min(MAX_R, R0 * Math.pow(Math.max(ageSec, 0.001), 0.3) - R0 * 0.4);\n          b.r = clamp(b.r, 0, MAX_R);\n        }\n        if (el) {\n          const u = clamp((b.x - JAR_LEFT) / (JAR_RIGHT - JAR_LEFT), 0, 1);\n          const y = BASE_Y - amp * bumpFn(u) - b.r * 0.7;\n          el.setAttribute(\"cx\", b.x.toFixed(2));\n          el.setAttribute(\"cy\", y.toFixed(2));\n          el.setAttribute(\"r\", Math.max(0, b.r).toFixed(2));\n        }\n      }\n\n      if (now - lastStatusRef.current > 900) {\n        lastStatusRef.current = now;\n        setStatusText(\n          feeding\n            ? \"sourdough starter fed, beginning to rise\"\n            : falling\n              ? \"sourdough starter collapsing\"\n              : \"sourdough starter rising\",\n        );\n      }\n\n      rafRef.current = window.requestAnimationFrame(step);\n    };\n\n    rafRef.current = window.requestAnimationFrame(step);\n    return () => window.cancelAnimationFrame(rafRef.current);\n  }, [reduced]);\n\n  const reducedFrame = reduced ? buildReducedFrame() : null;\n\n  return (\n    <div className={`relative h-full w-full overflow-hidden ${className}`} data-leaven-crest-fall>\n      <svg\n        viewBox={`0 0 ${VB_W} ${VB_H}`}\n        width=\"100%\"\n        height=\"100%\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        className=\"pointer-events-none block h-full w-full\"\n      >\n        {/* jar outline */}\n        <path\n          d={`M ${JAR_LEFT},${JAR_TOP} L ${JAR_LEFT},${JAR_BOTTOM - 14} Q ${JAR_LEFT},${JAR_BOTTOM} ${JAR_LEFT + 14},${JAR_BOTTOM} L ${JAR_RIGHT - 14},${JAR_BOTTOM} Q ${JAR_RIGHT},${JAR_BOTTOM} ${JAR_RIGHT},${JAR_BOTTOM - 14} L ${JAR_RIGHT},${JAR_TOP}`}\n          fill=\"none\"\n          stroke=\"var(--border)\"\n          strokeWidth={1.5}\n        />\n        {/* baseline fill mark, the rubber-band bakers use to track rise */}\n        <line\n          x1={JAR_LEFT - 4}\n          y1={BASE_Y}\n          x2={JAR_LEFT + 3}\n          y2={BASE_Y}\n          stroke=\"var(--border)\"\n          strokeWidth={1.5}\n        />\n\n        {reduced && reducedFrame ? (\n          <>\n            <path d={reducedFrame.domeD} fill=\"var(--ns-muted)\" />\n            {reducedFrame.bubbles.map((b, i) => (\n              <circle\n                key={i}\n                cx={b.x}\n                cy={b.y}\n                r={b.r}\n                fill=\"none\"\n                stroke=\"var(--foreground)\"\n                strokeWidth={0.6}\n              />\n            ))}\n          </>\n        ) : (\n          <>\n            <path ref={domeRef} fill=\"var(--ns-muted)\" d=\"\" />\n            {Array.from({ length: BUBBLE_POOL }).map((_, i) => (\n              <circle\n                key={i}\n                ref={(el) => {\n                  bubbleRefs.current[i] = el;\n                }}\n                cx={0}\n                cy={0}\n                r={0}\n                fill=\"none\"\n                stroke=\"var(--foreground)\"\n                strokeWidth={0.6}\n              />\n            ))}\n            <path\n              ref={flashRef}\n              d={`M ${JAR_LEFT},${BASE_Y} L ${JAR_RIGHT},${BASE_Y} L ${JAR_RIGHT},${JAR_BOTTOM} L ${JAR_LEFT},${JAR_BOTTOM} Z`}\n              fill=\"var(--foreground)\"\n              opacity={0}\n            />\n          </>\n        )}\n      </svg>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {statusText}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/leaven-crest-fall.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "status",
      "gauge",
      "svg",
      "fermentation",
      "cyclical",
      "ambient",
      "sourdough",
      "bubbles"
    ],
    "instruction": "A card-scale SVG-only sourdough-starter jar. A jar outline (a static <path>, rounded bottom corners, stroked in var(--border), never filled) sits fixed at JAR_LEFT=46/JAR_RIGHT=154/JAR_TOP=20/JAR_BOTTOM=196 inside a 200x220 viewBox scaled by preserveAspectRatio=\"xMidYMid meet\" so geometry is uniformly fit to whichever of the container's dimensions is smaller. The starter body is one filled <path> (var(--ns-muted)) whose top edge is the dome: every rAF frame, DOME_SAMPLES=22 x-positions across the jar's interior width each get a y-offset above BASE_Y=108 of `riseAmplitude(t) * bump(u)` where bump(u)=(1-cos(2*pi*u))/2 is a raised-cosine 0 at both jar walls and 1 at centre, plus a small two-harmonic ripple (amplitude 1.1, harmonics 3 and 5, phase rotating at 0.5 rad/s) that keeps the surface visibly non-static even during flat stretches of the rise/fall curve. Samples are joined into a smooth path with successive quadratic curves through each adjacent pair's midpoint, then closed down to the fixed JAR_BOTTOM. Height follows n(t) in [0,1], the fraction of one 12600ms cycle: an 8000ms logistic RISE (steepness k=9) from N_SETTLE=0.05/1.4~=0.0357 up toward 1, a 4000ms exponential-decay FALL back down to N_SETTLE (exp(-3*u)), then a 600ms FEED window with no height change but a brightness flash. The dome's height multiplier is 1+1.4*n and riseAmplitude(t)=48*n. Surface bubbles are a real Poisson arrival process: during the rise phase only, each frame has a LAMBDA*dt=0.8/60 chance of nucleating a new bubble at a random x, radius growing R0*age^0.3 up to MAX_R=3.4 viewBox units. The instant the fall phase begins, the current live (non-popping) bubble count is captured as peakCount; every frame during the fall, target=round(peakCount*clamp((n-N_SETTLE)/(1-N_SETTLE),0,1)) is recomputed and the smallest-radius live bubbles beyond that target are marked popping (radius eased 1->0 over 120ms) — so the bubble population visibly thins exactly as fast as the dome itself falls, a causal coupling rather than an independently-timed decoration. A fixed pool of 30 pre-rendered <circle> elements (unused ones r=0) is reused across the whole run so the DOM node count never changes. Every per-frame numeric write (dome path d, each bubble's cx/cy/r, the feed-flash overlay's opacity attribute) is a direct ref.setAttribute call inside one rAF loop; React state is only touched for prefers-reduced-motion detection and a ~1Hz sr-only status string (\"rising\"/\"collapsing\"/\"fed, beginning to rise\"), so the component never re-renders during the animation itself. autoplay: none because the whole cycle runs on its own internal clock with zero pointer/scroll/press dependency. Under prefers-reduced-motion the component renders one precomputed, seeded-PRNG-deterministic frame equal to t=8000ms of the cycle (n at its peak, ~14 bubbles at varied radii) instead of running the rAF loop at all, and the bubble circles in that frame are drawn stroked-outline only (no fill/no radius animation) to make clear nothing is mid-motion. Every colour is a CSS custom property read directly in SVG fill/stroke attributes (var(--background)/var(--foreground)/var(--ns-muted)/var(--border)) — zero literals anywhere, zero getComputedStyle, a theme flip repaints for free. The svg is aria-hidden with pointer-events-none; a single visually-hidden role=status/aria-live=polite span carries the current phase text for anyone landing on the region with a screen reader."
  },
  "type": "registry:ui"
}