{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "steam-trap-batch-flush",
  "title": "Steam Trap Batch Flush",
  "description": "An inline sync-status glyph modelled on a float-and-thermostatic steam trap: condensate (pending events) fills a small chamber, a float trips a discharge valve at 88% height, the chamber blows down fast on an eased-out curve, and refill starts immediately, forever.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/steam-trap-batch-flush/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SteamTrapBatchFlush — an inline sync-status glyph modelled on a\n// float-and-thermostatic steam trap: condensate (locally-buffered pending\n// events) collects in a small chamber until a float trips a discharge valve\n// open, the chamber blows down fast, and it starts refilling immediately.\n// The read is entirely motion + fill level, no colour swap, no icon change.\n//\n// Real numbers from the spec: fill takes 3.2s to go from empty (0%) to the\n// trip line (88% of chamber height) — that sets a constant fill rate of\n// 0.88/3.2 = 0.275 fraction/s, applied continuously whenever the chamber is\n// filling, including the post-blowdown refill that resumes from 6% rather\n// than 0%. Blowdown itself is 340ms, fraction falling 88% -> 6% on an\n// eased-out curve (fast, front-loaded — the real mechanical blowdown is far\n// quicker than the fill it releases). The float rides the top of the fill\n// and overshoots 2 (viewBox) units below the falling fill line during\n// blowdown before settling back onto the new level over a short damped\n// window — the mechanical snap-back read that separates this from a plain\n// sawtooth reset.\n//\n// Because refill resumes from 6% (not 0%) at the same constant rate, the\n// steady-state loop is genuinely periodic: fill 6% -> 88% takes\n// (0.88-0.06)/0.275 ~= 2.98s, plus the 340ms blowdown, for a full cycle of\n// ~3.32s — close to the spec's \"~3.6s\", the difference being that a\n// constant, spec-derived fill rate is preserved exactly rather than padding\n// the cycle to hit the round number.\n// ---------------------------------------------------------------------------\n\nconst TRIP_FRACTION = 0.88;\nconst LOW_FRACTION = 0.06;\nconst FILL_DURATION_MS = 3200; // empty (0) -> trip (0.88), sets the rate\nconst FILL_RATE = TRIP_FRACTION / (FILL_DURATION_MS / 1000); // fraction / s\nconst BLOWDOWN_MS = 340;\nconst OVERSHOOT_UNITS = 2; // viewBox units the float dips past the settled line\nconst OVERSHOOT_SETTLE_MS = 180;\n\n// chamber geometry, 24x24 viewBox\nconst CHAMBER = { x: 5, y: 3, w: 14, h: 18, rx: 2 };\nconst FILL_X = 6;\nconst FILL_W = 12;\nconst FILL_BOTTOM = 20; // chamber inner floor\nconst FILL_TOP_MAX = 4; // chamber inner ceiling (fraction = 1)\nconst FILL_SPAN = FILL_BOTTOM - FILL_TOP_MAX; // 16 units of usable height\nconst FLOAT_CX = FILL_X + FILL_W / 2;\nconst FLOAT_R = 1.4;\nconst STROKE_WIDTH = 1.25; // physical px via non-scaling-stroke, floors > 1px\n\nfunction fractionToY(fraction: number): number {\n  return FILL_BOTTOM - fraction * FILL_SPAN;\n}\n\nfunction easeOutCubic(t: number): number {\n  const u = 1 - t;\n  return 1 - u * u * u;\n}\n\nfunction clamp01(t: number): number {\n  return t < 0 ? 0 : t > 1 ? 1 : t;\n}\n\nexport interface SteamTrapBatchFlushProps {\n  /** glyph size in px, meant to sit inline beside a text label. legible 24-64. */\n  size?: number;\n  /** accessible label for the static role=\"img\" description */\n  label?: string;\n  /**\n   * renders as a focusable button that forces an early blowdown on press —\n   * omit for the pure ambient/status read (default). The forced blowdown\n   * reuses the exact same 340ms eased-out curve and never changes the trip\n   * threshold; it just starts blowdown from whatever fraction the chamber\n   * is currently at instead of waiting for the 88% trip.\n   */\n  interactive?: boolean;\n  /** called once a forced or natural blowdown starts, only when interactive */\n  onFlush?: () => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SteamTrapBatchFlush({\n  size = 24,\n  label = \"Syncing\",\n  interactive = false,\n  onFlush,\n  className = \"\",\n}: SteamTrapBatchFlushProps) {\n  const clipId = useId();\n  const fillRef = useRef<SVGRectElement>(null);\n  const floatRef = useRef<SVGCircleElement>(null);\n  const rootRef = useRef<HTMLSpanElement | HTMLButtonElement>(null);\n  const forceFlushRef = useRef<() => void>(() => {});\n\n  useEffect(() => {\n    const fillEl = fillRef.current;\n    const floatEl = floatRef.current;\n    const root = rootRef.current;\n    if (!fillEl || !floatEl || !root) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const applyFrame = (fraction: number, overshoot: number) => {\n      const y = fractionToY(fraction);\n      fillEl.setAttribute(\"y\", y.toFixed(2));\n      fillEl.setAttribute(\"height\", Math.max(0, FILL_BOTTOM - y).toFixed(2));\n      floatEl.setAttribute(\"cy\", (y + overshoot).toFixed(2));\n    };\n\n    if (reduced) {\n      // TRIP_POINT freeze frame: fill at 88%, float at its highest point,\n      // the instant before blowdown — the most information-dense frame,\n      // showing the full chamber, the trip threshold and the float at once.\n      applyFrame(TRIP_FRACTION, 0);\n      return;\n    }\n\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n\n    type Phase = \"filling\" | \"blowdown\";\n    let phase: Phase = \"filling\";\n    let fraction = LOW_FRACTION + Math.random() * (TRIP_FRACTION - LOW_FRACTION);\n    let blowdownStart = 0;\n    let blowdownFrom = TRIP_FRACTION;\n    let settleStart = -Infinity;\n    let settleFrom = 0;\n    let last = 0;\n\n    const startBlowdown = (now: number, from: number) => {\n      phase = \"blowdown\";\n      blowdownStart = now;\n      blowdownFrom = from;\n      onFlush?.();\n    };\n\n    forceFlushRef.current = () => {\n      if (phase !== \"filling\") return;\n      startBlowdown(performance.now(), fraction);\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!visible) return;\n      if (last === 0) last = now;\n      const dt = Math.min(100, now - last) / 1000;\n      last = now;\n\n      let overshoot = 0;\n\n      if (phase === \"filling\") {\n        fraction = Math.min(TRIP_FRACTION, fraction + FILL_RATE * dt);\n        if (fraction >= TRIP_FRACTION) startBlowdown(now, TRIP_FRACTION);\n      } else {\n        const t = clamp01((now - blowdownStart) / BLOWDOWN_MS);\n        const eased = easeOutCubic(t);\n        fraction = blowdownFrom + (LOW_FRACTION - blowdownFrom) * eased;\n        overshoot = OVERSHOOT_UNITS * eased;\n        if (t >= 1) {\n          phase = \"filling\";\n          settleStart = now;\n          settleFrom = OVERSHOOT_UNITS;\n        }\n      }\n\n      if (now - settleStart < OVERSHOOT_SETTLE_MS) {\n        const st = clamp01((now - settleStart) / OVERSHOOT_SETTLE_MS);\n        overshoot = settleFrom * (1 - easeOutCubic(st));\n      }\n\n      applyFrame(fraction, overshoot);\n      raf = requestAnimationFrame(tick);\n    };\n\n    raf = requestAnimationFrame(tick);\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !raf) {\n        last = 0;\n        raf = requestAnimationFrame(tick);\n      }\n    });\n    io.observe(root);\n\n    return () => {\n      disposed = true;\n      void disposed;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      io.disconnect();\n      forceFlushRef.current = () => {};\n    };\n  }, [onFlush]);\n\n  const glyph = (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      aria-hidden=\"true\"\n      focusable=\"false\"\n    >\n      <defs>\n        <clipPath id={clipId}>\n          <rect\n            x={CHAMBER.x}\n            y={CHAMBER.y}\n            width={CHAMBER.w}\n            height={CHAMBER.h}\n            rx={CHAMBER.rx}\n          />\n        </clipPath>\n      </defs>\n\n      <g clipPath={`url(#${clipId})`}>\n        <rect\n          ref={fillRef}\n          x={FILL_X}\n          y={FILL_BOTTOM}\n          width={FILL_W}\n          height={0}\n          style={{ fill: \"var(--foreground)\", opacity: 0.7 }}\n        />\n      </g>\n\n      <rect\n        x={CHAMBER.x}\n        y={CHAMBER.y}\n        width={CHAMBER.w}\n        height={CHAMBER.h}\n        rx={CHAMBER.rx}\n        fill=\"none\"\n        stroke=\"var(--ns-muted)\"\n        strokeWidth={STROKE_WIDTH}\n        vectorEffect=\"non-scaling-stroke\"\n      />\n\n      <circle\n        ref={floatRef}\n        cx={FLOAT_CX}\n        cy={fractionToY(TRIP_FRACTION)}\n        r={FLOAT_R}\n        style={{ fill: \"var(--foreground)\" }}\n      />\n    </svg>\n  );\n\n  if (interactive) {\n    return (\n      <button\n        ref={rootRef as React.RefObject<HTMLButtonElement>}\n        type=\"button\"\n        onClick={() => forceFlushRef.current()}\n        aria-label={label}\n        className={`inline-flex shrink-0 items-center justify-center rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${className}`}\n        style={{ width: size, height: size }}\n      >\n        {glyph}\n      </button>\n    );\n  }\n\n  return (\n    <span\n      ref={rootRef as React.RefObject<HTMLSpanElement>}\n      role=\"img\"\n      aria-label={label}\n      className={`inline-flex shrink-0 items-center justify-center ${className}`}\n      style={{ width: size, height: size }}\n    >\n      {glyph}\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/steam-trap-batch-flush.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "status",
      "indicator",
      "sync",
      "svg",
      "motion",
      "steam-trap",
      "loading",
      "icon",
      "buffer"
    ],
    "instruction": "An inline sync-status glyph, 24-64px, meant to sit beside a text label the way a spinner or cloud-sync icon would. It renders a rounded-rect chamber outline (stroke var(--ns-muted), strokeWidth 1.25 with vector-effect=\"non-scaling-stroke\" so the outline never drops below roughly 1 physical px at any size, including the smallest supported 24px in light theme) and, clipped inside that same rounded rect via an SVG clipPath keyed with useId (safe for multiple instances on one page), a fill rect (var(--foreground) at 0.7 opacity) whose y/height are updated every animation frame via direct setAttribute calls on a ref rather than React state, avoiding a re-render per frame. A small circle (also var(--foreground)) rides the top edge of the fill as the float. The whole cycle runs on a requestAnimationFrame loop paused via IntersectionObserver while off-screen: fraction fills linearly at a constant rate of 0.275/s (derived directly from the spec's '3.2s from empty to the 88% trip line' — 0.88 / 3.2), and once fraction reaches the 0.88 trip point a 340ms blowdown begins, interpolating fraction from 0.88 down to 0.06 on an eased-out cubic curve (fast, front-loaded — the real mechanical blowdown is far quicker than the fill it releases). During that same 340ms the float is pushed an extra 0-2 viewBox units below the fill line (also eased-out), and for a further 180ms after blowdown ends that extra offset eases back to zero while the chamber has already resumed filling from 6% — the overshoot-then-settle read that makes the trip feel like a mechanical snap rather than a sawtooth reset. Because refill always resumes from 6% rather than 0% at the same constant rate, the steady-state loop is genuinely periodic at roughly 3.32s (fill 6%->88% takes ~2.98s, plus the 340ms blowdown), which the component treats as authoritative over the spec's rounded '~3.6s' figure since it preserves the spec's stated fill rate exactly rather than padding the cycle. Each mounted instance starts at a random fraction between 6% and 88% rather than always at empty, so multiple glyphs on one page (e.g. a document list) are visibly out of phase with each other from t0. Under prefers-reduced-motion no rAF loop starts at all; the component sets one static frame directly — fraction at 0.88 (the trip point) with zero float overshoot, chosen because it is the single most information-dense frame available: full chamber, the trip threshold, and the float all clearly legible at once, rather than an arbitrary mid-fill or empty frame. Default render is a non-interactive `role=\"img\" aria-label={label}` span (label defaults to \"Syncing\") since the fill-and-flush motion itself carries no discrete state worth announcing via aria-live. Passing `interactive` renders the same glyph inside a real `<button>` instead, with a focus-visible ring using var(--ns-accent) as the only accent-eligible pixel in the whole component; pressing it calls an internal forceFlush (exposed via a ref-held closure, not React state, to stay off the render path) that starts a blowdown immediately from whatever fraction the chamber currently holds — it reuses the identical 340ms eased-out curve and never changes the 0.88 trip threshold or the 340ms duration, only the moment blowdown starts; an optional `onFlush` prop fires once per blowdown start, forced or natural. Props: size (px, default 24, legible 24-64), label (accessible text, default \"Syncing\"), interactive (boolean, default false), onFlush (callback), className. Zero color literals — chamber outline, fill, float and focus ring all read tokens via CSS var(), and cleanup on unmount cancels the rAF and disconnects the IntersectionObserver. Pure DOM/SVG, no canvas, zero dependencies."
  },
  "type": "registry:ui"
}