{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "braze-capillary-fill",
  "title": "Braze Capillary Fill",
  "description": "A progress/validation-fill indicator rendered as torch brazing: molten filler is drawn sideways along a hairline joint gap by capillary action, brightest at the leading front, settling into a solidified track behind it, and pooling into a fillet the instant it reaches the far end before cooling, fading, and starting a fresh joint.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/braze-capillary-fill/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BrazeCapillaryFill — a progress/validation-fill indicator rendered as\n// torch brazing: molten filler wicked sideways along a hairline joint gap\n// by capillary action, not gravity or pressure.\n//\n// SOURCE: torch brazing (AWS Brazing Handbook). Filler at the joint mouth\n// is drawn INTO a closely-fitted gap by capillary action — flow runs toward\n// the hottest, narrowest part of the gap, not downhill — and a fillet only\n// forms once filler reaches the far end and pools under surface tension.\n// A well-fitted gap wicks fast and completely; a loose one just puddles.\n//\n// The front sweeps the gap once per loop, unforced, on its own internal\n// clock — this is a resting ambient loop, not a press-driven animation; an\n// optional `progress` prop can pin the front to real external state instead\n// (see props), but the demo loop below runs with zero input regardless.\n//\n// Monochrome: every value is fg alpha over the bg backdrop, never a\n// literal colour. \"Molten\" reads as the brightest (highest-alpha, freshest)\n// point on the track; solidified fill settles to a lower, steady alpha;\n// the unfilled gap ahead is un-painted (background) with only the border-\n// toned sheet edges either side of it.\n// ---------------------------------------------------------------------------\n\nconst FRONT_S = 6.3; // capillary front sweeps the full gap length, 0->1\nconst MENISCUS_S = 0.9; // fillet bulge grows once the front reaches the far end\nconst FILL_S = 9.4; // total \"fill\" era — front + meniscus + a quiet settle tail\nconst HOLD_S = 2.1; // full fillet holds\nconst FADE_S = 1.2; // fades back to an empty gap (a fresh joint, never a reverse wipe)\nconst LOOP_S = FILL_S + HOLD_S + FADE_S; // 12.7s\n\nconst FREEZE_PROGRESS = 0.62; // reduced-motion freeze: front mid-gap, wetted trail\n// behind it and unfilled gap ahead both visible in the same frame — neither the\n// empty t0 frame nor the full-fillet hold shows both halves of the mechanic at once.\nconst FREEZE_T = FREEZE_PROGRESS * FRONT_S;\n\nconst INSET_FRAC = 0.06; // seam starts/ends this fraction of width in from each edge\nconst SOLID_ALPHA = 0.68; // settled/solidified fill\nconst MOLTEN_ALPHA = 1; // brightest — the front itself and a fresh fillet\nconst GLOW_FRAC = 0.32; // fraction of the seam span the molten glow trails behind the front\nconst GLOW_WOBBLE_HZ = 0.08; // standing for local gap-width variance along the seam\nconst GLOW_WOBBLE_AMOUNT = 0.2;\n\nfunction frontProgress(t: number): number {\n  return Math.min(1, t / FRONT_S);\n}\n\nfunction meniscusProgress(t: number): number {\n  return Math.min(1, Math.max(0, (t - FRONT_S) / MENISCUS_S));\n}\n\nexport interface BrazeCapillaryFillProps {\n  /** Accessible label for the reading, e.g. \"Import progress\". */\n  label?: string;\n  /** Canvas panel height in px. Default 96. */\n  height?: number;\n  /**\n   * Pin the front to real external progress (0-1) instead of the ambient\n   * demo loop. When omitted, the component runs its own unforced fill/\n   * hold/fade cycle continuously — this is the default and what the\n   * catalog card shows.\n   */\n  progress?: number;\n  className?: string;\n}\n\nexport function BrazeCapillaryFill({\n  label = \"Fill progress\",\n  height = 96,\n  progress,\n  className = \"\",\n}: BrazeCapillaryFillProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const progressRef = useRef(progress);\n  progressRef.current = progress;\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 reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let fg = \"\";\n    let bg = \"\";\n    let border = \"\";\n\n    // fallbacks are CSS keywords, never literal colour values\n    const readTokens = () => {\n      const root = getComputedStyle(document.documentElement);\n      fg = root.getPropertyValue(\"--foreground\").trim() || \"currentColor\";\n      bg = root.getPropertyValue(\"--background\").trim() || \"Canvas\";\n      border = root.getPropertyValue(\"--border\").trim() || \"currentColor\";\n    };\n\n    let w = 0;\n    let h = 0;\n    let sized = false;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = rect.width;\n      h = rect.height;\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 draw = (t: number) => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, w, h);\n\n      const cy = h / 2;\n      const gapHalf = Math.max(2, Math.min(h, w) * 0.03);\n      const startX = w * INSET_FRAC;\n      const endX = w * (1 - INSET_FRAC);\n      const span = endX - startX;\n\n      // sheet edges either side of the joint — separator lines, --border only.\n      // Left edge sits flush at startX (the joint mouth, where the front must\n      // visibly begin) — only the exit side overhangs, giving the fillet\n      // meniscus room to bulge past endX.\n      ctx.strokeStyle = border;\n      ctx.lineWidth = 1;\n      ctx.globalAlpha = 1;\n      ctx.beginPath();\n      ctx.moveTo(startX, cy - gapHalf);\n      ctx.lineTo(endX + h * 0.3, cy - gapHalf);\n      ctx.moveTo(startX, cy + gapHalf);\n      ctx.lineTo(endX + h * 0.3, cy + gapHalf);\n      ctx.stroke();\n\n      // -- phase resolution ---------------------------------------------\n      const pinned = progressRef.current;\n      let fillP: number;\n      let meniscusP: number;\n      let cycleAlpha = 1; // global fade multiplier for the fade-out phase\n      let molten = MOLTEN_ALPHA;\n\n      if (typeof pinned === \"number\") {\n        fillP = Math.min(1, Math.max(0, pinned));\n        meniscusP = fillP >= 1 ? 1 : 0;\n      } else {\n        const cycleT = t % LOOP_S;\n        if (cycleT < FILL_S) {\n          fillP = frontProgress(cycleT);\n          meniscusP = meniscusProgress(cycleT);\n        } else if (cycleT < FILL_S + HOLD_S) {\n          fillP = 1;\n          meniscusP = 1;\n          // the fillet cools across the hold — molten brightness eases down\n          // to the settled alpha rather than sitting at peak the whole time\n          const holdT = (cycleT - FILL_S) / HOLD_S;\n          molten = MOLTEN_ALPHA - (MOLTEN_ALPHA - SOLID_ALPHA) * Math.min(1, holdT * 1.6);\n        } else {\n          fillP = 1;\n          meniscusP = 1;\n          const fadeT = (cycleT - FILL_S - HOLD_S) / FADE_S;\n          cycleAlpha = Math.max(0, 1 - fadeT);\n          molten = SOLID_ALPHA;\n        }\n      }\n\n      const frontX = startX + fillP * span;\n\n      // -- solidified track (behind the front) ---------------------------\n      if (fillP > 0) {\n        ctx.globalAlpha = SOLID_ALPHA * cycleAlpha;\n        ctx.fillStyle = fg;\n        ctx.fillRect(startX, cy - gapHalf, frontX - startX, gapHalf * 2);\n      }\n\n      // -- molten glow trailing the front ---------------------------------\n      if (fillP > 0 && fillP < 1) {\n        const wobble = 1 + GLOW_WOBBLE_AMOUNT * Math.sin(2 * Math.PI * GLOW_WOBBLE_HZ * t);\n        const glowLen = Math.max(4, GLOW_FRAC * span * wobble);\n        const glowStart = Math.max(startX, frontX - glowLen);\n        const grad = ctx.createLinearGradient(glowStart, 0, frontX, 0);\n        grad.addColorStop(0, hexWithAlpha(fg, SOLID_ALPHA * cycleAlpha));\n        grad.addColorStop(1, hexWithAlpha(fg, molten * cycleAlpha));\n        ctx.fillStyle = grad;\n        ctx.globalAlpha = 1;\n        ctx.fillRect(glowStart, cy - gapHalf, frontX - glowStart, gapHalf * 2);\n      }\n\n      // -- fillet meniscus at the exit, once the front has arrived --------\n      if (meniscusP > 0) {\n        const maxR = gapHalf * 2.6;\n        const r = maxR * easeOutBack(meniscusP);\n        ctx.globalAlpha = molten * cycleAlpha;\n        ctx.fillStyle = fg;\n        ctx.beginPath();\n        ctx.ellipse(endX, cy, r * 0.55, r, 0, 0, Math.PI * 2);\n        ctx.fill();\n      }\n\n      ctx.globalAlpha = 1;\n    };\n\n    // -- loop ------------------------------------------------------------\n    let raf = 0;\n    let last = 0;\n    let globalT = 0;\n\n    const loop = (now: number) => {\n      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;\n      last = now;\n      globalT += dtMs / 1000;\n      draw(globalT);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      draw(reduced ? FREEZE_T : globalT);\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        readTokens();\n        resize();\n        draw(reduced ? FREEZE_T : globalT);\n      }, 150);\n    };\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const visible = entries[0]?.isIntersecting;\n        if (visible && !reduced && sized) {\n          cancelAnimationFrame(raf);\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        } else if (!visible) {\n          cancelAnimationFrame(raf);\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      cancelAnimationFrame(raf);\n      if (!document.hidden && !reduced && sized) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // no paint before the first token read\n    readTokens();\n    resize();\n\n    if (reduced) {\n      draw(FREEZE_T);\n    } else {\n      draw(0);\n      raf = requestAnimationFrame(loop);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [height]);\n\n  return (\n    <div className={className}>\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <span className=\"font-mono text-[11px] tracking-wide text-ns-muted\">\n          {label.toUpperCase()}\n        </span>\n        <span className=\"font-mono text-[11px] tracking-wide text-ns-muted\">CAPILLARY FILL</span>\n      </div>\n      <div\n        role=\"img\"\n        aria-label={`${label}: molten filler wicking along a joint gap, filling it and forming a fillet at the far end`}\n        className=\"mt-2\"\n      >\n        <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block w-full\" style={{ height }} />\n      </div>\n    </div>\n  );\n}\n\n/** Cubic ease-out with a slight overshoot then settle — a meniscus builds\n * past its resting bulge before easing back, not a flat asymptotic grow. */\nfunction easeOutBack(x: number): number {\n  const c1 = 1.4;\n  const c3 = c1 + 1;\n  return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);\n}\n\n/** #rrggbb -> rgba(...) string at the given alpha. Arithmetic on the token's\n * own channels only — never a new literal hue. */\nfunction hexWithAlpha(hex: string, alpha: number): string {\n  // Expand 3-digit shorthand first: this project's light --background is \"#fff\",\n  // and returning it unchanged silently dropped the alpha, turning every fade\n  // opaque in light theme while dark theme (6-digit tokens) looked correct.\n  let clean = hex.trim().replace(\"#\", \"\");\n  if (clean.length === 3) {\n    clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];\n  }\n  if (clean.length !== 6) return hex;\n  const r = parseInt(clean.slice(0, 2), 16);\n  const g = parseInt(clean.slice(2, 4), 16);\n  const b = parseInt(clean.slice(4, 6), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n",
      "type": "registry:ui",
      "target": "components/ui/braze-capillary-fill.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "progress",
      "loading",
      "fill",
      "canvas",
      "joining",
      "brazing",
      "ambient",
      "monochrome"
    ],
    "instruction": "Renders a progress/fill state as torch brazing: a horizontal hairline joint gap (two --border sheet-edge lines either side of a small canvas-drawn channel) that a bright capillary front sweeps left to right on its own internal clock, unforced, looping continuously with zero input — this is the default and what the catalog card shows with no props at all. The loop has three eras totalling 12.7s: a 9.4s fill era (the front sweeps the gap over its first 6.3s on a linear 0-1 progress, then a 0.9s fillet-meniscus grows at the far end with a slight overshoot-and-settle via a custom ease, then a ~2.2s quiet tail before the next era), a 2.1s hold at full fillet during which the molten alpha eases down from its peak to the settled solidified alpha (a 'cooling' cue), and a 1.2s fade of the entire filled track and fillet's opacity back to zero — a fresh empty joint, never a reverse wipe of the front position, since brazing doesn't unmelt. Everything drawn is fg (--foreground) at a modulated alpha over the bg backdrop, never a literal colour: the solidified trailing track sits at 0.68 alpha, the molten leading edge (a linear gradient trailing behind the front, its length wobbling ±20% at a slow 0.08Hz sine standing in for local gap-width variance along the seam) rises to 1.0 alpha, and the unfilled gap ahead of the front is simply unpainted background between two --border reference lines (the sheet edges, drawn as pure separators, full alpha since --border's own low contrast already keeps them structural rather than a fill). Canvas height defaults to 96px; geometry (gap half-thickness, meniscus radius) derives from min(canvas width, canvas height) so it holds up at card scale. Driven by requestAnimationFrame with elapsed-time accumulation (delta capped at 250ms so a backgrounded tab can't produce a giant catch-up jump), paused via IntersectionObserver (threshold 0) when scrolled offscreen and visibilitychange when the tab is hidden, resumed cleanly on return. Tokens (--foreground, --background, --border) are read via getComputedStyle(document.documentElement) before the very first draw call and re-read on a MutationObserver watching documentElement's class attribute, with every resize path (ResizeObserver on the canvas, debounced 150ms) re-reading tokens too so a theme flip mid-resize never paints stale colour. Under prefers-reduced-motion the canvas freezes at FREEZE_PROGRESS = 0.62 (front 62% across the gap, 3.906s into the notional 6.3s front-sweep) — chosen because it is the one frame that shows both halves of the mechanic at once: solidified/molten track behind the front and untouched gap ahead of it, which neither an empty t0 frame nor the full-fillet hold frame shows on its own. An optional `progress` prop (0-1) pins the front position directly to real external state instead of the ambient demo clock, for wiring this up as an actual upload/import/save indicator rather than a decorative loop; when a fillet is warranted (progress >= 1) it renders at full molten alpha with no hold/fade cycle, since a real completed action doesn't loop. The whole canvas is aria-hidden with a sibling role=img element carrying a static accessible label describing the mechanic, since the continuously-changing fill position is not meaningful to announce moment-to-moment (same non-narration-of-continuous-value pattern as meter-latency-capillary). Zero dependencies, single canvas element plus a small DOM label header, no WebGL."
  },
  "type": "registry:ui"
}