{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "float-ribbon-draw",
  "title": "Float Ribbon Draw",
  "description": "A multi-stage pipeline/job-status bar modeled on the float-glass process: a fixed molten-to-set thermal gradient marks the pipeline's stages while a faint ripple scrolls continuously through the still-molten zone at a constant draw speed, so the bar keeps reading as 'actively running' whether or not a specific stage is highlighted.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/float-ribbon-draw/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FloatRibbonDraw — a multi-stage pipeline/job-status bar modeled on the\n// float-glass process (the Pilkington process, the method that produces\n// essentially all flat glass made today): molten glass flows continuously\n// onto a bath of molten tin, floats and spreads to its natural equilibrium\n// thickness, and is drawn forward by top rollers at a constant line speed\n// while it cools along the bath's length, entering molten (~1,000C) and\n// exiting set (~600C). That process has no start/stop within a run — it is\n// a single continuous draw — which is exactly the shape most \"job status\"\n// widgets get wrong by pausing their idle animation while \"waiting.\"\n//\n// TWO SEPARATE THINGS, deliberately not conflated:\n//  - The THERMAL GRADIENT (bright/dense \"molten\" -> dim \"set\", left to\n//    right) is a FIXED function of position along the bar. It represents\n//    the pipeline's stages, and never itself animates — pausing it would\n//    misrepresent a process stage as somehow reversible.\n//  - The RIPPLE (a faint sine wobble riding the top edge, confined to the\n//    still-molten first third and damped out by where the gradient has\n//    mostly set) is what SCROLLS continuously at a constant rate. It is the\n//    \"alive at rest\" signal: material is actively moving through the\n//    pipeline right now, regardless of which stage is highlighted.\n//\n// Colour is luminance-only: every fill is a lerp between --ns-muted (t=0,\n// \"set\") and --foreground (t=1, \"molten\") — never a literal orange/red heat\n// colour. That mapping holds in both themes because --foreground is already\n// the theme's highest-density ink (near-white in dark, near-black in\n// light), so \"hot\" reads as \"densest\" in both, not as a hue.\n//\n// Optional `stages`/`activeStage` overlay fixed pipeline-stage tick marks\n// (queued/processing/done, etc.) at even x-fractions along the bar — the\n// ribbon's own gradient/ripple keeps running unconditionally underneath\n// regardless of which stage is active, per the real process never stopping\n// mid-draw.\n// ---------------------------------------------------------------------------\n\ntype Vec3 = [number, number, number];\n\n// Same parse+lerp idiom used elsewhere in this registry (house convention,\n// duplicated per component rather than shared).\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0]! + hex[0], 16);\n      const g = parseInt(hex[1]! + hex[1], 16);\n      const b = parseInt(hex[2]! + hex[2], 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nfunction lerpColor(a: Vec3, b: Vec3, t: number): Vec3 {\n  const c = Math.min(1, Math.max(0, t));\n  return [\n    Math.round(a[0] + (b[0] - a[0]) * c),\n    Math.round(a[1] + (b[1] - a[1]) * c),\n    Math.round(a[2] + (b[2] - a[2]) * c),\n  ];\n}\n\nfunction rgbStr(v: Vec3, alpha = 1): string {\n  return alpha >= 1 ? `rgb(${v[0]},${v[1]},${v[2]})` : `rgba(${v[0]},${v[1]},${v[2]},${alpha})`;\n}\n\nconst SAMPLES = 64; // 1D luminance/ripple lookup across the ribbon's length\nconst LAMBDA = 0.35; // exp decay constant, in ribbon-lengths — how fast \"hot\" gives way to \"set\"\nconst T_FLOOR = 0.08; // luminance never fully bottoms out to pure --ns-muted\nconst RIBBON_FRAC = 0.28; // ribbon band height as a fraction of container height\nconst SCROLL_FRAC_PER_S = 0.08; // ripple scroll speed, fraction of container width per second\nconst RIPPLE_WAVELEN_FRAC = 0.18; // ripple wavelength, fraction of container width\nconst RIPPLE_AMP_FRAC = 0.09; // ripple amplitude, fraction of ribbon height\nconst MOLTEN_ZONE = 0.34; // ripple envelope is ~0 past this fraction along the ribbon\n\nfunction luminanceAt(xFrac: number): number {\n  return T_FLOOR + (1 - T_FLOOR) * Math.exp(-xFrac / LAMBDA);\n}\n\n// ripple envelope: full amplitude near the molten (left) edge, damped to 0\n// by MOLTEN_ZONE — a raised-cosine falloff, not a hard cutoff.\nfunction rippleEnvelope(xFrac: number): number {\n  if (xFrac >= MOLTEN_ZONE) return 0;\n  const t = xFrac / MOLTEN_ZONE;\n  return 0.5 * (1 + Math.cos(t * Math.PI));\n}\n\nexport interface FloatRibbonDrawProps {\n  /** total component height in px; the ribbon band derives as 28% of this. Default 88. */\n  height?: number;\n  /** optional ordered pipeline stage labels, drawn as fixed ticks along the bar */\n  stages?: string[];\n  /** index into `stages` that is currently active/lit. Omit for a purely ambient ribbon with no stage semantics. */\n  activeStage?: number;\n  /** accessible label for the status region. Default \"Processing pipeline\" */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function FloatRibbonDraw({\n  height = 88,\n  stages,\n  activeStage,\n  label = \"Processing pipeline\",\n  className = \"\",\n}: FloatRibbonDrawProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const lastAnnouncedRef = useRef<number | undefined>(undefined);\n\n  useEffect(() => {\n    if (!stages || activeStage === undefined) return;\n    if (lastAnnouncedRef.current === activeStage) return;\n    lastAnnouncedRef.current = activeStage;\n    const s = stages[activeStage];\n    if (s) setAnnounce(`Stage: ${s}`);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [activeStage, stages?.join(\"|\")]);\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 muted: Vec3 = [128, 128, 128];\n    let fg: Vec3 = [0, 0, 0];\n    let border: Vec3 = [128, 128, 128];\n    let borderAlpha = 1;\n\n    const readTokens = () => {\n      const root = getComputedStyle(document.documentElement);\n      muted = parseColor(root.getPropertyValue(\"--ns-muted\")) ?? muted;\n      fg = parseColor(root.getPropertyValue(\"--foreground\")) ?? fg;\n      border = parseColor(root.getPropertyValue(\"--border\")) ?? border;\n      // --border ships at low alpha via its own token in some themes; if the\n      // computed value carries no alpha channel, keep full opacity and let\n      // the token's own colour do the (deliberately faint) work.\n      const m = root.getPropertyValue(\"--border\").match(/rgba?\\([^)]*,\\s*([\\d.]+)\\s*\\)/);\n      borderAlpha = m ? Number(m[1]) : 1;\n    };\n\n    let width = 0;\n    let sized = false;\n    let ribbonH = 0;\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      width = rect.width;\n      ribbonH = Math.max(4, height * RIBBON_FRAC);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(rect.width * dpr));\n      canvas.height = Math.max(1, Math.round(rect.height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      sized = true;\n    };\n\n    // scrollPx: how far the ripple texture has travelled, in css px. Never\n    // resets — the draw is a single continuous run.\n    let scrollPx = 0;\n\n    const draw = () => {\n      if (!sized) return;\n      const rect = canvas.getBoundingClientRect();\n      const h = rect.height;\n      const top = (h - ribbonH) / 2;\n      ctx.clearRect(0, 0, rect.width, h);\n\n      // 1D sample lookup across the ribbon's length: luminance (fixed, a\n      // function of x only) and ripple y-offset (a function of x and the\n      // scroll phase), bilinearly interpolated between the SAMPLES points.\n      const lum = new Float32Array(SAMPLES);\n      const rip = new Float32Array(SAMPLES);\n      const wavelenPx = Math.max(1, width * RIPPLE_WAVELEN_FRAC);\n      const ampPx = ribbonH * RIPPLE_AMP_FRAC;\n      for (let i = 0; i < SAMPLES; i++) {\n        const xFrac = i / (SAMPLES - 1);\n        lum[i] = luminanceAt(xFrac);\n        const env = rippleEnvelope(xFrac);\n        const xPx = xFrac * width;\n        rip[i] = env * ampPx * Math.sin(((xPx + scrollPx) / wavelenPx) * Math.PI * 2);\n      }\n\n      const sampleAt = (arr: Float32Array, xFrac: number): number => {\n        const pos = Math.min(SAMPLES - 1, Math.max(0, xFrac * (SAMPLES - 1)));\n        const i0 = Math.floor(pos);\n        const i1 = Math.min(SAMPLES - 1, i0 + 1);\n        const f = pos - i0;\n        const a = arr[i0] ?? 0;\n        const b = arr[i1] ?? 0;\n        return a + (b - a) * f;\n      };\n\n      // gradient fill along the ribbon's length, stopped at 12 points from\n      // the pre-sampled luminance curve (the curve is exponential, not\n      // linear, so a 2-stop canvas gradient would misrepresent it).\n      const grad = ctx.createLinearGradient(0, 0, width, 0);\n      const STOPS = 12;\n      for (let s = 0; s <= STOPS; s++) {\n        const xFrac = s / STOPS;\n        const t = sampleAt(lum, xFrac);\n        grad.addColorStop(xFrac, rgbStr(lerpColor(muted, fg, t)));\n      }\n\n      ctx.beginPath();\n      ctx.moveTo(0, top + sampleAt(rip, 0));\n      const STEP = 2; // px, top-edge ripple polyline resolution\n      for (let x = 0; x <= width; x += STEP) {\n        const xFrac = x / width;\n        ctx.lineTo(x, top + sampleAt(rip, xFrac));\n      }\n      ctx.lineTo(width, top + ribbonH);\n      ctx.lineTo(0, top + ribbonH);\n      ctx.closePath();\n      ctx.fillStyle = grad;\n      ctx.fill();\n\n      // a thin --border rule under the full ribbon length, keeping the\n      // \"set\" (right, low-luminance) end from disappearing into --surface\n      // in light theme.\n      ctx.beginPath();\n      ctx.moveTo(0, top + ribbonH + 0.5);\n      ctx.lineTo(width, top + ribbonH + 0.5);\n      ctx.lineWidth = 1;\n      ctx.strokeStyle = rgbStr(border, borderAlpha);\n      ctx.stroke();\n\n      // fixed pipeline-stage ticks, if provided — luminance only, never\n      // accent, drawn on top of the ribbon at even x-fractions.\n      if (stages && stages.length > 0) {\n        for (let i = 0; i < stages.length; i++) {\n          const xFrac = stages.length > 1 ? i / (stages.length - 1) : 0.5;\n          const x = xFrac * width;\n          const active = activeStage === i;\n          ctx.fillStyle = rgbStr(active ? fg : muted, active ? 0.9 : 0.55);\n          const tickH = active ? ribbonH * 0.55 : ribbonH * 0.35;\n          ctx.fillRect(Math.round(x - 0.5), top + (ribbonH - tickH) / 2, 1, tickH);\n        }\n      }\n    };\n\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n\n    const loop = (now: number) => {\n      if (!visible || document.hidden) {\n        raf = 0;\n        return;\n      }\n      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;\n      last = now;\n      scrollPx += width * SCROLL_FRAC_PER_S * (dtMs / 1000);\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (raf || reduced) return;\n      last = 0;\n      raf = requestAnimationFrame(loop);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\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();\n      }, 120);\n    };\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible) start();\n        else stop();\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      if (document.hidden) stop();\n      else if (visible) start();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // no paint before the first token read\n    readTokens();\n    resize();\n\n    if (reduced) {\n      // freeze at scroll-phase 0 — the ripple's most evenly-spread crest\n      // layout across the molten zone, not an arbitrary mid-scroll moment.\n      scrollPx = 0;\n      draw();\n    } else {\n      draw();\n      start();\n    }\n\n    return () => {\n      stop();\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [height, stages?.join(\"|\"), activeStage]);\n\n  return (\n    <div role=\"group\" aria-label={label} className={`relative w-full ${className}`} style={{ height }}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"block h-full w-full\" />\n      {stages && stages.length > 0 && (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-1 left-0 flex w-full justify-between px-0.5\"\n        >\n          {stages.map((s, i) => (\n            <span\n              key={s}\n              className=\"font-mono text-[9px] uppercase tracking-[0.1em]\"\n              style={{ color: activeStage === i ? \"var(--foreground)\" : \"var(--ns-muted)\" }}\n            >\n              {s}\n            </span>\n          ))}\n        </div>\n      )}\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/float-ribbon-draw.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": [
      "status",
      "progress",
      "pipeline",
      "stepper",
      "job",
      "canvas",
      "monochrome"
    ],
    "instruction": "Build <FloatRibbonDraw height? stages? activeStage? label? className?> as a full-width horizontal status bar. SOURCE, NOT INVENTED: the float-glass (Pilkington) process, the method that produces essentially all flat glass made today — molten glass flows continuously onto a bath of molten tin, floats and spreads to its natural equilibrium thickness, and is drawn forward by top rollers at a constant line speed while it cools along the bath's length, entering molten (~1,000C) and exiting set (~600C). The real process never starts or stops mid-draw, which is the exact property most 'job status' bars get wrong by pausing their idle animation while waiting on something. TWO DELIBERATELY SEPARATE THINGS: (1) a THERMAL GRADIENT — bright/dense 'molten' on the left fading to dim 'set' on the right — that is a FIXED function of x-position along the bar and never itself animates, because it represents the pipeline's stage territory, not elapsed time; (2) a RIPPLE — a faint sine wobble riding the ribbon's top edge, confined by a raised-cosine envelope to roughly the still-molten first third (full amplitude near x=0, damped to ~0 by x-fraction MOLTEN_ZONE=0.34) — that scrolls continuously at SCROLL_FRAC_PER_S=0.08 (8% of the container's width per second, so the speed is resolution-independent rather than a fixed px/s), which is the actual 'alive at rest' signal: material is moving through the pipeline right now, independent of which stage is highlighted. GEOMETRY/RESOLUTION: ribbon band height = height * RIBBON_FRAC (0.28), vertically centered in the canvas; both the fixed luminance curve and the ripple's y-offset are pre-sampled into two Float32Array(SAMPLES) lookups (SAMPLES=64) across the ribbon's length each frame, bilinearly interpolated when read — luminance at x-fraction xf is T_FLOOR + (1-T_FLOOR) * exp(-xf/LAMBDA) with LAMBDA=0.35 and T_FLOOR=0.08 (so it decays fast then trails, matching the bath's front-loaded real cooling curve, and never fully bottoms out to flat --ns-muted); the canvas fill is a linear gradient built from 12 stops sampled off that curve (a 2-stop gradient would misrepresent the exponential shape as linear). COLOUR IS LUMINANCE ONLY: every fill is parseColor()+lerpColor() between --ns-muted (t=0, 'set') and --foreground (t=1, 'molten') — read via getComputedStyle(document.documentElement), re-read on a documentElement class MutationObserver, no paint before the first read. This mapping holds in both themes without inversion because --foreground is already each theme's highest-density ink (near-white in dark, near-black in light), so 'hot' reads as 'densest ink' in both rather than as a hue swap. A thin 1px --border rule (its own alpha parsed from the token, not assumed) is drawn under the full ribbon length unconditionally — it is what keeps the dim 'set' end of the gradient from disappearing into --surface in light theme; check light theme first. RESTING LOOP: scrollPx accumulates every frame (width * SCROLL_FRAC_PER_S * dt) and never resets — t0, t+2.5s and t+5s each land the ripple's crest positions at a different phase along the same fixed gradient, so all three frames are visibly distinct while the gradient itself stays put. OPTIONAL STAGES: `stages` (string[]) and `activeStage` (index) overlay fixed 1px tick marks at even x-fractions (i/(stages.length-1)) over the ribbon, plus matching mono labels in a DOM row beneath it — the active tick/label render at --foreground, others at --ns-muted, never --ns-accent; the ribbon's gradient/ripple render loop keeps running completely unconditionally regardless of activeStage, since pausing it to 'wait' on a stage misrepresents the real process. A visually-hidden role=status aria-live=polite region announces 'Stage: <label>' exactly once per activeStage change (never a running commentary on the ripple) when stages/activeStage are supplied; with neither supplied the bar is a bare ambient processing indicator and no announcement fires. prefers-reduced-motion freezes at scrollPx=0 — the ripple's most evenly-spread crest layout across the molten zone — and stops the rAF loop entirely; the fixed gradient, border rule, and any stage ticks/labels render exactly as in motion, so the mechanic and any real stage state stay fully legible without motion. Standard canvas host lifecycle: DPR-aware backing store capped at 2, ResizeObserver on the canvas (not window.resize), IntersectionObserver (threshold 0) pausing the loop off-screen, visibilitychange pausing on a hidden tab, and full cleanup (cancelAnimationFrame, disconnect both observers, remove the visibilitychange listener) on unmount. No interactive elements, no gate descriptor: the root is role=group with an accessible `label` (default 'Processing pipeline'); the canvas itself is aria-hidden decoration. Zero dependencies, direct-canvas rendering, only the stage announcement and its own change-detection ref live in React state."
  },
  "type": "registry:ui"
}