{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "melt-pond-drain",
  "title": "Melt Pond Drain",
  "description": "A card-scale ambient loader/empty-state panel modelling Arctic melt-pond drainage: a shallow top-view basin whose pool fills over ~9-11s, then drains rapidly through a crack that opens at a new rim location each cycle, leaving a shallow residual pool that refills and repeats.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/melt-pond-drain/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// MeltPondDrain — an ambient loader/empty-state panel modelling Arctic\n// melt-pond drainage (Polashenski et al.): a shallow top-view basin whose\n// pool slowly fills over a melt season, then drains rapidly once meltwater\n// finds a crack through to the ocean below, leaving a shallow residual pool\n// that refills and drains again through a DIFFERENT crack later. Unbound and\n// self-driven — no `value` prop, no steady-state reading — the climax is a\n// periodic, self-triggered fill/drain CYCLE, distinct from the data-bound\n// waterline gauges (gauge-capacity-waterline, meter-quota-meniscus) and from\n// the fracture-family siblings (craze-rule, compare-crack-seam), where the\n// crack is the subject rather than incidental drainage infrastructure.\n//\n// Fill: ~9-11s (randomized per cycle) from a shallow residual level up to a\n// near-full threshold. Drain: a fixed 1.2s event with three visible stages —\n// a funnel dimple forms at a newly chosen rim location (0-30%), the water\n// level falls rapidly toward it while the dimple holds (30-85%), then the\n// dimple recedes as the floor settles at a residual level 80-90% below the\n// pre-drain level, never to zero (85-100%). The drain location is a new\n// angle around the basin rim each cycle, chosen at least 100deg away from\n// the immediately-prior crack so it reads as migrating drainage, not a\n// mechanical valve firing in the same spot.\n//\n// Seeded mid-drain at mount (t0), so the pond is already visibly non-empty,\n// non-full, and the fill/drain distinction, plus at least one full\n// drain-to-refill cycle boundary, are guaranteed inside the first ~1.2s —\n// well inside the 2.5s/5s resting-loop checks — rather than depending on\n// where a randomized multi-second fill happened to start.\n//\n// All ink is read from --foreground/--ns-muted via getComputedStyle on\n// documentElement (never a literal), re-read on a MutationObserver watching\n// its class attribute. Water alpha is chosen from the theme's own read\n// foreground luminance rather than a hardcoded per-theme branch, so a\n// third theme with tokens in between still gets a legible value step.\n// ---------------------------------------------------------------------------\n\nconst FILL_MS_MIN = 9000;\nconst FILL_MS_MAX = 11000;\nconst DRAIN_MS = 1200;\nconst DIMPLE_END = 0.3; // fraction of DRAIN_MS: dimple fully formed\nconst FALL_END = 0.85; // fraction of DRAIN_MS: level finishes falling\nconst FULL_LEVEL = 0.93; // fill threshold that triggers the next drain\nconst DROP_MIN = 0.8; // 80-90% of the pre-drain level is lost each event\nconst DROP_MAX = 0.9;\nconst RESEED_MIN_DEG = 100; // next crack is at least this far from the last\nconst RESEED_SPAN_DEG = 160;\nconst N_POINTS = 64;\nconst DIMPLE_HALF_WIDTH = 0.62; // radians, ~35deg\nconst DIMPLE_DEPTH = 0.24; // fraction of the water radius the dimple bites in\n\nfunction clamp01(n: number): number {\n  return Math.min(1, Math.max(0, n));\n}\n\nfunction smoothstep(a: number, b: number, x: number): number {\n  if (a === b) return x < a ? 0 : 1;\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n}\n\nfunction easeInOutCubic(t: number): number {\n  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;\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 parseHex(raw: string): [number, number, number] | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nfunction relLuminance([r, g, b]: [number, number, number]): number {\n  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n}\n\n// smallest signed angular distance from a to b, in (-PI, PI]\nfunction angleDelta(a: number, b: number): number {\n  let d = (b - a) % (Math.PI * 2);\n  if (d > Math.PI) d -= Math.PI * 2;\n  if (d < -Math.PI) d += Math.PI * 2;\n  return d;\n}\n\ninterface DrainState {\n  phase: \"fill\" | \"drain\";\n  level: number; // current fill level, 0..1\n  fillElapsed: number;\n  fillDuration: number;\n  drainElapsed: number;\n  levelAtDrainStart: number;\n  dropFraction: number;\n  thetaDrain: number;\n  prevTheta: number;\n}\n\nfunction pickNextTheta(prev: number, rand: () => number): number {\n  const offsetDeg = RESEED_MIN_DEG + rand() * RESEED_SPAN_DEG;\n  const offset = (offsetDeg * Math.PI) / 180;\n  let theta = prev + offset;\n  theta = ((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);\n  return theta;\n}\n\nfunction coverageFor(level: number): number {\n  return 0.16 + 0.76 * Math.sqrt(clamp01(level));\n}\n\n/** dimple amount (0..1) and the level for a given point inside a drain\n * event of duration DRAIN_MS, given elapsed ms into it. */\nfunction drainFrame(\n  elapsed: number,\n  levelAtStart: number,\n  dropFraction: number\n): { dimple: number; level: number } {\n  const u = clamp01(elapsed / DRAIN_MS);\n  const residual = levelAtStart * (1 - dropFraction);\n  if (u < DIMPLE_END) {\n    return { dimple: smoothstep(0, DIMPLE_END, u), level: levelAtStart };\n  }\n  if (u < FALL_END) {\n    const t = easeInOutCubic((u - DIMPLE_END) / (FALL_END - DIMPLE_END));\n    return { dimple: 1, level: levelAtStart + (residual - levelAtStart) * t };\n  }\n  const recede = smoothstep(FALL_END, 1, u);\n  return { dimple: 1 - recede, level: residual };\n}\n\nexport interface MeltPondDrainProps {\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function MeltPondDrain({ className = \"\" }: MeltPondDrainProps) {\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const rand = mulberry32(0xd4a17e ^ Date.now());\n    const reducedMq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let dpr = 1;\n    let w = 0;\n    let h = 0;\n    let sized = false;\n\n    let fgRgb: [number, number, number] = [23, 23, 23];\n    let mutedHex = \"#4d4d4d\";\n    let waterAlphaBase = 0.55;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const fgHex = cs.getPropertyValue(\"--foreground\").trim();\n      fgRgb = parseHex(fgHex) ?? fgRgb;\n      mutedHex = cs.getPropertyValue(\"--ns-muted\").trim() || mutedHex;\n      const lum = relLuminance(fgRgb);\n      // dark theme: foreground reads pale/bright -> water sits near its\n      // bright end, high alpha (\"pale reflective\"). Light theme: foreground\n      // reads dark -> water stays a moderate, subtle value step, leaning on\n      // the specular/edge strokes below for definition rather than a flat\n      // dark fill fighting the copy ink.\n      waterAlphaBase = 0.42 + lum * 0.42;\n    };\n    readTokens();\n\n    const fg = (a: number) => `rgba(${fgRgb[0]},${fgRgb[1]},${fgRgb[2]},${a})`;\n    const muted = (a: number) => {\n      const rgb = parseHex(mutedHex) ?? [128, 128, 128];\n      return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;\n    };\n\n    // Seeded mid-drain: the pond is already draining at mount, guaranteeing\n    // a visibly different level by 2.5s and a completed drain-to-refill\n    // cycle boundary well inside the 5s check, regardless of RNG.\n    const seedTheta = rand() * Math.PI * 2;\n    const state: DrainState = {\n      phase: \"drain\",\n      level: 0.8 + rand() * 0.12,\n      fillElapsed: 0,\n      fillDuration: FILL_MS_MIN + rand() * (FILL_MS_MAX - FILL_MS_MIN),\n      drainElapsed: rand() * DRAIN_MS * 0.6,\n      levelAtDrainStart: 0.8 + rand() * 0.12,\n      dropFraction: DROP_MIN + rand() * (DROP_MAX - DROP_MIN),\n      thetaDrain: seedTheta,\n      prevTheta: seedTheta,\n    };\n    state.levelAtDrainStart = state.level;\n\n    const advance = (dtMs: number) => {\n      if (state.phase === \"drain\") {\n        state.drainElapsed += dtMs;\n        const frame = drainFrame(state.drainElapsed, state.levelAtDrainStart, state.dropFraction);\n        state.level = frame.level;\n        if (state.drainElapsed >= DRAIN_MS) {\n          state.level = state.levelAtDrainStart * (1 - state.dropFraction);\n          state.phase = \"fill\";\n          state.fillElapsed = 0;\n          state.fillDuration = FILL_MS_MIN + rand() * (FILL_MS_MAX - FILL_MS_MIN);\n          state.prevTheta = state.thetaDrain;\n        }\n      } else {\n        state.fillElapsed += dtMs;\n        const t = easeInOutCubic(clamp01(state.fillElapsed / state.fillDuration));\n        const residual = state.levelAtDrainStart * (1 - state.dropFraction);\n        state.level = residual + (FULL_LEVEL - residual) * t;\n        if (state.fillElapsed >= state.fillDuration) {\n          state.phase = \"drain\";\n          state.drainElapsed = 0;\n          state.levelAtDrainStart = FULL_LEVEL;\n          state.thetaDrain = pickNextTheta(state.prevTheta, rand);\n        }\n      }\n    };\n\n    const currentDimple = (): number => {\n      if (state.phase !== \"drain\") return 0;\n      return drainFrame(state.drainElapsed, state.levelAtDrainStart, state.dropFraction).dimple;\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, w, h);\n\n      const cx = w / 2;\n      const cy = h / 2;\n      const smaller = Math.min(w, h);\n      const rx = smaller * 0.46;\n      const ry = rx * 0.6;\n\n      // basin depression: floor fill + rim hairline, --ns-muted only\n      ctx.beginPath();\n      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);\n      ctx.fillStyle = muted(0.16);\n      ctx.fill();\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = muted(0.38);\n      ctx.stroke();\n\n      const dimple = currentDimple();\n      const theta = state.thetaDrain;\n      const coverage = coverageFor(state.level);\n\n      const pts: [number, number][] = [];\n      for (let i = 0; i < N_POINTS; i++) {\n        const angle = (i / N_POINTS) * Math.PI * 2;\n        const dist = Math.abs(angleDelta(angle, theta));\n        const bump = smoothstep(DIMPLE_HALF_WIDTH, 0, dist);\n        const indent = dimple * bump * DIMPLE_DEPTH;\n        const r = coverage * (1 - indent);\n        pts.push([cx + Math.cos(angle) * rx * r, cy + Math.sin(angle) * ry * r]);\n      }\n\n      // water surface, smoothed closed curve through the sampled points\n      ctx.beginPath();\n      const first = pts[0]!;\n      const lastPt = pts[N_POINTS - 1]!;\n      ctx.moveTo((first[0] + lastPt[0]) / 2, (first[1] + lastPt[1]) / 2);\n      for (let i = 0; i < N_POINTS; i++) {\n        const p = pts[i]!;\n        const next = pts[(i + 1) % N_POINTS]!;\n        const midX = (p[0] + next[0]) / 2;\n        const midY = (p[1] + next[1]) / 2;\n        ctx.quadraticCurveTo(p[0], p[1], midX, midY);\n      }\n      ctx.closePath();\n      ctx.fillStyle = fg(waterAlphaBase);\n      ctx.fill();\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = fg(Math.min(0.95, waterAlphaBase + 0.22));\n      ctx.stroke();\n\n      // specular highlight — a soft arc toward the upper-left of the\n      // surface, the value-step cue the light-theme spec calls for\n      ctx.beginPath();\n      ctx.ellipse(\n        cx - rx * coverage * 0.32,\n        cy - ry * coverage * 0.4,\n        rx * coverage * 0.34,\n        ry * coverage * 0.18,\n        -0.4,\n        0,\n        Math.PI * 2\n      );\n      ctx.fillStyle = fg(Math.min(0.95, waterAlphaBase + 0.3));\n      ctx.globalAlpha = 0.35;\n      ctx.fill();\n      ctx.globalAlpha = 1;\n\n      // drain crack — a small zigzag from the water edge out to the rim,\n      // visible only while a dimple is forming/holding/receding\n      if (dimple > 0.02) {\n        const edgeR = coverage * (1 - dimple * DIMPLE_DEPTH);\n        const ex = cx + Math.cos(theta) * rx * edgeR;\n        const ey = cy + Math.sin(theta) * ry * edgeR;\n        const rimX = cx + Math.cos(theta) * rx * 0.97;\n        const rimY = cy + Math.sin(theta) * ry * 0.97;\n        const midX1 = ex + (rimX - ex) * 0.35 + Math.cos(theta + Math.PI / 2) * 4;\n        const midY1 = ey + (rimY - ey) * 0.35 + Math.sin(theta + Math.PI / 2) * 4;\n        const midX2 = ex + (rimX - ex) * 0.7 - Math.cos(theta + Math.PI / 2) * 4;\n        const midY2 = ey + (rimY - ey) * 0.7 - Math.sin(theta + Math.PI / 2) * 4;\n        ctx.beginPath();\n        ctx.moveTo(ex, ey);\n        ctx.lineTo(midX1, midY1);\n        ctx.lineTo(midX2, midY2);\n        ctx.lineTo(rimX, rimY);\n        ctx.strokeStyle = muted(0.5 * dimple + 0.15);\n        ctx.lineWidth = 1.25;\n        ctx.stroke();\n      }\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      if (w < 2 || h < 2) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      sized = true;\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!visible || !sized) return;\n      const dt = last ? Math.min(200, now - last) : 1000 / 60;\n      last = now;\n      advance(dt);\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const drawReducedFrame = () => {\n      // deliberately NOT t0's live seed: a fixed, hand-chosen mid-drain\n      // instant — dimple fully formed, level partway through its fall,\n      // floor partly exposed — the single most structured frame.\n      const frozen: DrainState = {\n        ...state,\n        phase: \"drain\",\n        levelAtDrainStart: 0.88,\n        dropFraction: 0.85,\n        drainElapsed: DRAIN_MS * 0.55,\n        thetaDrain: seedTheta,\n      };\n      const frame = drainFrame(frozen.drainElapsed, frozen.levelAtDrainStart, frozen.dropFraction);\n      state.level = frame.level;\n      state.phase = \"drain\";\n      state.drainElapsed = frozen.drainElapsed;\n      state.levelAtDrainStart = frozen.levelAtDrainStart;\n      state.dropFraction = frozen.dropFraction;\n      state.thetaDrain = frozen.thetaDrain;\n      draw();\n    };\n\n    const start = () => {\n      if (reducedMq.matches) {\n        drawReducedFrame();\n        return;\n      }\n      last = 0;\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (reducedMq.matches) drawReducedFrame();\n    });\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !reducedMq.matches) start();\n      else stop();\n    });\n    io.observe(canvas);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reducedMq.matches) drawReducedFrame();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const onReducedChange = () => {\n      if (reducedMq.matches) {\n        stop();\n        drawReducedFrame();\n      } else {\n        start();\n      }\n    };\n    reducedMq.addEventListener(\"change\", onReducedChange);\n\n    const onVis = () => {\n      if (document.hidden) stop();\n      else if (visible && !reducedMq.matches) start();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    resize();\n    if (sized) {\n      if (reducedMq.matches) drawReducedFrame();\n      else start();\n    }\n\n    return () => {\n      disposed = true;\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      reducedMq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      void disposed;\n    };\n  }, []);\n\n  return (\n    <div role=\"img\" aria-label=\"Loading\" className={`relative h-full w-full ${className}`}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block h-full w-full\" />\n    </div>\n  );\n}\n\nMeltPondDrain.displayName = \"MeltPondDrain\";\n",
      "type": "registry:ui",
      "target": "components/ui/melt-pond-drain.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "loader",
      "empty-state",
      "canvas",
      "ambient",
      "water",
      "glaciology",
      "cycle"
    ],
    "instruction": "A card-scale (`w-full h-full`) canvas-2D ambient panel with zero props to bind and zero pointer interaction — the mechanic is melt/gravity driven, never touch driven. Internally it runs a small state machine with two phases, `fill` and `drain`, over a single scalar `level` (0-1, the fraction of the basin's near-full threshold FULL_LEVEL=0.93 currently reached). `fill` interpolates level from a post-drain residual up to FULL_LEVEL over a randomized 9-11s duration (eased in/out cubic) before flipping to `drain`. `drain` is a fixed 1200ms event with three stages driven off elapsed-fraction u: 0-30% grows a `dimple` scalar 0->1 via smoothstep while level holds (the funnel forming); 30-85% holds dimple at 1 while level eases from its pre-drain value down to a residual = preDrainLevel * (1 - dropFraction), dropFraction randomized 0.8-0.9 each event (never to zero — a shallow residual pool always remains); 85-100% eases dimple back to 0 while level holds at the residual (the floor settling). On completing a drain, the NEXT drain's rim location `thetaDrain` (an angle around the basin ellipse) is chosen as the previous angle plus a random 100-260deg offset, so it always lands at a materially different spot, never the same rim point twice running. The basin is an ellipse (rx = 0.46x the container's SMALLER dimension, ry = 0.6x that) drawn as a flat --ns-muted fill at ~16% alpha plus a ~38% alpha rim hairline, both real geometry so it reads as a depression regardless of theme. The water surface is 64 points sampled evenly around the ellipse, each point's radius = a shared `coverage` factor (0.16 + 0.76*sqrt(level), so the residual pool is visibly smaller than a near-full one and the basin rim is never fully consumed) minus a local indent near thetaDrain (smoothstep falloff over a ~35deg half-width, scaled by `dimple`, max ~24% of the water radius) — the two effects compose into one physical read: the whole pond visibly shrinks (level) while a funnel dimple bites in locally at the drain point (dimple), exactly the departure/arrival sequence the round-9 cadence rule requires rather than an instant level-snap. Those points are stitched into a smoothed closed quadratic-curve path and filled/stroked in the --foreground token; the fill alpha is not a hardcoded per-theme branch but computed once from --foreground's own read relative luminance (0.42 + luminance*0.42), so a bright (dark-theme) foreground lands near its pale, reflective end and a dark (light-theme) foreground still reads as a distinct, subtler value step rather than flat pale-on-pale, backed by a soft specular highlight ellipse and a slightly-higher-alpha edge stroke for definition in the harder light-theme case. While `dimple` is above ~0.02, a small three-segment zigzag crack is drawn in --ns-muted from the water's edge at thetaDrain out to the basin rim, alpha scaled by dimple, so the crack itself only exists for the ~800ms it is mechanically relevant. At mount the whole state machine is seeded already mid-drain (levelAtDrainStart 0.8-0.92, drainElapsed a random 0-60% into the 1200ms event) rather than at a random point in a 9-11s fill — this guarantees the level is visibly different by 2.5s and that at least one full drain-to-refill cycle boundary has been crossed well inside the 5s resting-loop check, deterministically, regardless of the RNG seed. Colours are read once via getComputedStyle(document.documentElement) for --foreground/--ns-muted and re-read on a MutationObserver watching its class attribute (no paint happens before that first read); --ns-accent and --border never touch the water surface or the crack. Canvas backing store is dpr-clamped to 2 and resized via ResizeObserver on the canvas itself (not window resize, since this is a card-scale element that can resize independently of the viewport); a paired IntersectionObserver pauses the rAF loop when the card scrolls off-screen and resumes it on return, alongside a visibilitychange listener for background tabs. `prefers-reduced-motion` freezes on a deliberately fixed mid-drain instant (dimple fully formed at 1, drain 55% elapsed so level is partway through its fall, floor partly exposed) rather than the live t0 seed or a flat full/empty pond — the single most structured frame, showing basin, water level, AND the drain mechanism at once. `autoplay: none` because the cycle runs on its own internal clock with no pointer/scroll/press input for a synthetic-input driver to act on. The root carries `role=\"img\" aria-label=\"Loading\"` since this occupies a loader/empty-state slot with no discrete states to announce; the canvas itself is `aria-hidden`. Zero dependencies, canvas-2D only, no WebGL, no SVG."
  },
  "type": "registry:ui"
}