{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "frazil-dam",
  "title": "Frazil Dam",
  "description": "A card-scale processing/progress indicator modelled on frazil ice: fine crystals nucleate in turbulent water, drift across a channel via a 2-octave curl-noise flow field, and pile against a rack, the crest rising over 15-20s to a threshold, then calving a coherent chunk downstream and resuming from a smaller residual base, unbounded.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/frazil-dam/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FrazilDam — a card-scale processing/progress indicator modelled on frazil\n// ice: fine, flat, mm-scale crystals that nucleate directly within turbulent\n// water (rather than skinning over a still surface), are advected by the\n// flow, and pile up against an obstruction — a channel narrowing, an\n// ice-boom rack, the nose of an already-frozen reach — building a\n// frazil/anchor-ice dam. Once accumulated mass or local hydraulics cross a\n// threshold, a coherent portion sloughs off as an \"ice run\" and is carried\n// downstream, after which accumulation resumes from a smaller residual base\n// (documented in river-ice engineering literature, e.g. Beltaos, River Ice\n// Formation) — never a full reset to bare channel.\n//\n// One canvas, cleared and redrawn every frame from a fixed-timestep (20Hz)\n// simulation accumulator, mirroring how the paint-rate-decoupling guidance\n// wants any real periodic process handled: the curl-noise velocity field\n// that advects crystals is re-sampled at each 20Hz tick (never per-frame),\n// and crystal position, dam growth and the release/chunk animation all\n// advance inside that same fixed-step loop so motion never depends on\n// display refresh rate.\n//\n// Crystals spawn at the channel's left edge at a fixed rate, drift right on\n// a base current plus a divergence-free curl of a 2-octave value-noise\n// potential field (the same construction as background-ascii-flow: curl of\n// a scalar field guarantees the flow never \"pools\" a crystal — it is always\n// being pushed somewhere), and lodge once they reach the dam's leading\n// (upstream) face or the rack itself. The dam's crest height is driven on\n// its own clock (accumulation compressed from the real minutes-to-hours\n// timescale to ~15-20s, per cycle, toward a threshold measured as 70% of the\n// channel's cross-section) independently of exactly which crystal lodges\n// when — the crystal layer is the visible turbulent-transport texture, the\n// crest curve is the legible headline metric. On crossing threshold, 35-45%\n// of the accumulated crest breaks free as one coherent chunk that travels\n// across and off the right edge over 1.2s while the crest itself eases down\n// to a 55-65% residual base, and accumulation resumes from there.\n// ---------------------------------------------------------------------------\n\nconst SIM_HZ = 20; // curl-noise field + dam/crystal sim tick rate\nconst SIM_STEP_S = 1 / SIM_HZ;\nconst SIM_STEP_MS = 1000 / SIM_HZ;\nconst MAX_CATCHUP_TICKS = 8; // guards against a huge dt after a tab was backgrounded\n\nconst SPAWN_RATE = 8; // crystals/s at the left edge (spec: 6-10/s)\nconst BASE_TRANSIT_S = 5; // baseline left-edge-to-rack transit time (spec: 2-8s)\n\nconst THRESHOLD = 0.7; // release threshold: crest at 70% of channel cross-section\nconst CYCLE_MIN_S = 15;\nconst CYCLE_MAX_S = 20; // dam accumulation compressed to ~15-20s per cycle\nconst RELEASE_MS = 1200; // chunk transit across and off the frame\nconst CREST_DROP_MS = 420; // how fast the crest itself eases down once a release fires\nconst RESIDUAL_MIN = 0.55;\nconst RESIDUAL_MAX = 0.65; // residual dam: 55-65% of prior crest survives a release\n\nconst STATIC_TOWARD_THRESHOLD = 0.55; // reduced-motion freeze: 55% of the way to threshold\nconst STATIC_CRYSTAL_COUNT = 8;\n\nconst NOISE_FREQ = 0.045;\nconst FIELD_SPEED = 0.1; // t units/s the potential drifts\nconst CURL_EPS = 0.5;\n\nfunction hash2(ix: number, iy: number, seed: number): number {\n  const s = Math.sin(ix * 127.1 + iy * 311.7 + seed * 74.7) * 43758.5453;\n  return s - Math.floor(s);\n}\n\nfunction smooth(t: number) {\n  return t * t * (3 - 2 * t);\n}\n\nfunction noise2D(x: number, y: number, seed: number): number {\n  const x0 = Math.floor(x);\n  const y0 = Math.floor(y);\n  const tx = smooth(x - x0);\n  const ty = smooth(y - y0);\n  const a = hash2(x0, y0, seed);\n  const b = hash2(x0 + 1, y0, seed);\n  const c = hash2(x0, y0 + 1, seed);\n  const d = hash2(x0 + 1, y0 + 1, seed);\n  const top = a + (b - a) * tx;\n  const bot = c + (d - c) * tx;\n  return top + (bot - top) * ty;\n}\n\n// 2-octave scalar potential\nfunction potential(x: number, y: number, t: number): number {\n  const n1 = noise2D(x * NOISE_FREQ, y * NOISE_FREQ + t, 8.1);\n  const n2 = noise2D(x * NOISE_FREQ * 2.2 - t * 0.5, y * NOISE_FREQ * 2.2, 41.7);\n  return n1 * 0.7 + n2 * 0.3;\n}\n\n// curl of the potential -> divergence-free turbulent velocity, px/s once scaled\nfunction curlVel(x: number, y: number, t: number, scale: number): [number, number] {\n  const py1 = potential(x, y + CURL_EPS, t);\n  const py0 = potential(x, y - CURL_EPS, t);\n  const px1 = potential(x + CURL_EPS, y, t);\n  const px0 = potential(x - CURL_EPS, y, t);\n  const vx = ((py1 - py0) / (2 * CURL_EPS)) * scale;\n  const vy = -((px1 - px0) / (2 * CURL_EPS)) * scale;\n  return [vx, vy];\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction randRange(rand: () => number, lo: number, hi: number): number {\n  return lo + rand() * (hi - lo);\n}\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return Math.max(lo, Math.min(hi, v));\n}\n\ninterface Tokens {\n  fg: string;\n  muted: string;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const fg = cs.getPropertyValue(\"--foreground\").trim();\n  const muted = cs.getPropertyValue(\"--ns-muted\").trim();\n  if (!fg || !muted) return null; // not loaded yet — no paint before this\n  return { fg, muted };\n}\n\ninterface Geo {\n  W: number;\n  H: number;\n  minDim: number;\n  channelTop: number;\n  channelBottom: number;\n  channelH: number;\n  channelLen: number;\n  rackX: number;\n  exitX: number;\n  pileMinW: number;\n  pileMaxW: number;\n  crystalR: number;\n  curlScale: number;\n  baseVx: number;\n}\n\nfunction computeGeo(W: number, H: number): Geo {\n  const minDim = Math.min(W, H);\n  const channelTop = H * 0.14;\n  const channelBottom = H * 0.86;\n  const channelH = channelBottom - channelTop;\n  const rackX = W * 0.78;\n  const exitX = W * 1.08;\n  const channelLen = rackX;\n  return {\n    W,\n    H,\n    minDim,\n    channelTop,\n    channelBottom,\n    channelH,\n    channelLen,\n    rackX,\n    exitX,\n    pileMinW: minDim * 0.09,\n    pileMaxW: minDim * 0.3,\n    crystalR: clamp(minDim * 0.014, 2, 4),\n    curlScale: minDim * 0.5,\n    baseVx: channelLen / BASE_TRANSIT_S,\n  };\n}\n\nfunction pileWidth(crestFrac: number, g: Geo): number {\n  return g.pileMinW + (crestFrac / THRESHOLD) * (g.pileMaxW - g.pileMinW);\n}\n\n/** height of the dam's ice surface above the channel floor at a given x, for the current crest fraction */\nfunction damHeightAt(x: number, crestFrac: number, g: Geo): number {\n  if (crestFrac <= 0) return 0;\n  const w = pileWidth(crestFrac, g);\n  const left = g.rackX - w;\n  if (x < left) return 0;\n  const t = clamp((x - left) / Math.max(w, 0.001), 0, 1);\n  return crestFrac * g.channelH * Math.pow(t, 1.4);\n}\n\ninterface Crystal {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n}\n\ninterface Chunk {\n  x: number;\n  y: number;\n  y0: number;\n  startedAtMs: number;\n}\n\ntype Phase = \"accumulate\" | \"release\";\n\ninterface SimState {\n  crystals: Crystal[];\n  spawnAcc: number;\n  phase: Phase;\n  cycleT: number; // seconds since this accumulation phase started\n  cycleRiseS: number;\n  residualBase: number; // crest fraction this cycle rises from\n  releaseElapsedMs: number;\n  releaseResidual: number; // crest fraction the current release settles to\n  chunk: Chunk | null;\n  simTimeS: number;\n}\n\nfunction freshSim(rand: () => number): SimState {\n  return {\n    crystals: [],\n    spawnAcc: 0,\n    phase: \"accumulate\",\n    cycleT: 0,\n    cycleRiseS: randRange(rand, CYCLE_MIN_S, CYCLE_MAX_S),\n    residualBase: 0,\n    releaseElapsedMs: 0,\n    releaseResidual: 0,\n    chunk: null,\n    simTimeS: 0,\n  };\n}\n\n/** current crest fraction (0..THRESHOLD) as a pure function of phase state */\nfunction currentCrestFrac(sim: SimState): number {\n  if (sim.phase === \"accumulate\") {\n    const p = clamp(sim.cycleT / sim.cycleRiseS, 0, 1);\n    const eased = smooth(p);\n    return sim.residualBase + (THRESHOLD - sim.residualBase) * eased;\n  }\n  const dropP = clamp(sim.releaseElapsedMs / CREST_DROP_MS, 0, 1);\n  const eased = smooth(dropP);\n  return THRESHOLD + (sim.releaseResidual - THRESHOLD) * eased;\n}\n\nfunction spawnCrystal(sim: SimState, g: Geo, rand: () => number) {\n  sim.crystals.push({\n    x: 0,\n    y: randRange(rand, g.channelTop + 3, g.channelBottom - 3),\n    vx: g.baseVx,\n    vy: 0,\n  });\n}\n\nfunction beginRelease(sim: SimState, g: Geo, rand: () => number) {\n  sim.phase = \"release\";\n  sim.releaseElapsedMs = 0;\n  sim.releaseResidual = THRESHOLD * randRange(rand, RESIDUAL_MIN, RESIDUAL_MAX);\n  const crestY = g.channelBottom - THRESHOLD * g.channelH;\n  sim.chunk = { x: g.rackX - 2, y: crestY, y0: crestY, startedAtMs: 0 };\n}\n\nfunction endRelease(sim: SimState, rand: () => number) {\n  sim.phase = \"accumulate\";\n  sim.residualBase = sim.releaseResidual;\n  sim.cycleT = 0;\n  sim.cycleRiseS = randRange(rand, CYCLE_MIN_S, CYCLE_MAX_S);\n  sim.chunk = null;\n}\n\n/** advances the whole sim (spawn, curl-noise field resample, dam clock, release/chunk) by one fixed 20Hz tick */\nfunction tick(sim: SimState, g: Geo, rand: () => number) {\n  sim.simTimeS += SIM_STEP_S;\n\n  sim.spawnAcc += SIM_STEP_S * SPAWN_RATE;\n  while (sim.spawnAcc >= 1) {\n    sim.spawnAcc -= 1;\n    spawnCrystal(sim, g, rand);\n  }\n\n  const crestFrac = currentCrestFrac(sim);\n  const t = sim.simTimeS * FIELD_SPEED;\n\n  const next: Crystal[] = [];\n  for (const c of sim.crystals) {\n    const [cvx, cvy] = curlVel(c.x, c.y, t, g.curlScale);\n    c.vx = g.baseVx + cvx;\n    c.vy = cvy;\n    c.x += c.vx * SIM_STEP_S;\n    c.y += c.vy * SIM_STEP_S;\n    if (c.y < g.channelTop) {\n      c.y = g.channelTop;\n      c.vy = Math.abs(c.vy);\n    } else if (c.y > g.channelBottom) {\n      c.y = g.channelBottom;\n      c.vy = -Math.abs(c.vy);\n    }\n\n    if (c.x >= g.rackX) continue; // hit the rack — lodged\n    const leadX = g.rackX - pileWidth(crestFrac, g);\n    if (c.x >= leadX) {\n      const dh = damHeightAt(c.x, crestFrac, g);\n      if (c.y >= g.channelBottom - dh) continue; // lodged in the dam's face\n    }\n    if (c.x > g.W + 4) continue; // drifted clean past the frame, drop it\n    next.push(c);\n  }\n  sim.crystals = next;\n\n  if (sim.phase === \"accumulate\") {\n    sim.cycleT += SIM_STEP_S;\n    if (sim.cycleT >= sim.cycleRiseS) beginRelease(sim, g, rand);\n  } else {\n    sim.releaseElapsedMs += SIM_STEP_MS;\n    if (sim.chunk) {\n      const p = clamp(sim.releaseElapsedMs / RELEASE_MS, 0, 1);\n      const eased = 1 - (1 - p) * (1 - p); // ease-out — quick break, then swept downstream\n      sim.chunk.x = g.rackX - 2 + eased * (g.exitX - (g.rackX - 2));\n      const [wvx, wvy] = curlVel(sim.chunk.x, sim.chunk.y0, t, g.curlScale * 0.4);\n      void wvx;\n      sim.chunk.y = sim.chunk.y0 + wvy * p * 0.6 + p * g.channelH * 0.08;\n    }\n    if (sim.releaseElapsedMs >= RELEASE_MS) endRelease(sim, rand);\n  }\n}\n\nfunction drawWater(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens) {\n  ctx.save();\n  ctx.globalAlpha = 0.3;\n  ctx.fillStyle = tokens.muted;\n  ctx.fillRect(0, g.channelTop, g.W, g.channelH);\n  ctx.restore();\n}\n\nfunction drawDam(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens, crestFrac: number) {\n  if (crestFrac <= 0.002) return;\n  const w = pileWidth(crestFrac, g);\n  const left = g.rackX - w;\n  const steps = 20;\n  ctx.save();\n  ctx.beginPath();\n  ctx.moveTo(left, g.channelBottom);\n  for (let i = 0; i <= steps; i++) {\n    const x = left + (w * i) / steps;\n    const y = g.channelBottom - damHeightAt(x, crestFrac, g);\n    ctx.lineTo(x, y);\n  }\n  ctx.lineTo(g.rackX, g.channelBottom);\n  ctx.closePath();\n  const crestY = g.channelBottom - crestFrac * g.channelH;\n  const grad = ctx.createLinearGradient(0, crestY, 0, g.channelBottom);\n  grad.addColorStop(0, tokens.fg);\n  grad.addColorStop(1, tokens.muted);\n  ctx.globalAlpha = 0.92;\n  ctx.fillStyle = grad;\n  ctx.fill();\n  ctx.restore();\n}\n\nfunction drawRack(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens) {\n  ctx.save();\n  ctx.strokeStyle = tokens.fg;\n  ctx.globalAlpha = 1;\n  ctx.lineWidth = Math.max(1.5, g.minDim * 0.006);\n  ctx.beginPath();\n  ctx.moveTo(g.rackX, g.channelTop);\n  ctx.lineTo(g.rackX, g.channelBottom);\n  ctx.stroke();\n  // grate ticks — reads as a structural obstruction, not a bare line\n  ctx.lineWidth = Math.max(1, g.minDim * 0.004);\n  ctx.globalAlpha = 0.8;\n  const tickCount = 6;\n  const tickLen = g.minDim * 0.02;\n  for (let i = 1; i < tickCount; i++) {\n    const y = g.channelTop + (g.channelH * i) / tickCount;\n    ctx.beginPath();\n    ctx.moveTo(g.rackX - tickLen, y);\n    ctx.lineTo(g.rackX + tickLen, y);\n    ctx.stroke();\n  }\n  ctx.restore();\n}\n\nfunction drawCrystals(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens, crystals: Crystal[]) {\n  ctx.save();\n  ctx.fillStyle = tokens.fg;\n  ctx.globalAlpha = 0.78;\n  for (const c of crystals) {\n    ctx.beginPath();\n    ctx.arc(c.x, c.y, g.crystalR, 0, Math.PI * 2);\n    ctx.fill();\n  }\n  ctx.restore();\n}\n\nfunction drawChunk(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens, chunk: Chunk) {\n  ctx.save();\n  ctx.fillStyle = tokens.fg;\n  ctx.globalAlpha = 0.9;\n  const r = g.minDim * 0.045;\n  const offsets: [number, number, number][] = [\n    [0, 0, r],\n    [-r * 0.9, r * 0.35, r * 0.65],\n    [r * 0.85, -r * 0.25, r * 0.6],\n  ];\n  for (const [dx, dy, rr] of offsets) {\n    ctx.beginPath();\n    ctx.arc(chunk.x + dx, chunk.y + dy, rr, 0, Math.PI * 2);\n    ctx.fill();\n  }\n  ctx.restore();\n}\n\nfunction render(ctx: CanvasRenderingContext2D, g: Geo, tokens: Tokens, sim: SimState) {\n  ctx.clearRect(0, 0, g.W, g.H);\n  drawWater(ctx, g, tokens);\n  const crestFrac = currentCrestFrac(sim);\n  drawDam(ctx, g, tokens, crestFrac);\n  drawRack(ctx, g, tokens);\n  drawCrystals(ctx, g, tokens, sim.crystals);\n  if (sim.chunk) drawChunk(ctx, g, tokens, sim.chunk);\n}\n\n/** deterministically builds the reduced-motion freeze frame: mid-accumulation\n * at 55% toward threshold, several crystals visibly mid-transit. */\nfunction buildReducedSim(g: Geo, rand: () => number): SimState {\n  const sim = freshSim(rand);\n  sim.cycleT = sim.cycleRiseS * STATIC_TOWARD_THRESHOLD;\n  const crystals: Crystal[] = [];\n  for (let i = 0; i < STATIC_CRYSTAL_COUNT; i++) {\n    const x = randRange(rand, g.rackX * 0.06, g.rackX * 0.82);\n    const y = randRange(rand, g.channelTop + 4, g.channelBottom - 4);\n    crystals.push({ x, y, vx: g.baseVx, vy: 0 });\n  }\n  sim.crystals = crystals;\n  return sim;\n}\n\nexport interface FrazilDamProps {\n  /** small mono label above the chart */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function FrazilDam({ label = \"PROCESSING\", className = \"\" }: FrazilDamProps) {\n  const chartWrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const chartWrap = chartWrapRef.current;\n    const canvas = canvasRef.current;\n    if (!chartWrap || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const rand = mulberry32(0xf2a217);\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let dpr = 1;\n    let g: Geo = computeGeo(1, 1);\n    let sized = false;\n    let visible = true;\n\n    let sim = freshSim(rand);\n    let raf = 0;\n    let tokenWaitRaf = 0;\n    let lastMs = 0;\n    let acc = 0;\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(g.W * dpr));\n      canvas.height = Math.max(1, Math.round(g.H * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const resizeAll = () => {\n      if (!tokens) return;\n      const rect = chartWrap.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      g = computeGeo(rect.width, rect.height);\n      fitCanvas();\n      sized = true;\n    };\n\n    const loop = (nowMs: number) => {\n      if (disposed) return;\n      if (!visible) {\n        raf = 0; // IntersectionObserver re-arms this on re-entering view\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n      if (lastMs === 0) lastMs = nowMs;\n      acc += Math.min(200, nowMs - lastMs);\n      lastMs = nowMs;\n      let ticks = 0;\n      while (acc >= SIM_STEP_MS && ticks < MAX_CATCHUP_TICKS) {\n        acc -= SIM_STEP_MS;\n        tick(sim, g, rand);\n        ticks += 1;\n      }\n      render(ctx, g, tokens, sim);\n    };\n\n    const buildReducedFrame = () => {\n      if (!tokens) return;\n      sim = buildReducedSim(g, mulberry32(0xc0ffee));\n      render(ctx, g, tokens, sim);\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        buildReducedFrame();\n        return; // no rAF loop, no timers — a single deterministic frame\n      }\n      lastMs = 0;\n      acc = 0;\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(start);\n        return;\n      }\n      resizeAll();\n      kick();\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resizeAll();\n      if (reduced) buildReducedFrame();\n      kick();\n    });\n    ro.observe(chartWrap);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      if (tokens) {\n        resizeAll();\n        if (reduced) buildReducedFrame();\n        kick();\n      }\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && tokens && !raf) {\n        tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden\n        resizeAll();\n        lastMs = 0;\n        acc = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(chartWrap);\n\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n    };\n  }, []);\n\n  return (\n    <div className={`relative w-full max-w-sm overflow-hidden rounded-md border border-border bg-surface p-4 ${className}`}>\n      <div className=\"mb-3 flex items-center justify-between gap-2\">\n        <p className=\"truncate font-mono text-[11px] tracking-widest text-ns-muted\">{label}</p>\n        <p className=\"shrink-0 font-mono text-[10px] tracking-widest text-ns-muted\">FRAZIL</p>\n      </div>\n      <div ref={chartWrapRef} className=\"relative w-full\" style={{ aspectRatio: \"3 / 4\" }}>\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/frazil-dam.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "card",
      "canvas",
      "progress",
      "processing",
      "loader",
      "ice",
      "curl-noise",
      "physics",
      "ambient",
      "status"
    ],
    "instruction": "A card-scale single-canvas component modelling frazil-ice accumulation: fine crystals nucleate in turbulent water, are advected left-to-right across a channel, and pile against a fixed rack near the right edge until the pile's crest crosses a threshold, at which point a coherent chunk calves off and is carried downstream, and accumulation resumes from a smaller residual base. All geometry derives from the chart wrapper's getBoundingClientRect via computeGeo(W, H): a channel band spanning 14%-86% of the container height, a rack at x = 0.78*W drawn full-height, and a dam wedge that tapers upstream (left) from the rack. The whole simulation — crystal spawn, the curl-noise velocity field, dam-crest growth, and the release/chunk animation — runs inside one fixed 20Hz tick accumulator (never per rAF frame), so the field that advects crystals is genuinely re-sampled at a fixed rate decoupled from display refresh: crystals spawn at the left edge at 8/s, each carrying a constant rightward base velocity (channel length / 5s, keeping single-crystal transit inside the spec's 2-8s real-time range) plus a divergence-free curl of a 2-octave value-noise potential field (curlVel = perpendicular gradient of potential(x,y,t), the same construction used by background-ascii-flow), which is what makes the turbulence look genuinely mixed rather than a laminar drift. A crystal lodges — is removed from the moving set — the instant its x reaches the rack itself, or reaches the dam's current leading (upstream) face and its y falls inside the dam's height profile at that x (damHeightAt: an eased power-1.4 ramp from 0 at the pile's upstream edge to the current crest height at the rack, so the pile reads as a wedge nosing upstream, not a vertical wall). The crest height itself runs on its own clock, independent of exactly which crystal lodges when: an accumulation phase eases the crest from a residual base up to THRESHOLD = 0.7 (70% of the channel's cross-section) over a per-cycle duration randomised 15-20s (compressed from the real minutes-to-hours river-ice timescale, matching the spec's illustrative-compression note); crossing threshold immediately starts a release phase in which the crest eases back down over 420ms to a fresh residual (55-65% of the prior crest, randomised per release, i.e. 35-45% of the mass is what calved) while a chunk sprite — three overlapping circles for an irregular but coherent silhouette — spawns at the crest's peak position and eases (ease-out, quick break then swept) across and off the right edge over the full 1200ms release window, after which the accumulation phase restarts from the new residual; the dam never returns to zero. Colour: the channel is filled once per frame with --ns-muted at low alpha (over whatever the card's own background is, this alone reads as near-black turbulent water in dark theme and pale-grey water in light theme with zero theme branching), the dam is a linear gradient from --foreground at its crest to --ns-muted at its base, crystals and the release chunk are --foreground circles, and the rack — drawn as a vertical line plus six perpendicular grate ticks — is stroked at full --foreground alpha (genuine structural contrast, never --border's ~1.1:1 separator contrast). Colour tokens are read once via getComputedStyle(document.documentElement) for --foreground and --ns-muted with no literal fallback of any kind; if either is empty (stylesheet not yet applied) the mount loop retries on the next rAF and paints nothing until both resolve. A MutationObserver on document.documentElement's class attribute re-reads tokens on every theme flip and forces a resize/redraw; a ResizeObserver on the chart wrapper recomputes geometry and resizes the canvas backing store (capped devicePixelRatio 2); an IntersectionObserver stops driving new ticks while off-screen and, on re-entering view, re-reads tokens and restarts the fixed-step accumulator from the current wall clock (the sim state itself — crest, residual, crystals — is not reset, only the accumulator's real-time reference, so a long time off-screen does not fast-forward through dozens of missed release cycles: MAX_CATCHUP_TICKS caps how many 20Hz ticks a single frame can catch up). Under prefers-reduced-motion the component runs zero rAF loops and zero timers: it deterministically builds one frozen frame with the accumulation clock parked at 55% of the way toward THRESHOLD from empty (crest ≈ 0.385 of the channel, mid-pile, no release in progress) and eight crystals scattered across the channel's transit region so the still frame shows both the building dam and crystals visibly in flight, not an empty channel. Cleanup on unmount cancels both the animation and token-wait rAF handles and disconnects all three observers. Zero dependencies, DOM+canvas only, no colour literal anywhere including fallbacks."
  },
  "type": "registry:ui"
}