{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-capillary-wick",
  "title": "Background Capillary Wick",
  "description": "A full-bleed ambient background modeling paper chromatography: ink fronts wick along a fixed fibre-vein lattice, decelerating per the Washburn capillary-rise law as they climb, stalling at every junction, and re-igniting a subset of the node's dry outgoing edges before evaporating back to dry to make room for the next front.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/background-capillary-wick/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BackgroundCapillaryWick — a full-bleed ambient background modeling paper\n// chromatography: ink fronts wicking along a FIXED fibre-vein lattice, not a\n// fluid turbulence field. The substrate is a 2D canvas graph (jittered\n// triangular mesh of nodes + edges, generated once per size and never\n// reshuffled), and the physics is front propagation along that graph —\n// deceleration per the Washburn capillary-rise law (dPos/dt ∝ 1/√pos, a real\n// front slows as it climbs because flow resistance grows with wetted length),\n// stalling at each junction for a random dwell, then re-igniting a subset of\n// the node's still-dry outgoing edges. This is the load-bearing difference\n// from dye-whorl (registry/loud/dye-whorl): dye-whorl is a real incompressible\n// Navier-Stokes solver advecting a continuous dye field through open fluid —\n// no lattice, no discrete fronts, no junctions. Here the graph topology IS\n// the physics; there is no velocity field, no pressure solve, no advection —\n// ink can only exist ON an edge, and only in the order the graph connects.\n//\n// ALIVE AT REST, structurally, not cosmetically: a wet edge does not stay\n// wet. After a dwell past saturation it EVAPORATES (intensity decays back to\n// zero on a slower time constant than wetting), which returns it to \"dry\"\n// and eligible for re-ignition by a later nucleation event elsewhere on the\n// mesh. So the field is a standing turnover, not a fill-to-completion —\n// there is no saturated end state to reach, by construction, forever.\n//\n// READING ZONE: fresh nucleation points are weighted away from the\n// container's centre, and every stroke's alpha is additionally scaled by a\n// radial coverage mask that stays low near centre and rises toward the\n// edges — so overlaid headline/CTA content (typically centred) keeps a\n// legible field under it even while the lattice is busy at the margins.\n//\n// Tokens: --foreground is the only ink color (wetted strokes + the bright\n// advancing front head); --ns-muted draws the always-visible dry lattice\n// structure at low alpha, well below --border's separator contrast, so the\n// fixed veins read as texture rather than as a UI seam. No --ns-accent\n// anywhere — this is a resting ambient surface with no interaction moment.\n// ---------------------------------------------------------------------------\n\nconst TAU = Math.PI * 2;\n\n// mulberry32 — deterministic per-mesh PRNG so a given size always regenerates\n// the same lattice topology (only the runtime front schedule differs).\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\ninterface Node {\n  x: number;\n  y: number;\n  edges: number[]; // indices into the edges array\n  stallUntil: number; // sim time the node may spread to dry neighbors\n  pendingSpread: boolean;\n}\n\ninterface Edge {\n  a: number;\n  b: number;\n  len: number; // px, for speed normalization\n  cx: number; // midpoint, for the coverage mask\n  cy: number;\n  state: 0 | 1 | 2; // 0 dry, 1 wetting (front advancing), 2 wet (post-saturation)\n  pos: number; // 0..1 front position while wetting\n  intensity: number; // 0..1 ink alpha, ramps to 1 on saturation, decays on evaporation\n  wetAt: number; // sim time it reached state 2, for the evaporation delay\n}\n\nconst EVAP_DELAY = 3.4; // s a wet edge holds full intensity before it starts fading\nconst EVAP_TAU = 2.6; // s time constant of the fade back to dry\nconst WICK_K = 0.34; // Washburn rate constant, grid-units/s^0.5 equivalent\nconst NUCLEATE_MIN = 0.5; // s between nucleation attempts\nconst NUCLEATE_MAX = 1.3;\nconst STALL_MIN = 0.25; // s a junction holds before re-igniting neighbors\nconst STALL_MAX = 1.1;\nconst SPREAD_CHANCE = 0.62; // per-edge odds a dry neighbor gets re-ignited at a spread event\nconst CENTER_COVERAGE = 0.16; // ink alpha multiplier at the reading-zone centre\nconst EDGE_COVERAGE = 1.0; // ink alpha multiplier at the frame edges\nconst WARM_STEPS = 260; // fixed-dt sim steps run before first paint (and for reduced motion)\nconst WARM_DT = 1 / 30;\n\nexport interface BackgroundCapillaryWickProps {\n  /** lattice pitch as a fraction of the container's smaller dimension. @default 0.055 */\n  pitchRatio?: number;\n  /** freeze the field at its warm-start frame. @default false */\n  paused?: boolean;\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function BackgroundCapillaryWick({\n  pitchRatio = 0.055,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: BackgroundCapillaryWickProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(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 reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // token fields start empty and are assigned unconditionally from\n    // getComputedStyle before any draw path can run — nothing here has a\n    // literal fallback color.\n    let ink = \"\";\n    let muted = \"\";\n    let bg = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      ink = cs.getPropertyValue(\"--foreground\").trim();\n      muted = cs.getPropertyValue(\"--ns-muted\").trim();\n      bg = cs.getPropertyValue(\"--background\").trim();\n    };\n\n    let dpr = 1;\n    let width = 0;\n    let height = 0;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n    let last = 0;\n    let simTime = 0;\n\n    let nodes: Node[] = [];\n    let edges: Edge[] = [];\n    let nextNucleate = 0;\n    let pitch = 40;\n    let strokeW = 1.5;\n\n    const rand = mulberry32(0x9e3779b9);\n\n    const buildLattice = () => {\n      pitch = Math.max(22, Math.min(72, Math.min(width, height) * pitchRatio));\n      strokeW = Math.max(1, pitch * 0.055);\n\n      const cols = Math.ceil(width / pitch) + 2;\n      const rows = Math.ceil(height / pitch) + 2;\n      const jitter = pitch * 0.28;\n\n      nodes = [];\n      const grid: number[][] = [];\n      for (let ry = 0; ry < rows; ry++) {\n        const rowIdx: number[] = [];\n        for (let rx = 0; rx < cols; rx++) {\n          // hex-ish stagger so the mesh doesn't read as a raw square grid\n          const offsetX = ry % 2 === 0 ? 0 : pitch * 0.5;\n          const x = rx * pitch + offsetX - pitch + (rand() - 0.5) * jitter;\n          const y = ry * pitch - pitch + (rand() - 0.5) * jitter;\n          rowIdx.push(nodes.length);\n          nodes.push({ x, y, edges: [], stallUntil: 0, pendingSpread: false });\n        }\n        grid.push(rowIdx);\n      }\n\n      edges = [];\n      const addEdge = (ia: number, ib: number) => {\n        if (ia === ib) return;\n        const na = nodes[ia];\n        const nb = nodes[ib];\n        const dx = nb.x - na.x;\n        const dy = nb.y - na.y;\n        const len = Math.hypot(dx, dy);\n        if (len < 1 || len > pitch * 2.2) return; // fixed lattice: no long-range shortcuts\n        const ei = edges.length;\n        edges.push({\n          a: ia,\n          b: ib,\n          len,\n          cx: (na.x + nb.x) / 2,\n          cy: (na.y + nb.y) / 2,\n          state: 0,\n          pos: 0,\n          intensity: 0,\n          wetAt: 0,\n        });\n        na.edges.push(ei);\n        nb.edges.push(ei);\n      };\n\n      // connect each node to its right, down, and both down-diagonal\n      // neighbors — a fixed triangular fibre mesh, built once per size.\n      for (let ry = 0; ry < rows; ry++) {\n        for (let rx = 0; rx < cols; rx++) {\n          const i = grid[ry][rx];\n          if (rx + 1 < cols) addEdge(i, grid[ry][rx + 1]);\n          if (ry + 1 < rows) {\n            addEdge(i, grid[ry + 1][rx]);\n            const offsetEven = ry % 2 === 0;\n            if (offsetEven && rx - 1 >= 0) addEdge(i, grid[ry + 1][rx - 1]);\n            if (!offsetEven && rx + 1 < cols) addEdge(i, grid[ry + 1][rx + 1]);\n          }\n        }\n      }\n\n      simTime = 0;\n      nextNucleate = NUCLEATE_MIN;\n    };\n\n    const dryNeighbors = (nodeIndex: number) =>\n      nodes[nodeIndex].edges.filter((ei) => edges[ei].state === 0);\n\n    const centerDist01 = (x: number, y: number) => {\n      // 0 at container centre, 1 at the frame's far corner\n      const dx = (x - width / 2) / (width / 2 || 1);\n      const dy = (y - height / 2) / (height / 2 || 1);\n      return Math.min(1, Math.hypot(dx, dy));\n    };\n\n    const igniteEdge = (ei: number) => {\n      const e = edges[ei];\n      e.state = 1;\n      e.pos = 0;\n      e.intensity = 0.18;\n    };\n\n    const tryNucleate = () => {\n      if (nodes.length === 0) return;\n      // bias node selection away from the reading zone at the container's\n      // centre — fewer fresh sources start there, keeping it lighter.\n      for (let attempt = 0; attempt < 6; attempt++) {\n        const idx = Math.floor(rand() * nodes.length);\n        const n = nodes[idx];\n        const d = centerDist01(n.x, n.y);\n        if (rand() > 0.25 + d * 0.75) continue; // low d (near centre) mostly rejected\n        const candidates = dryNeighbors(idx);\n        if (candidates.length === 0) continue;\n        igniteEdge(candidates[Math.floor(rand() * candidates.length)]);\n        return;\n      }\n    };\n\n    const spreadFromNode = (nodeIndex: number) => {\n      const candidates = dryNeighbors(nodeIndex);\n      if (candidates.length === 0) return;\n      let ignitedAny = false;\n      for (const ei of candidates) {\n        if (rand() < SPREAD_CHANCE) {\n          igniteEdge(ei);\n          ignitedAny = true;\n        }\n      }\n      // a junction that rolled all misses still routes to one edge — a real\n      // capillary front never just stops at a junction with dry paper open.\n      if (!ignitedAny) igniteEdge(candidates[Math.floor(rand() * candidates.length)]);\n    };\n\n    const step = (dt: number) => {\n      simTime += dt;\n\n      if (simTime >= nextNucleate) {\n        tryNucleate();\n        nextNucleate = simTime + NUCLEATE_MIN + rand() * (NUCLEATE_MAX - NUCLEATE_MIN);\n      }\n\n      for (let i = 0; i < edges.length; i++) {\n        const e = edges[i];\n        if (e.state === 1) {\n          // Washburn capillary rise: front speed falls off as 1/sqrt(distance\n          // already wetted) — fast off the junction, decelerating as it\n          // climbs, because wetted-length resistance grows with distance.\n          const speed = (WICK_K / Math.sqrt(Math.max(e.pos, 0.06))) * (pitch / Math.max(e.len, 1));\n          e.pos += speed * dt;\n          e.intensity = Math.min(1, e.intensity + dt * 1.4);\n          if (e.pos >= 1) {\n            e.pos = 1;\n            e.state = 2;\n            e.intensity = 1;\n            e.wetAt = simTime;\n            const farNode = nodes[e.b];\n            if (!farNode.pendingSpread) {\n              farNode.pendingSpread = true;\n              farNode.stallUntil = simTime + STALL_MIN + rand() * (STALL_MAX - STALL_MIN);\n            }\n          }\n        } else if (e.state === 2) {\n          const age = simTime - e.wetAt;\n          if (age > EVAP_DELAY) {\n            e.intensity *= Math.exp(-dt / EVAP_TAU);\n            if (e.intensity < 0.035) {\n              e.intensity = 0;\n              e.state = 0;\n              e.pos = 0;\n            }\n          }\n        }\n      }\n\n      for (let i = 0; i < nodes.length; i++) {\n        const n = nodes[i];\n        if (n.pendingSpread && simTime >= n.stallUntil) {\n          spreadFromNode(i);\n          n.pendingSpread = false;\n        }\n      }\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, width, height);\n\n      // dry lattice: always-visible faint structure, well under --border's\n      // separator contrast — reads as fibre texture, not a UI seam.\n      ctx.strokeStyle = muted;\n      ctx.globalAlpha = 0.16;\n      ctx.lineWidth = strokeW * 0.6;\n      ctx.beginPath();\n      for (const e of edges) {\n        if (e.state !== 0) continue;\n        const na = nodes[e.a];\n        const nb = nodes[e.b];\n        ctx.moveTo(na.x, na.y);\n        ctx.lineTo(nb.x, nb.y);\n      }\n      ctx.stroke();\n\n      // wet + wetting edges: --foreground ink, alpha = intensity * a radial\n      // coverage mask that stays low near centre so overlaid content reads.\n      ctx.strokeStyle = ink;\n      ctx.lineCap = \"round\";\n      for (const e of edges) {\n        if (e.state === 0 || e.intensity <= 0) continue;\n        const na = nodes[e.a];\n        const nb = nodes[e.b];\n        const mask = CENTER_COVERAGE + (EDGE_COVERAGE - CENTER_COVERAGE) * centerDist01(e.cx, e.cy);\n        const alpha = e.intensity * mask;\n        if (alpha <= 0.01) continue;\n        ctx.globalAlpha = alpha;\n        ctx.lineWidth = strokeW;\n        ctx.beginPath();\n        if (e.state === 1) {\n          const fx = na.x + (nb.x - na.x) * e.pos;\n          const fy = na.y + (nb.y - na.y) * e.pos;\n          ctx.moveTo(na.x, na.y);\n          ctx.lineTo(fx, fy);\n          ctx.stroke();\n          // the advancing meniscus — a small bright head at the front, the\n          // one moment brighter than steady-state ink.\n          ctx.beginPath();\n          ctx.globalAlpha = Math.min(1, alpha * 1.6);\n          ctx.arc(fx, fy, strokeW * 1.15, 0, TAU);\n          ctx.fillStyle = ink;\n          ctx.fill();\n        } else {\n          ctx.moveTo(na.x, na.y);\n          ctx.lineTo(nb.x, nb.y);\n          ctx.stroke();\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\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      height = rect.height;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      buildLattice();\n      sized = true;\n    };\n\n    const warmStart = () => {\n      for (let i = 0; i < WARM_STEPS; i++) step(WARM_DT);\n    };\n\n    const loop = (now: number) => {\n      if (!visible) return;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      step(dt);\n      draw();\n      raf = requestAnimationFrame(loop);\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        resize();\n        if (!sized) return;\n        // a lattice regenerates on resize, so re-warm it before the next\n        // paint rather than showing a bare freshly-dry mesh for a beat.\n        warmStart();\n        ready = true;\n        if (reduced || paused) {\n          draw();\n        } else {\n          draw();\n          if (visible && !raf) {\n            last = 0;\n            raf = requestAnimationFrame(loop);\n          }\n        }\n      }, 150);\n    };\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 && ready && !reduced && !paused) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        } else {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (visible && ready && !reduced && !paused) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced || paused) draw();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      if (!sized) {\n        ready = true;\n        return;\n      }\n      // fixed-dt warm start so the first paint — and the reduced-motion\n      // frame — already shows multiple fronts mid-advance with visible\n      // branching, never a bare or fully-saturated lattice.\n      warmStart();\n      ready = true;\n      if (reduced || paused) {\n        draw();\n      } else {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [pitchRatio, paused]);\n\n  return (\n    <div\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nBackgroundCapillaryWick.displayName = \"BackgroundCapillaryWick\";\n",
      "type": "registry:ui",
      "target": "components/ui/background-capillary-wick.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": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "chromatography",
      "capillary",
      "lattice",
      "ink",
      "decorative"
    ],
    "instruction": "Build <BackgroundCapillaryWick pitchRatio? paused? children? className? style?> as a full-bleed Canvas 2D background modeling REAL PAPER CHROMATOGRAPHY — capillary wicking along a FIXED fibre-vein lattice — not fluid turbulence and not a diffusion field. SUBSTRATE: a jittered triangular mesh of nodes and edges is generated once per container size (hex-staggered rows, pitch clamped to the container's smaller dimension so the mesh never reads as zoomed-in on a full-bleed hero or a small preview card alike) and never reshuffled while mounted — ink can only exist ON an edge, in the order the graph connects, which is the whole distinction from dye-whorl (registry/loud/dye-whorl), a real incompressible Navier-Stokes solver advecting a continuous dye field through open fluid with no lattice, no discrete fronts and no junctions at all. PHYSICS: an active front's position along its edge advances per the Washburn capillary-rise law, dPos/dt proportional to 1/sqrt(pos) — fast off the junction, decelerating as it climbs, because a real wicking front's flow resistance grows with the wetted length behind it. On reaching the far node the edge becomes fully wet and the node STALLS for a randomized dwell before re-igniting a probabilistic subset of its still-dry outgoing edges (always at least one if any remain dry, so a junction with open dry paper never simply stops) — this stall-then-reroute at every junction is the literal mechanic asked for, not a cosmetic delay. ALIVE AT REST BY CONSTRUCTION, NOT DECORATION: a wet edge holds full intensity only briefly, then evaporates on a slower exponential decay back to fully dry, which makes it eligible for a later independent nucleation event elsewhere on the mesh — so the field is a standing turnover with no saturated end state to ever settle into, forever, not a fill-to-completion animation that happens to loop. Nucleation itself is a periodic Poisson-ish process picking a random node and igniting one of its dry neighbor edges, independent of any prior front. READING ZONE: fresh nucleation is weighted away from the container's centre and every stroke's alpha is additionally scaled by a radial coverage mask that stays low near centre (~16%) and rises toward the edges (~100%), so centred headline/CTA content sitting over it keeps a legible field underneath even while the lattice is busy at the margins — the same discipline background-halftone-rosette used for its coverage gradient, applied here as a lighter centre rather than a denser one because chromatography's own physical layout (a reservoir at the edges of a strip, ink climbing inward) makes that the honest reading. TOKENS: --foreground is the only ink color, used for both wetted strokes and the brighter advancing meniscus drawn as a small filled circle at each active front's head; --ns-muted draws the always-visible dry lattice at low fixed alpha, kept well under --border's separator contrast so the fixed veins read as texture, never as a UI seam; --background clears the canvas every frame. No --ns-accent anywhere — this is a resting ambient surface with no interaction moment, so accent chrome has no job here. All three tokens are read via getComputedStyle(document.documentElement) only after document.fonts.ready resolves, before the first paint, and re-read on a MutationObserver watching documentElement's class; every path that could draw before that first read (ResizeObserver, IntersectionObserver, the reduced-motion branch) is gated behind a ready flag so nothing paints with an empty token string. WARM START: a fluid front graph has no meaningful t=0 either — a freshly built mesh is entirely dry — so the sim runs a fixed 260 steps at a 1/30s timestep before the first paint in every mode, seeding several fronts already mid-advance with visible branching by the time anything is shown. prefers-reduced-motion (and the paused prop) render exactly that warm-started frame once and never step again — chosen specifically because it is the first moment the mesh already shows multiple fronts at different stages and real junction branching, not a bare dry mesh (t=0 proper) and not a degenerate edge case. SCALE: lattice pitch is clamped to a fraction (default 0.055) of min(width, height), and stroke width derives from pitch, so spacing and front width stay proportional at both full-bleed hero scale and a small preview-card crop. HOST: DPR-capped backing store (max 2), IntersectionObserver (threshold 0) and visibilitychange both stop the single rAF loop, ResizeObserver rebuilds and re-warms the lattice on layout change. The canvas is aria-hidden and pointer-events-none; children render as real DOM above it in normal focus order and are never intercepted by the canvas."
  },
  "type": "registry:ui"
}