{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "airlift-slug-flow",
  "title": "Airlift Slug Flow",
  "description": "A background file-sync/upload activity rail: a slim vertical conduit where discrete air slugs, injected at the foot every 1.8s, rise at a constant 140px/s and drag a liquid plug immediately ahead of them, depositing into an accumulator basin on arrival. A real airlift-pump slug-flow regime standing in for a generic spinner or looping percentage.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/airlift-slug-flow/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// AirliftSlugFlow — a background file-sync/upload activity rail: a slim\n// vertical conduit, sized off the container's smaller dimension, standing in\n// for a generic spinner or a looping \"syncing…\" label. The mechanic is a\n// real airlift pump (mining dewatering, geothermal lift, aquarium airlift):\n// compressed air injected at the foot of a submerged riser forms discrete gas\n// slugs, and each slug's buoyant rise drags the plug of liquid immediately\n// ahead of it up the pipe — classic two-phase \"slug flow\", delivery arriving\n// in distinct pulses rather than a continuous stream.\n//\n// A new slug/plug pair is injected at the rail's foot every 1.8s (inside the\n// real 0.5-3Hz small-riser slugging band) and rises at a constant 140px/s\n// (buoyancy-driven rise is near-constant, not accelerating). The slug is a\n// rounded capsule at reduced --foreground opacity (the bubble read); the\n// liquid plug it drags is a solid --foreground band riding flush against the\n// slug's leading edge, same speed — two coupled parts, never one blob. On\n// reaching the rail's head the pair fades over 220ms and deposits into a\n// small accumulator basin, whose fill increments 1/14th per arrival and\n// wraps every 14 arrivals (~25s self-contained macro-cycle).\n//\n// Everything is driven off a virtual clock that only advances while the rail\n// is on-screen (an IntersectionObserver pauses/resumes it), so a tab-away\n// never produces a catch-up burst on return. Geometry is recomputed from a\n// ResizeObserver reading the container's own box — no restart on resize, the\n// running pairs simply reflow onto the new rail height next frame.\n// ---------------------------------------------------------------------------\n\nconst INJECT_INTERVAL_MS = 1800; // slug injection period — real 0.5-3Hz small-riser band\nconst RISE_SPEED_PX_S = 140; // constant buoyancy-driven rise speed\nconst SLUG_HEIGHT_FRAC = 0.09; // slug capsule height, fraction of rail height\nconst PLUG_HEIGHT_FRAC = 0.14; // dragged liquid plug height, fraction of rail height\nconst FADE_MS = 220; // arrival fade-out, slug+plug together\nconst BASIN_STEPS = 14; // arrivals per full basin cycle (~25s macro-cycle)\nconst RAIL_WIDTH_FRAC = 0.08; // rail width = 8% of the container's smaller dimension\nconst RAIL_WIDTH_MIN = 5;\nconst RAIL_WIDTH_MAX = 22;\nconst BASIN_SIZE_MULT = 2.4; // basin side length = rail width * this\nconst BASIN_GAP = 3; // px gap between rail head and basin\nconst REDUCED_BASIN_FRAC = 5 / BASIN_STEPS; // frozen-frame basin level — mid-cycle, not 0 or full\n\ninterface Pair {\n  slug: HTMLDivElement;\n  plug: HTMLDivElement;\n  injectedAtV: number; // virtual ms timestamp at injection\n  fading: boolean;\n  fadeStartV: number;\n}\n\nexport interface AirliftSlugFlowProps {\n  /** fired once per slug arrival at the basin — hook a real file row's status pulse to it */\n  onSlugArrival?: () => void;\n  /** extra classes merged onto the root element */\n  className?: string;\n}\n\nexport function AirliftSlugFlow({ onSlugArrival, className = \"\" }: AirliftSlugFlowProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const railRef = useRef<HTMLDivElement>(null);\n  const columnRef = useRef<HTMLDivElement>(null);\n  const layerRef = useRef<HTMLDivElement>(null);\n  const basinRef = useRef<HTMLDivElement>(null);\n  const basinFillRef = useRef<HTMLDivElement>(null);\n  const onArrivalRef = useRef(onSlugArrival);\n  onArrivalRef.current = onSlugArrival;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const rail = railRef.current;\n    const column = columnRef.current;\n    const layer = layerRef.current;\n    const basin = basinRef.current;\n    const basinFill = basinFillRef.current;\n    if (!root || !rail || !column || !layer || !basin || !basinFill) return;\n\n    let disposed = false;\n    let railWidthPx = 0;\n    let railHeightPx = 0;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let vClock = 0;\n    let injectAcc = 0;\n    let arrivalCount = 0;\n    let pairs: Pair[] = [];\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n\n    const layoutBasin = () => {\n      const size = Math.min(RAIL_WIDTH_MAX * BASIN_SIZE_MULT, Math.max(RAIL_WIDTH_MIN * BASIN_SIZE_MULT, railWidthPx * BASIN_SIZE_MULT));\n      basin.style.width = `${size}px`;\n      basin.style.height = `${size}px`;\n      basin.style.marginBottom = `${BASIN_GAP}px`;\n    };\n\n    const setBasinFrac = (frac: number) => {\n      basinFill.style.height = `${Math.max(0, Math.min(1, frac)) * 100}%`;\n    };\n\n    const spawnPair = (injectedAtV: number) => {\n      const slug = document.createElement(\"div\");\n      slug.className = \"asf-slug\";\n      const plug = document.createElement(\"div\");\n      plug.className = \"asf-plug\";\n      layer.appendChild(plug);\n      layer.appendChild(slug);\n      pairs.push({ slug, plug, injectedAtV, fading: false, fadeStartV: 0 });\n    };\n\n    const removePair = (p: Pair) => {\n      p.slug.remove();\n      p.plug.remove();\n    };\n\n    const arrive = () => {\n      arrivalCount = (arrivalCount % BASIN_STEPS) + 1;\n      setBasinFrac(arrivalCount / BASIN_STEPS);\n      onArrivalRef.current?.();\n    };\n\n    const applyPairStyle = (p: Pair, elapsedV: number) => {\n      const slugH = railHeightPx * SLUG_HEIGHT_FRAC;\n      const plugH = railHeightPx * PLUG_HEIGHT_FRAC;\n      const y = (elapsedV / 1000) * RISE_SPEED_PX_S;\n      const slugTop = y + slugH;\n      const pairTop = slugTop + plugH;\n\n      if (!p.fading && pairTop >= railHeightPx) {\n        p.fading = true;\n        p.fadeStartV = elapsedV;\n        p.slug.style.transition = `opacity ${FADE_MS}ms linear`;\n        p.plug.style.transition = `opacity ${FADE_MS}ms linear`;\n        p.slug.style.opacity = \"0\";\n        p.plug.style.opacity = \"0\";\n        arrive();\n      }\n\n      p.slug.style.height = `${slugH}px`;\n      p.slug.style.bottom = `${Math.min(y, railHeightPx - slugH)}px`;\n      p.plug.style.height = `${plugH}px`;\n      p.plug.style.bottom = `${Math.min(slugTop, railHeightPx - 0)}px`;\n    };\n\n    const layoutFrozenFrame = () => {\n      const slugH = railHeightPx * SLUG_HEIGHT_FRAC;\n      const plugH = railHeightPx * PLUG_HEIGHT_FRAC;\n      const pairH = slugH + plugH;\n      const bottom = railHeightPx * 0.5 - pairH / 2;\n      layer.innerHTML = \"\";\n      const slug = document.createElement(\"div\");\n      slug.className = \"asf-slug\";\n      slug.style.height = `${slugH}px`;\n      slug.style.bottom = `${bottom}px`;\n      const plug = document.createElement(\"div\");\n      plug.className = \"asf-plug\";\n      plug.style.height = `${plugH}px`;\n      plug.style.bottom = `${bottom + slugH}px`;\n      layer.appendChild(plug);\n      layer.appendChild(slug);\n      setBasinFrac(REDUCED_BASIN_FRAC);\n    };\n\n    const measure = () => {\n      const rect = root.getBoundingClientRect();\n      const minDim = Math.min(rect.width, rect.height);\n      railWidthPx = Math.max(RAIL_WIDTH_MIN, Math.min(RAIL_WIDTH_MAX, minDim * RAIL_WIDTH_FRAC));\n      column.style.width = `${railWidthPx}px`;\n      rail.style.width = `${railWidthPx}px`;\n      layoutBasin();\n      const basinRect = basin.getBoundingClientRect();\n      railHeightPx = Math.max(0, rect.height - basinRect.height - BASIN_GAP);\n      rail.style.height = `${railHeightPx}px`;\n      if (mq.matches) layoutFrozenFrame();\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (disposed) return;\n      if (!visible || railHeightPx <= 0) {\n        raf = requestAnimationFrame(loop);\n        return;\n      }\n      if (last === 0) last = now;\n      const dt = Math.min(100, now - last);\n      last = now;\n      vClock += dt;\n\n      injectAcc += dt;\n      while (injectAcc >= INJECT_INTERVAL_MS) {\n        injectAcc -= INJECT_INTERVAL_MS;\n        spawnPair(vClock);\n      }\n\n      pairs = pairs.filter((p) => {\n        const elapsedV = vClock - p.injectedAtV;\n        applyPairStyle(p, elapsedV);\n        if (p.fading && vClock - p.fadeStartV >= FADE_MS) {\n          removePair(p);\n          return false;\n        }\n        return true;\n      });\n\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      measure();\n      if (mq.matches) return; // reduced motion: one static frame, no loop\n      last = 0;\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    const onReducedChange = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      layer.innerHTML = \"\";\n      pairs = [];\n      injectAcc = 0;\n      vClock = 0;\n      last = 0;\n      arrivalCount = 0;\n      setBasinFrac(0);\n      start();\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    let resizeTimer = 0;\n    const ro = new ResizeObserver(() => {\n      window.clearTimeout(resizeTimer);\n      resizeTimer = window.setTimeout(() => {\n        if (disposed) return;\n        measure();\n      }, 100);\n    });\n    ro.observe(root);\n\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !mq.matches && !raf) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    document.fonts.ready.then(() => {\n      if (!disposed) measure();\n    });\n\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      window.clearTimeout(resizeTimer);\n      mq.removeEventListener(\"change\", onReducedChange);\n      ro.disconnect();\n      io.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  return (\n    <div ref={rootRef} className={`relative flex h-full w-full flex-col items-center justify-end ${className}`}>\n      <style>{CSS}</style>\n      <div ref={basinRef} className=\"asf-basin\">\n        <div ref={basinFillRef} className=\"asf-basin-fill\" />\n      </div>\n      <div ref={columnRef} className=\"relative\">\n        <div ref={railRef} className=\"asf-static-column relative overflow-visible\">\n          <div ref={layerRef} className=\"absolute inset-x-0 bottom-0\" />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.asf-static-column {\n  background: var(--ns-muted);\n  opacity: 0.3;\n  border-radius: 999px;\n}\n.dark .asf-static-column {\n  opacity: 0.16;\n}\n.asf-slug {\n  position: absolute;\n  left: 0;\n  right: 0;\n  border-radius: 999px;\n  background: var(--foreground);\n  opacity: 0.4;\n}\n.asf-plug {\n  position: absolute;\n  left: 0;\n  right: 0;\n  border-radius: 2px;\n  background: var(--foreground);\n  opacity: 1;\n}\n.asf-basin {\n  position: relative;\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: 3px;\n  background: var(--background);\n}\n.asf-basin-fill {\n  position: absolute;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  height: 0%;\n  background: var(--foreground);\n  transition: height 220ms linear;\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/airlift-slug-flow.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "sync",
      "upload",
      "progress",
      "rail",
      "svg",
      "dom",
      "ambient",
      "fluid",
      "pump",
      "activity"
    ],
    "instruction": "A vertical activity rail, card-scale, meant to sit beside a real file list or sync panel. Root is a flex column anchored to the bottom (justify-end) so the rail's foot sits at the container's bottom edge and a small accumulator basin sits above its head. On mount, and on every debounced (100ms) ResizeObserver callback on the root, the container's own bounding box is measured: rail width = 8% of the container's smaller dimension, clamped 5-22px; the basin is a bordered square whose side is railWidth * 2.4 (itself clamped against the same 5-22px band before multiplying), separated from the rail by a 3px gap; rail height = container height minus the basin's rendered height minus that 3px gap, so the rail always fills whatever vertical space remains. The static liquid column is a single pill-shaped div spanning the full rail height, background var(--ns-muted) at opacity 0.3 in light theme and 0.16 under a `.dark` ancestor class (the muted token washes out against light backgrounds at low opacity, so light theme gets a deliberately higher floor; the moving slug/plug pair is var(--foreground) unmodified across themes since it is already the primary legibility carrier). Every 1800ms (real airlift small-riser slugging sits in the 0.5-3Hz band; 1.8s is inside it) a new slug+plug pair is injected at the rail's foot: raw DOM elements appended directly to a layer div and driven every animation frame by imperative style writes (bottom/height), not React state, so multiple concurrent pairs in flight cost nothing extra. The slug is a full-width pill (border-radius 999px) at 40% opacity var(--foreground) — the bubble read; the liquid plug it drags is a solid 100%-opacity var(--foreground) band with a 2px corner radius, riding flush against the slug's leading (upper) edge at the identical rise speed, so the two always touch and never separate or overlap. Both rise at a constant 140px/s (buoyancy-driven rise is near-constant, not accelerating) computed off a virtual clock that only advances while an IntersectionObserver reports the root as on-screen, so backgrounding the tab never produces a catch-up burst of slugs on return; the same virtual clock (not wall time) drives the 1800ms injection accumulator. Slug height is 9% of the current rail height, plug height 14%, both re-derived from railHeightPx every frame so a mid-flight resize reflows the pair onto the new geometry without restarting the cycle. The moment the pair's combined top edge reaches the rail's head, both elements get a 220ms linear opacity transition to 0 (removed from the DOM once that transition's virtual-clock duration elapses) and the arrival is recorded: an arrival counter increments mod 14 and the basin's fill div (absolutely positioned, bottom-anchored, height set as a percentage with its own 220ms linear transition so the deposit reads as landing in sync with the fade) is set to count/14 — a full 14-arrival cycle is roughly 25s and self-contained, no external data required. An optional `onSlugArrival` callback fires once per arrival, exactly at the moment the fade begins, meant to be wired to a real adjacent file row's status text for a single discrete pulse — the callback and the demo's row-cycling handler are the only sanctioned use of the arrival event; nothing in the component itself ever mixes var(--ns-accent) into the slug, plug, or basin fill, since accent is interaction chrome only and this is an ambient, non-interactive rail. Under `prefers-reduced-motion: reduce` (checked via matchMedia with a live change listener) no rAF loop or injection accumulator ever starts: exactly one slug/plug pair is laid out frozen with its combined vertical center at 50% of rail height — the single frame where both parts are visible, coupled, and clearly separated from the static column above and below — and the basin fill is set to a fixed 5/14, a deliberately mid-cycle, non-zero, non-full level rather than either boundary. Cleanup on unmount cancels the animation frame, clears the resize debounce timer, and disconnects both the ResizeObserver and the IntersectionObserver. Zero dependencies, DOM + CSS only, no canvas, every colour a token (--background --foreground --ns-muted --border), autoplay is 'none' because the rail runs on its own internal clock with nothing for a synthetic pointer/scroll/press driver to trigger."
  },
  "type": "registry:ui"
}