{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "termite-ventilation-shafts",
  "title": "Termite Ventilation Shafts",
  "description": "A full-bleed ambient background modeling Macrotermes mound thermosiphon ventilation: a fixed network of surface ridge conduits, generated once from a central chimney and never re-grown, visibly fills and drains with flow in alternating directions over a slow 42s diurnal cycle driven by a rising and falling temperature differential.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/termite-ventilation-shafts/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TermiteVentilationShafts — a full-bleed ambient background modeling\n// Macrotermes mound ventilation: thermosiphon convection through a FIXED\n// network of surface ridge/shaft conduits (Turner 2001, \"On the mound of\n// Macrotermes michaelseni\" — the exhalent/inhalent flow model). The conduit\n// network — a chimney trunk at the container's center branching outward via\n// a recursive angle-split — is generated exactly ONCE at mount and never\n// re-grown or re-simulated. That is the load-bearing difference from the\n// space-colonization pieces (auxin-canal, forage-vein, agar-starve): those\n// regenerate or discover their network topology live; here the topology is\n// architecture, fixed the moment the mound is built, and what's alive is\n// only the flow direction and volume moving through it.\n//\n// MECHANIC: a single slow diurnal clock (42s period, a compressed day/night\n// cycle) drives a temperature differential dT(t) = sin(2*PI*t/42). dT > 0 is\n// exhale (warm air rising out through the network, flow runs trunk-to-tip);\n// dT < 0 is inhale (flow reverses, tip-to-trunk). |dT| sets flow magnitude,\n// so the network goes nearly still at the two crossover points each cycle\n// (t=0, t=21) rather than snapping between directions. Each conduit segment\n// eases its fill-fraction toward clamp(|dT| * segmentDepthFactor, 0, 1) on a\n// 2.5s time constant, with segmentDepthFactor = 1 - 0.05*generation so flow\n// visibly propagates outward from the trunk (or inward toward it) rather\n// than updating everywhere at once. Direction is legible from a fill\n// gradient alone only ambiguously, which is why a particle overlay carries\n// it explicitly: any segment whose fill-fraction exceeds 0.15 gets 4-8 dots\n// (scaled by segment length) advancing at 12px/s along the current flow\n// direction, wrapping at the segment end. For the ~1.5s window around each\n// crossover (|dT| < 0.08) particle motion pauses entirely — a real, briefly\n// still moment twice per cycle — rather than crawling through zero.\n//\n// TOKENS: conduit outlines stroke in --border (the fixed structure, never a\n// fill). Fill wash strokes the same path in --ns-muted at alpha scaled 0 to\n// ~0.4 by fill-fraction. Flow particles are small --foreground dots at low\n// alpha (raised in light theme, where the --ns-muted wash and --foreground\n// sit closer together in contrast). --ns-accent never appears — direction\n// is carried entirely by particle motion, magnitude entirely by fill-alpha,\n// never by hue, so the piece stays legible with color removed entirely.\n// ---------------------------------------------------------------------------\n\nconst CYCLE_S = 42; // diurnal period, s — the single governing clock\nconst FILL_TAU = 2.5; // s, fill-fraction ease time constant\nconst DEPTH_FACTOR_STEP = 0.05; // fill-target attenuation per generation (lag outward)\nconst PARTICLE_SPEED = 12; // px/s along a conduit\nconst STALL_THRESHOLD = 0.08; // |dT| below which particle motion fully stops\nconst FILL_GATE = 0.15; // fill-fraction above which particles render at all\n\nconst MAX_GEN = 6; // generations, trunk = 0\nconst TRUNK_COUNT = 7; // initial branches radiating from the chimney\nconst LENGTH_RATIO = 0.72; // per-generation length falloff\nconst SPREAD_MIN = (25 * Math.PI) / 180;\nconst SPREAD_MAX = (40 * Math.PI) / 180;\nconst CHILD_MIN = 2;\nconst CHILD_MAX = 3;\nconst EDGE_MARGIN = 0.46; // fraction of min(width,height)/1 — recursion stops past this radius\nconst TRUNK_LEN = 0.14; // fraction of min(width,height), generation-0 branch length\n\nconst REDUCED_FREEZE_T = 10.5; // in-cycle seconds -> dT=1, PEAK_EXHALE\n\n// mulberry32 — deterministic per-mount PRNG so the fixed network is stable\n// across re-renders within one mount (only the seed varies mount to mount).\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 Segment {\n  x0: number; // normalized coords, fraction of min(width,height), origin at container center\n  y0: number;\n  x1: number;\n  y1: number;\n  generation: number;\n  length: number; // px, filled in on resize\n  fill: number; // current eased fill-fraction, 0..1\n  particles: Float32Array; // t in [0,1] along the segment, one entry per particle\n}\n\nfunction buildNetwork(rand: () => number): Segment[] {\n  const segments: Segment[] = [];\n\n  const grow = (x: number, y: number, angle: number, length: number, generation: number) => {\n    const x1 = x + Math.cos(angle) * length;\n    const y1 = y + Math.sin(angle) * length;\n    segments.push({ x0: x, y0: y, x1, y1, generation, length: 0, fill: 0, particles: new Float32Array(0) });\n\n    const r1 = Math.hypot(x1, y1);\n    if (generation >= MAX_GEN - 1 || r1 >= EDGE_MARGIN) return;\n\n    const childCount = rand() < 0.5 ? CHILD_MIN : CHILD_MAX;\n    const spread = SPREAD_MIN + rand() * (SPREAD_MAX - SPREAD_MIN);\n    const nextLength = length * LENGTH_RATIO;\n    for (let i = 0; i < childCount; i++) {\n      const t = i / (childCount - 1) - 0.5; // -0.5..0.5\n      const jitter = (rand() - 0.5) * spread * 0.3;\n      const childAngle = angle + t * spread * 2 + jitter;\n      grow(x1, y1, childAngle, nextLength, generation + 1);\n    }\n  };\n\n  for (let i = 0; i < TRUNK_COUNT; i++) {\n    const angle = (i / TRUNK_COUNT) * Math.PI * 2 + rand() * 0.3;\n    grow(0, 0, angle, TRUNK_LEN, 0);\n  }\n\n  return segments;\n}\n\nfunction particleCountFor(px: number): number {\n  const n = Math.round(4 + (px / 90) * 4);\n  return Math.max(4, Math.min(8, n));\n}\n\nfunction parseHex(raw: string): [number, number, number] {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return [128, 128, 128];\n  let h = m[1];\n  if (h.length === 3) {\n    h = h\n      .split(\"\")\n      .map((c) => c + c)\n      .join(\"\");\n  }\n  const n = parseInt(h, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\n\nexport interface TermiteVentilationShaftsProps {\n  /** Skips mounting the canvas layer entirely; children render on the plain background. */\n  disabled?: boolean;\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function TermiteVentilationShafts({\n  disabled = false,\n  children,\n  className = \"\",\n  style,\n}: TermiteVentilationShaftsProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    if (disabled) return;\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !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    // mount mid-cycle at a random phase, not t=0, so the resting loop shows\n    // motion already in progress from the first paint\n    const rand = mulberry32((Math.random() * 0xffffffff) >>> 0);\n    const segments = buildNetwork(rand);\n    // mount phase, in-cycle seconds; reduced motion freezes on PEAK_EXHALE regardless\n    const phaseOffset = reduced ? REDUCED_FREEZE_T : rand() * CYCLE_S;\n    const startTime = performance.now() / 1000 - phaseOffset;\n\n    let disposed = false;\n    let ready = false;\n    let raf = 0;\n    let last = 0;\n\n    let width = 0;\n    let height = 0;\n    let minDim = 0;\n\n    let borderColor: [number, number, number] = [200, 200, 200];\n    let mutedColor: [number, number, number] = [128, 128, 128];\n    let fgColor: [number, number, number] = [23, 23, 23];\n    let particleAlpha = 0.5;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      borderColor = parseHex(cs.getPropertyValue(\"--border\") || \"#e5e5e5\");\n      mutedColor = parseHex(cs.getPropertyValue(\"--ns-muted\") || \"#8f8f8f\");\n      fgColor = parseHex(cs.getPropertyValue(\"--foreground\") || \"#171717\");\n      // light theme's fill/foreground contrast compresses relative to dark,\n      // so particles need a higher floor to stay distinct against the wash\n      const isDark = document.documentElement.classList.contains(\"dark\");\n      particleAlpha = isDark ? 0.5 : 0.68;\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      const w = Math.round(rect.width);\n      const h = Math.round(rect.height);\n      if (w < 2 || h < 2) return;\n      width = w;\n      height = h;\n      minDim = Math.min(w, h);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\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\n      for (const seg of segments) {\n        const px0 = width / 2 + seg.x0 * minDim;\n        const py0 = height / 2 + seg.y0 * minDim;\n        const px1 = width / 2 + seg.x1 * minDim;\n        const py1 = height / 2 + seg.y1 * minDim;\n        seg.length = Math.hypot(px1 - px0, py1 - py0);\n        const count = particleCountFor(seg.length);\n        if (seg.particles.length !== count) {\n          const next = new Float32Array(count);\n          for (let i = 0; i < count; i++) next[i] = i / count;\n          seg.particles = next;\n        }\n      }\n    };\n\n    const dTAt = (tCycle: number) => Math.sin((2 * Math.PI * tCycle) / CYCLE_S);\n\n    const step = (nowS: number, dt: number) => {\n      const tCycle = ((nowS % CYCLE_S) + CYCLE_S) % CYCLE_S;\n      const dT = dTAt(tCycle);\n      const mag = Math.abs(dT);\n      const direction = dT >= 0 ? 1 : -1; // +1 exhale/outward, -1 inhale/inward\n      const stalled = mag < STALL_THRESHOLD;\n      const easeAmount = FILL_TAU > 0 ? 1 - Math.exp(-dt / FILL_TAU) : 1;\n\n      for (const seg of segments) {\n        const depthFactor = 1 - DEPTH_FACTOR_STEP * seg.generation;\n        const target = clamp01(mag * depthFactor);\n        seg.fill += (target - seg.fill) * easeAmount;\n\n        if (seg.fill <= FILL_GATE || stalled || seg.length <= 0) continue;\n        const dtNorm = (PARTICLE_SPEED * dt) / seg.length;\n        for (let i = 0; i < seg.particles.length; i++) {\n          let t = seg.particles[i] + direction * dtNorm;\n          if (t > 1) t -= 1;\n          else if (t < 0) t += 1;\n          seg.particles[i] = t;\n        }\n      }\n    };\n\n    const render = () => {\n      ctx.clearRect(0, 0, width, height);\n      const cx = width / 2;\n      const cy = height / 2;\n\n      // pass 1: fixed outline, --border, thinner further out\n      ctx.strokeStyle = `rgb(${borderColor[0]}, ${borderColor[1]}, ${borderColor[2]})`;\n      for (const seg of segments) {\n        const w = Math.max(0.6, minDim * 0.014 * Math.pow(LENGTH_RATIO, seg.generation));\n        ctx.lineWidth = w;\n        ctx.lineCap = \"round\";\n        ctx.beginPath();\n        ctx.moveTo(cx + seg.x0 * minDim, cy + seg.y0 * minDim);\n        ctx.lineTo(cx + seg.x1 * minDim, cy + seg.y1 * minDim);\n        ctx.stroke();\n      }\n\n      // pass 2: flow fill wash, --ns-muted, alpha by fill-fraction (0..~0.4)\n      for (const seg of segments) {\n        if (seg.fill <= 0.01) continue;\n        const w = Math.max(0.8, minDim * 0.022 * Math.pow(LENGTH_RATIO, seg.generation));\n        const alpha = seg.fill * 0.4;\n        ctx.strokeStyle = `rgba(${mutedColor[0]}, ${mutedColor[1]}, ${mutedColor[2]}, ${alpha})`;\n        ctx.lineWidth = w;\n        ctx.lineCap = \"round\";\n        ctx.beginPath();\n        ctx.moveTo(cx + seg.x0 * minDim, cy + seg.y0 * minDim);\n        ctx.lineTo(cx + seg.x1 * minDim, cy + seg.y1 * minDim);\n        ctx.stroke();\n      }\n\n      // pass 3: flow particles, --foreground, low alpha\n      ctx.fillStyle = `rgba(${fgColor[0]}, ${fgColor[1]}, ${fgColor[2]}, ${particleAlpha})`;\n      const dotR = Math.max(0.9, minDim * 0.0032);\n      for (const seg of segments) {\n        if (seg.fill <= FILL_GATE) continue;\n        for (let i = 0; i < seg.particles.length; i++) {\n          const t = seg.particles[i];\n          const px = cx + (seg.x0 + (seg.x1 - seg.x0) * t) * minDim;\n          const py = cy + (seg.y0 + (seg.y1 - seg.y0) * t) * minDim;\n          ctx.beginPath();\n          ctx.arc(px, py, dotR, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n    };\n\n    const loop = (nowMs: number) => {\n      if (!ready) {\n        raf = requestAnimationFrame(loop);\n        return;\n      }\n      const nowS = nowMs / 1000;\n      const dt = last ? Math.min(0.1, nowS - last) : 1 / 60;\n      last = nowS;\n      step(nowS - startTime, dt);\n      render();\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const renderFrozenFrame = () => {\n      // one deterministic settle pass at the PEAK_EXHALE phase (fill targets\n      // fully converged, no easing lag left) so the freeze doesn't land\n      // mid-ease from whatever partial fill mount happened to start at\n      for (let i = 0; i < 40; i++) step(REDUCED_FREEZE_T, FILL_TAU);\n      render();\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced && ready) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\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 (reduced && ready) renderFrozenFrame();\n      }, 150);\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (ready) {\n        if (reduced) renderFrozenFrame();\n        else render();\n      }\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let ro: ResizeObserver | undefined;\n    let io: IntersectionObserver | undefined;\n    let visible = true;\n\n    readTokens();\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      resize();\n      ready = true;\n      if (reduced) {\n        renderFrozenFrame();\n      } else {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n      ro = new ResizeObserver(onResize);\n      ro.observe(root);\n      if (!reduced) {\n        io = new IntersectionObserver(\n          (entries) => {\n            const entry = entries[0];\n            if (!entry) return;\n            visible = entry.isIntersecting;\n            if (visible && !raf) {\n              last = 0;\n              raf = requestAnimationFrame(loop);\n            } else if (!visible && raf) {\n              cancelAnimationFrame(raf);\n              raf = 0;\n            }\n          },\n          { threshold: 0 },\n        );\n        io.observe(root);\n      }\n    });\n\n    if (!reduced) document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro?.disconnect();\n      io?.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [disabled]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative flex h-full w-full flex-col overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      {!disabled ? (\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n        />\n      ) : null}\n      {children ? <div className=\"relative z-10 flex h-full w-full flex-col\">{children}</div> : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/termite-ventilation-shafts.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",
      "canvas",
      "ventilation",
      "thermosiphon",
      "convection",
      "network",
      "ambient",
      "cyclical"
    ],
    "instruction": "Build <TermiteVentilationShafts disabled? children? className? style?> as a full-bleed Canvas 2D background modeling REAL Macrotermes mound ventilation (Turner 2001, 'On the mound of Macrotermes michaelseni', the exhalent/inhalent thermosiphon flow model) — the diurnal-cycle convective flow reversal, not fluid turbulence and not a growing network. NETWORK: generated exactly ONCE at mount via a plain recursive branch generator — 7 trunk branches radiate from the container's center (the chimney) at evenly spaced angles with mount-time jitter, each splitting into 2-3 children per node at a random ±25-40deg spread per split, child length = parent length * 0.72, down to 6 generations or until a branch's endpoint passes 46% of min(width,height) from center, whichever comes first. Coordinates are stored normalized to min(width,height) so the fixed topology re-scales cleanly on resize without ever being regenerated or re-simulated — this fixed-once network is the entire distinction from auxin-canal, forage-vein and agar-starve, which all regrow or rediscover their topology live; here only flow through an already-built structure animates. CLOCK: one governing 42s diurnal period drives dT(t) = sin(2*PI*t/42); dT>0 is exhale (flow trunk-to-tip, warm air rising out), dT<0 is inhale (flow reverses tip-to-trunk), magnitude |dT| scales both fill and particle count so the network goes visibly near-still at each of the two crossover points per cycle rather than snapping direction. FLOW RENDER: every segment eases its fill-fraction toward clamp(|dT| * segmentDepthFactor, 0, 1) on a 2.5s time constant, segmentDepthFactor = 1 - 0.05*generation so a direction change visibly propagates outward from (or inward toward) the trunk over about a second rather than updating everywhere at once. PARTICLES: any segment with fill-fraction above 0.15 carries 4-8 small dots (count scaled by the segment's actual pixel length) advancing at 12px/s along the segment in the current flow direction, wrapping at the segment end — this is the load-bearing legibility cue, since fill-fraction alone reads as magnitude but never direction. CROSSOVER STALL: for the roughly 1.5s window around each reversal (|dT| < 0.08) particle motion pauses completely — a real, observable full stop twice per 42s cycle, not a crawl through zero — while fill-fraction keeps easing normally (toward near-zero, since |dT| is small there anyway). MOUNT: the network mounts at a random phase within the 42s cycle (not t=0) so the resting loop always shows some fill and particle motion already present at first paint, with direction and magnitude depending on where the random phase landed; by t=2.5s and t=5s fill-fraction and particle density/direction have visibly shifted from that starting phase regardless of where it began. TOKENS: three passes per frame — a fixed --border stroke for the conduit outline (thinner per generation, never a fill), a --ns-muted stroke wash on top scaled 0 to ~0.4 alpha by that segment's current fill-fraction, and small --foreground dots at the particle positions, alpha 0.5 in dark theme and raised to 0.68 in light theme (detected via the documentElement 'dark' class) since light theme's fill/foreground contrast compresses and a lower fixed alpha reads as noise there. All three 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; nothing paints before that first read. --ns-accent never appears anywhere — direction is carried entirely by particle motion, magnitude entirely by fill-alpha, never by hue, so removing color leaves the piece exactly as legible. REDUCED MOTION: freezes on PEAK_EXHALE (dT=1, t=10.5 in-cycle) — chosen over the crossover-stall frame because it's the state that most clearly shows the network actively carrying flow rather than the one moment it's genuinely empty — reached via 40 deterministic step() calls at dt=2.5s (the fill time constant) so every segment's fill-fraction has fully converged to its target with no easing lag, then rendered once; the resize and MutationObserver paths under reduced motion re-run that same convergence pass rather than resuming a loop. HOST: DPR-capped(2) backing store, IntersectionObserver (threshold 0) pauses/resumes the single rAF loop alongside visibilitychange, ResizeObserver re-derives pixel geometry (never the topology) and re-buckets particle counts on layout change, everything torn down on unmount (cancelAnimationFrame, all three observers disconnected). The canvas is aria-hidden and pointer-events-none; children render as real DOM above it in normal focus order."
  },
  "type": "registry:ui"
}