{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "offset-fountain-split",
  "title": "Offset Fountain Split",
  "description": "A full-bleed ambient background modeling a web-offset press ink train: ink splits and smears across six rollers from fountain to form, driven by 24 independently drifting fountain-key zones so the field's ribbed banding never converges flat.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/offset-fountain-split/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// OffsetFountainSplit — a full-bleed ambient background modeling a web-offset\n// press ink train: ink drawn from a fountain reservoir passes through a chain\n// of 6 rollers (fountain -> ductor -> 4 oscillating distributors -> form),\n// splitting into a thinner film at every nip (value(x,i) = 0.5*value(x,i-1)\n// + 0.5*value(x,i-1) sampled at a laterally-oscillating offset) so each stage\n// both halves-and-recombines AND smears sideways. The fountain roller itself\n// never holds still: it tracks 24 discrete \"fountain key\" zones across the\n// width, each drifting on its own slow sine (a real press operator's key\n// settings are never perfectly stable), which is what keeps the whole train\n// from ever converging to a flat field — the visible symptom real pressmen\n// call \"ribbing\", fought with exactly this roller train.\n//\n// Only the FINAL (form-roller) field is painted, as vertical full-height\n// ink-density strips: --foreground at an alpha proportional to that band's\n// value, over the --background the wrapper already shows. No canvas fill of\n// background — nothing paints before the token read below, and the wrapper's\n// own bg-background class covers the frame regardless.\n//\n// Every roller's array persists across frames and relaxes toward its target\n// with a 180ms time constant, so a fountain-key shift visibly ripples down\n// the train stage by stage instead of snapping instantly.\n// ---------------------------------------------------------------------------\n\nconst ROLLER_COUNT = 6; // 0 fountain, 1-4 distributors (oscillate), 5 form\nconst OSC_START = 1;\nconst OSC_END = 4; // inclusive — bands 0 and 5 never oscillate\nconst ZONE_COUNT = 24;\nconst ZONE_BASE = 0.5;\nconst ZONE_DRIFT_PERIOD = 40; // s\nconst ZONE_DRIFT_AMPLITUDE = 0.18;\nconst RELAX_TAU = 0.18; // s, per-band relaxation constant\nconst OSC_PERIOD = 2.6; // s, distributor oscillation\nconst OSC_AMPLITUDE_PX = 14;\nconst NUDGE_BOOST = 0.1;\nconst NUDGE_DECAY = 1.2; // s, linear decay back to 0\nconst FREEZE_T = 8.4; // s — chosen phase for the reduced-motion frame\nconst COL_PITCH_MIN = 3;\nconst COL_PITCH_MAX = 10; // px — derived from container's smaller dimension\nconst N_MIN = 256;\nconst N_MAX = 720; // sample count clamp\nconst MAX_ALPHA_DARK = 0.85;\nconst MAX_ALPHA_LIGHT = 0.75;\nconst DPR_CAP = 1.5;\nconst RESIZE_DEBOUNCE_MS = 150;\n\nfunction clamp(x: number, lo: number, hi: number) {\n  return x < lo ? lo : x > hi ? hi : x;\n}\nfunction lerp(a: number, b: number, t: number) {\n  return a + (b - a) * t;\n}\n\n// relative luminance of a `--token` value read as a hex string; returns null\n// if the token isn't a plain hex color (e.g. inherits `currentColor`).\nfunction hexLuminance(raw: string): number | null {\n  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());\n  if (!m) return null;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  const r = ((n >> 16) & 255) / 255;\n  const g = ((n >> 8) & 255) / 255;\n  const b = (n & 255) / 255;\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\nexport interface OffsetFountainSplitProps {\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function OffsetFountainSplit({\n  className = \"\",\n  style,\n}: OffsetFountainSplitProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\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(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let sized = false;\n    let visible = false;\n\n    let fg = \"currentColor\";\n    let maxAlpha = MAX_ALPHA_DARK;\n\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = cs.getPropertyValue(\"--foreground\").trim() || fg;\n      const bgRaw = cs.getPropertyValue(\"--background\").trim();\n      const fl = hexLuminance(fg);\n      const bl = hexLuminance(bgRaw);\n      // background lighter than foreground => light theme, cap alpha lower\n      // (dark ink on a light ground reads heavier at equal alpha).\n      maxAlpha =\n        fl != null && bl != null && bl > fl ? MAX_ALPHA_LIGHT : MAX_ALPHA_DARK;\n    };\n\n    let width = 0;\n    let height = 0;\n    let colPitch = COL_PITCH_MIN;\n    let n = N_MIN;\n\n    let bands: Float32Array[] = [];\n    const zonePhase = new Float32Array(ZONE_COUNT);\n    const zoneBoost = new Float32Array(ZONE_COUNT);\n    for (let z = 0; z < ZONE_COUNT; z++) zonePhase[z] = z * 0.9137;\n\n    const initBands = () => {\n      bands = [];\n      for (let i = 0; i < ROLLER_COUNT; i++) bands.push(new Float32Array(n));\n      zoneBoost.fill(0);\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) {\n        sized = false;\n        return;\n      }\n      width = w;\n      height = h;\n      colPitch = clamp(Math.min(w, h) / 90, COL_PITCH_MIN, COL_PITCH_MAX);\n      n = clamp(Math.ceil(w / colPitch), N_MIN, N_MAX);\n      colPitch = w / n; // exact pitch so columns tile the width with no gap\n      const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);\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      initBands();\n      sized = true;\n    };\n\n    // stepped fountain-key field: 24 discrete zones, each drifting on its\n    // own slow sine plus a decaying pointer \"key tweak\" boost.\n    const zoneTargetAt = (xSample: number, t: number) => {\n      const zoneW = n / ZONE_COUNT;\n      const z = clamp(Math.floor(xSample / zoneW), 0, ZONE_COUNT - 1);\n      const drift =\n        ZONE_DRIFT_AMPLITUDE *\n        Math.sin((2 * Math.PI * t) / ZONE_DRIFT_PERIOD + zonePhase[z]);\n      return clamp(ZONE_BASE + drift + zoneBoost[z], 0, 1);\n    };\n\n    const cascade = (t: number, relax: number, write: Float32Array[]) => {\n      const b0 = write[0];\n      for (let x = 0; x < n; x++) {\n        const target = zoneTargetAt(x, t);\n        b0[x] += (target - b0[x]) * relax;\n      }\n      for (let i = 1; i < ROLLER_COUNT; i++) {\n        const prev = write[i - 1];\n        const cur = write[i];\n        const oscillating = i >= OSC_START && i <= OSC_END;\n        const offsetPx = oscillating\n          ? OSC_AMPLITUDE_PX * Math.sin((2 * Math.PI * t) / OSC_PERIOD + i * 0.7)\n          : 0;\n        const offsetSamples = offsetPx / colPitch;\n        for (let x = 0; x < n; x++) {\n          let xs = x - offsetSamples;\n          if (xs < 0) xs = 0;\n          else if (xs > n - 1) xs = n - 1;\n          const x0 = Math.floor(xs);\n          const x1 = Math.min(n - 1, x0 + 1);\n          const shifted = lerp(prev[x0], prev[x1], xs - x0);\n          const target = 0.5 * prev[x] + 0.5 * shifted;\n          cur[x] += (target - cur[x]) * relax;\n        }\n      }\n    };\n\n    const paintField = (field: Float32Array) => {\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = fg;\n      for (let x = 0; x < n; x++) {\n        const alpha = clamp(field[x], 0, 1) * maxAlpha;\n        if (alpha <= 0.002) continue;\n        ctx.globalAlpha = alpha;\n        ctx.fillRect(x * colPitch, 0, colPitch + 1, height);\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const drawFrame = () => {\n      if (!sized) return;\n      paintField(bands[ROLLER_COUNT - 1]);\n    };\n\n    // reduced-motion: one static frame at a fixed sim-time, computed as if\n    // every band had already fully relaxed to its instantaneous target\n    // (relax = 1) rather than replaying the transient history — a legible\n    // \"process caught mid-run\" frame with no rAF loop and no listeners.\n    const drawStaticFreeze = () => {\n      if (!sized) return;\n      const tmp: Float32Array[] = [];\n      for (let i = 0; i < ROLLER_COUNT; i++) tmp.push(new Float32Array(n));\n      cascade(FREEZE_T, 1, tmp);\n      paintField(tmp[ROLLER_COUNT - 1]);\n    };\n\n    let raf = 0;\n    let last = 0;\n    let simT = 0;\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      simT += dt;\n      cascade(simT, 1 - Math.exp(-dt / RELAX_TAU), bands);\n      for (let z = 0; z < ZONE_COUNT; z++) {\n        if (zoneBoost[z] > 0) {\n          zoneBoost[z] = Math.max(\n            0,\n            zoneBoost[z] - (NUDGE_BOOST / NUDGE_DECAY) * dt\n          );\n        }\n      }\n      drawFrame();\n      raf = !document.hidden && visible ? requestAnimationFrame(loop) : 0;\n    };\n\n    const startLoop = () => {\n      if (raf || reduced || document.hidden || !visible || !sized) return;\n      last = 0;\n      raf = requestAnimationFrame(loop);\n    };\n    const stopLoop = () => {\n      if (raf) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (reduced || !sized) return;\n      const rect = canvas.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n      if (x < 0 || x > rect.width || y < 0 || y > rect.height) return;\n      const xs = x / colPitch;\n      const zoneW = n / ZONE_COUNT;\n      const z = clamp(Math.floor(xs / zoneW), 0, ZONE_COUNT - 1);\n      zoneBoost[z] = NUDGE_BOOST;\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) drawStaticFreeze();\n      }, RESIZE_DEBOUNCE_MS);\n    };\n\n    readTokens();\n    resize();\n    if (reduced) {\n      drawStaticFreeze();\n    }\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) drawStaticFreeze();\n      else if (sized && !raf) drawFrame();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const ro = new ResizeObserver(onResize);\n    ro.observe(root);\n\n    let io: IntersectionObserver | undefined;\n    const onVis = () => {\n      if (document.hidden) stopLoop();\n      else startLoop();\n    };\n\n    if (!reduced) {\n      io = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            visible = entry.isIntersecting;\n            if (visible) startLoop();\n            else stopLoop();\n          }\n        },\n        { threshold: 0 }\n      );\n      io.observe(root);\n      document.addEventListener(\"visibilitychange\", onVis);\n      window.addEventListener(\"pointermove\", onPointerMove);\n    }\n\n    return () => {\n      stopLoop();\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      ro.disconnect();\n      io?.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative h-full w-full overflow-hidden bg-background ${\n        /\\bmin-h-/.test(className) ? \"\" : \"min-h-screen\"\n      } ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/offset-fountain-split.tsx"
    }
  ],
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "print",
      "ink",
      "canvas",
      "ambient",
      "offset",
      "monochrome"
    ],
    "instruction": "Build <OffsetFountainSplit className? style?> as a full-bleed, aria-hidden, pointer-events-transparent 2D canvas background (no children wrapping — callers overlay their own content in a sibling element, matching the background-gradient-shader pattern). Model a web-offset ink train as 6 roller stages (index 0 fountain, 1-4 oscillating distributors, 5 form), each stage a Float32Array of N samples (N = clamp(ceil(width / colPitch), 256, 720); colPitch = clamp(min(width,height)/90, 3, 10)px, derived from the container's smaller dimension). Stage 0's per-sample TARGET comes from 24 discrete fountain-key zones spanning the width (zoneWidth = N/24, one stepped target per zone, not interpolated — a real fountain key is discrete), each zone drifting on its own sine: 0.5 + 0.18*sin(2*PI*t/40 + zonePhase[z]), zonePhase[z] = z*0.9137 for a non-repeating spread across zones. This 40s drift is what keeps the whole train from ever converging flat. For stages 1-5, each sample's TARGET is 0.5*prev[x] + 0.5*prev[x sampled at a laterally shifted, linearly-interpolated position] — an ink-splitting halve-and-recombine at every nip, with the shift only applied on stages 1-4 (distributors): offsetPx = 14*sin(2*PI*t/2.6 + stageIndex*0.7), converted to samples via /colPitch; stages 0 and 5 (fountain, form) never oscillate, offset = 0. Every stage's array RELAXES toward its target rather than snapping: value += (target - value) * (1 - exp(-dt/0.18)), a 180ms per-stage time constant, so a fountain-key shift visibly ripples down the train stage by stage over multiple frames rather than teleporting. Only the final (form-roller, index 5) array is painted, as full-height vertical strips: ctx.fillStyle = the --foreground token (read raw as a CSS color string via getComputedStyle(document.documentElement).getPropertyValue('--foreground'), re-read on a MutationObserver watching documentElement's class), globalAlpha = clamp(value,0,1) * maxAlpha per strip, strip width = exact colPitch (recomputed as width/N so columns tile with no gap), no background fill — the wrapper's own bg-background class supplies the ground, so nothing paints before the token read. maxAlpha is chosen by comparing the relative luminance (0.2126r+0.7152g+0.0722b from a parsed #hex) of --foreground against --background: 0.75 when background is the lighter token (light theme, where equal alpha reads heavier), 0.85 otherwise (dark theme) — this comparison, not a hardcoded theme guess, is what the token rule requires. Pointer interaction: a window-level pointermove listener (filtered against the canvas's own getBoundingClientRect so it only reacts inside the frame) finds the nearest fountain-key zone under the cursor's x and sets that zone's zoneBoost to 0.1, added directly into the zone's target for one relaxation pass; zoneBoost then linearly decays back to 0 over 1.2s ((0.1/1.2)*dt per frame) whether or not the pointer is still present. This is a luminance-only 'key tweak' nudge — never mix --ns-accent into it, and it must leave no permanent trace once decayed. Canvas backing store is DPR-capped at 1.5, resized via a 150ms-debounced ResizeObserver on the root (each resize reallocates and zeroes all 6 stage arrays — state loss on resize is acceptable). The rAF loop only runs while an IntersectionObserver (threshold 0) on the root reports intersecting AND document.visibilityState is visible; it is started/stopped from both the IntersectionObserver callback and a visibilitychange listener, and is never started at all under prefers-reduced-motion. prefers-reduced-motion instead renders exactly one static frame: the same 6-stage cascade computed once at a fixed sim-time of 8.4s with an idealized relax factor of 1 (each stage snaps straight to its instantaneous target rather than replaying the transient history) — no rAF loop, no pointer listener, no IntersectionObserver, just a direct recomputation on token-change too (the MutationObserver callback re-invokes this same static draw when reduced-motion is active). Clean up on unmount: cancel any pending rAF, clear the resize debounce timer, and disconnect the MutationObserver, ResizeObserver, and IntersectionObserver (the last only exists when motion is not reduced). Props: className, style — both merged onto the root; a `min-h-*` class in className suppresses the default `min-h-screen` the same way the sibling backgrounds do."
  },
  "type": "registry:ui"
}