{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interlace-field-comb",
  "title": "Interlace Field Comb",
  "description": "A full-bleed background reproducing analog interlaced video's defining artifact: two temporally-offset fields drawn a beat apart, disagreeing on a moving diagonal edge and serrating it into a comb, then periodically re-weaving into one clean frame.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/interlace-field-comb/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// InterlaceFieldComb — a full-bleed background reproducing analog interlaced\n// video's defining artifact: combing. Interlaced NTSC/PAL video draws a frame\n// as two temporally-offset fields — odd scanlines, then even scanlines, each\n// a separate instant. A still frame \"weaves\" the two fields into one clean\n// picture; anything that moved BETWEEN the two field captures makes the odd\n// and even rows disagree on where an edge sits, and a diagonal edge serrates\n// into a visible horizontal comb. That is the mechanic here: content is drawn\n// twice, once per field, with a small relative horizontal offset between the\n// two draws that grows and shrinks over time, and the composite interleaves\n// real device-pixel rows from each field's render — not a filter simulating\n// the look, an actual two-buffer interleave.\n//\n// This is deliberately distinct from two already-shipped CRT artifacts on\n// this registry: flyback-tear is signal/sync LOSS (rolling hold, tear bursts,\n// dropout, snow) and rolling-shutter-skew is a CMOS sensor's per-row READOUT\n// skew. Combing is neither — it is two co-owned, temporally offset scanline\n// sets disagreeing on a moving edge, which only reads correctly when the two\n// fields are genuinely composited from separate renders, not derived from one\n// buffer with a wobble applied.\n//\n// DECOUPLING (round 9 note): a real interlaced field rate sits near 50-60Hz,\n// which would alias against the page's own 60Hz paint and read as a glitch,\n// not as interlacing. So the divergence between fields is driven by a slow,\n// deliberately visualized 4.0s cycle instead of any real field rate — see\n// PERIOD below — and the field-pair render itself is throttled well under\n// 60Hz (DRAW_HZ) so the two-buffer interleave cost stays cheap.\n//\n// CONTENT: three long, soft-edged diagonal bands (getting their softness from\n// ctx.filter blur on a plain rotated fillRect, the same technique already\n// used by ring-graze/agar-starve in this registry) drifting slowly and\n// wrapping. Diagonal, not horizontal, on purpose — a horizontal edge sitting\n// exactly on a scanline boundary would show no comb at all; the bands need a\n// real diagonal so a horizontal misalignment between fields visibly staggers\n// their edges into steps.\n//\n// SUBSTRATE: 2D canvas. Two off-DOM canvases hold one field's render each;\n// the composite interleaves their pixel rows in groups of PITCH device rows\n// (derived from the container's smaller dimension) via ImageData.set() row\n// copies, then a single putImageData onto the visible canvas. No WebGL.\n// ---------------------------------------------------------------------------\n\ninterface Band {\n  angleDeg: number; // direction the band's LENGTH runs\n  thicknessFrac: number; // of min(W, H)\n  speedMul: number; // multiplier on DRIFT_SPEED, travel perpendicular to length\n  phaseFrac: number; // 0..1, initial position along the wrap range\n  tone: \"fg\" | \"muted\";\n  alpha: number;\n}\n\nconst BANDS: Band[] = [\n  { angleDeg: 24, thicknessFrac: 0.16, speedMul: 1, phaseFrac: 0.1, tone: \"fg\", alpha: 0.5 },\n  { angleDeg: -16, thicknessFrac: 0.11, speedMul: 0.7, phaseFrac: 0.55, tone: \"muted\", alpha: 0.42 },\n  { angleDeg: 38, thicknessFrac: 0.08, speedMul: 1.3, phaseFrac: 0.82, tone: \"fg\", alpha: 0.3 },\n];\n\nconst DRIFT_SPEED = 6; // css px/s, perpendicular travel of each band\nconst PERIOD = 4.0; // s — the re-weave cycle: divergence returns to ~0 every PERIOD\nconst DRAW_HZ = 30; // the field-pair render + interleave is throttled to this, not 60Hz\nconst DRAW_INTERVAL_MS = 1000 / DRAW_HZ;\n\n// The one instant the two fields fully agree — divergence(t) hits exactly 0\n// at t = PERIOD / 2 with the phase chosen below (max divergence sits at t=0\n// instead, matching the resting loop's t0 read). This is also the most\n// structured frame: the underlying bands are visible with zero comb, which\n// reduced-motion freezes on rather than the mid-divergence t=0 state.\nconst STATIC_TIME = PERIOD / 2;\n\nfunction divergencePx(t: number, maxOffset: number): number {\n  return (maxOffset * (1 + Math.cos((2 * Math.PI * t) / PERIOD))) / 2;\n}\n\nfunction drawField(\n  ctx: CanvasRenderingContext2D,\n  w: number,\n  h: number,\n  t: number,\n  xOffset: number,\n  fg: string,\n  muted: string\n) {\n  ctx.clearRect(0, 0, w, h);\n  const diag = Math.hypot(w, h);\n  const minDim = Math.min(w, h);\n  for (const b of BANDS) {\n    const wrapRange = diag * 1.3;\n    const travel = ((t * DRIFT_SPEED * b.speedMul + b.phaseFrac * wrapRange) % wrapRange + wrapRange) % wrapRange;\n    const pos = travel - diag * 0.15;\n    const angle = (b.angleDeg * Math.PI) / 180;\n    // perpendicular unit vector — this is the axis the band travels along\n    const perpX = Math.cos(angle + Math.PI / 2);\n    const perpY = Math.sin(angle + Math.PI / 2);\n    const cx = w / 2 + perpX * (pos - diag / 2) + xOffset;\n    const cy = h / 2 + perpY * (pos - diag / 2);\n    const thickness = b.thicknessFrac * minDim;\n    ctx.save();\n    ctx.translate(cx, cy);\n    ctx.rotate(angle);\n    ctx.filter = `blur(${(thickness * 0.55).toFixed(1)}px)`;\n    ctx.fillStyle = b.tone === \"fg\" ? fg : muted;\n    ctx.globalAlpha = b.alpha;\n    ctx.fillRect(-diag * 0.7, -thickness / 2, diag * 1.4, thickness);\n    ctx.restore();\n  }\n  ctx.filter = \"none\";\n  ctx.globalAlpha = 1;\n}\n\nexport interface InterlaceFieldCombProps {\n  /** headline / CTA rendered over the field */\n  children?: ReactNode;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\nexport function InterlaceFieldComb({ children, className = \"\", style }: InterlaceFieldCombProps) {\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 oddCanvas = document.createElement(\"canvas\");\n    const evenCanvas = document.createElement(\"canvas\");\n    const oddCtx = oddCanvas.getContext(\"2d\", { willReadFrequently: true });\n    const evenCtx = evenCanvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!oddCtx || !evenCtx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // token fields start empty; nothing paints until readTokens() has run —\n    // guarded in draw() below, closing every path (rAF, resize, the reduced\n    // branch) that could otherwise paint a literal.\n    let fgColor = \"\";\n    let mutedColor = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fgColor = cs.getPropertyValue(\"--foreground\").trim();\n      mutedColor = cs.getPropertyValue(\"--ns-muted\").trim();\n    };\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let pitchDev = 1; // device rows per field line\n    let maxOffsetCss = 1;\n    let sized = false;\n    let visible = true;\n    let raf = 0;\n    let t = reduced ? STATIC_TIME : 0;\n    let last = 0;\n    let lastDrawMs = -Infinity;\n    let composite: ImageData | null = null;\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n      const pw = Math.max(1, Math.round(w * dpr));\n      const ph = Math.max(1, Math.round(h * dpr));\n      canvas.width = pw;\n      canvas.height = ph;\n      oddCanvas.width = pw;\n      oddCanvas.height = ph;\n      evenCanvas.width = pw;\n      evenCanvas.height = ph;\n      oddCtx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      evenCtx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      const minDim = Math.min(w, h);\n      const pitchCss = Math.max(1, Math.min(3, Math.round(minDim / 260)));\n      pitchDev = Math.max(1, Math.round(pitchCss * dpr));\n      maxOffsetCss = pitchCss * 2;\n      composite = new ImageData(pw, ph);\n      sized = true;\n      lastDrawMs = -Infinity; // force an immediate redraw at the new size\n    };\n\n    const draw = () => {\n      if (!sized || !fgColor || !composite) return;\n      const pw = canvas.width;\n      const ph = canvas.height;\n      const div = divergencePx(t, maxOffsetCss);\n      drawField(oddCtx, w, h, t, div / 2, fgColor, mutedColor);\n      drawField(evenCtx, w, h, t, -div / 2, fgColor, mutedColor);\n      const oddData = oddCtx.getImageData(0, 0, pw, ph).data;\n      const evenData = evenCtx.getImageData(0, 0, pw, ph).data;\n      const out = composite.data;\n      const rowBytes = pw * 4;\n      for (let y = 0; y < ph; y++) {\n        const scan = Math.floor(y / pitchDev);\n        const src = scan % 2 === 0 ? oddData : evenData;\n        const start = y * rowBytes;\n        out.set(src.subarray(start, start + rowBytes), start);\n      }\n      ctx.putImageData(composite, 0, 0);\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      if (now - lastDrawMs >= DRAW_INTERVAL_MS) {\n        lastDrawMs = now;\n        draw();\n      }\n      if (visible && !document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const ro = new ResizeObserver(() => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) draw();\n      }, 120);\n    });\n    ro.observe(root);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && !reduced && !raf) {\n          last = 0;\n          raf = requestAnimationFrame(loop);\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(root);\n\n    const onVis = () => {\n      if (!document.hidden && visible && !reduced && !raf) {\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 || !raf) draw();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    readTokens();\n    resize();\n\n    if (reduced) {\n      draw();\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden className=\"pointer-events-none absolute inset-0 block h-full w-full\" />\n      {children ? (\n        <div className=\"relative z-10 flex min-h-screen w-full flex-col items-center justify-center gap-4 px-6 text-center\">\n          {children}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nInterlaceFieldComb.displayName = \"InterlaceFieldComb\";\n",
      "type": "registry:ui",
      "target": "components/ui/interlace-field-comb.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "hero",
      "canvas",
      "crt",
      "interlace",
      "scanline",
      "monochrome"
    ],
    "instruction": "Build <InterlaceFieldComb children? className? style?> as a full-bleed 2D-canvas background reproducing analog interlaced video's combing artifact. THE MECHANIC: interlaced NTSC/PAL video draws a frame as two temporally-offset fields, odd scanlines then even scanlines, each a separate captured instant. A still frame weaves the two fields into one clean picture; anything that moved between the two field captures makes odd and even rows disagree on where an edge sits, so a diagonal edge serrates into a visible horizontal comb. This is reproduced literally, not simulated with a filter: content is rendered TWICE into two off-DOM canvases (one per field) with a small relative horizontal offset between the two renders, then the visible canvas is built by copying real device-pixel ROWS from each field's buffer in alternating groups of PITCH rows (pitch derived from the container's smaller dimension, clamp(round(minDim/260),1,3) css px, converted to device px) via ImageData row copies and a single putImageData — an actual two-buffer interleave, not a wobble on one buffer. THE OFFSET BETWEEN FIELDS is driven by divergence(t) = maxOffset * (1 + cos(2*PI*t/PERIOD)) / 2 with PERIOD = 4.0s and maxOffset = pitchCss * 2 px; the odd field gets +divergence/2 horizontal offset, the even field gets -divergence/2. This is a DELIBERATELY DECOUPLED, slow visualized cycle (round 9 lesson) rather than a literal 50/60Hz field rate, which would alias against the page's own paint rate and read as a glitch rather than as interlacing. At t=0 divergence is at its maximum (visible comb); it returns to exactly 0 at t = PERIOD/2 = 2.0s, the moment of full re-weave, and repeats every PERIOD after that. CONTENT is three long, soft-edged diagonal bands (angles 24deg, -16deg, 38deg; NOT horizontal — a horizontal edge sitting on a scanline boundary shows no comb at all, so the bands must run diagonally for a horizontal misalignment to visibly stagger their edges into steps), each a rotated ctx.filter-blurred fillRect drifting perpendicular to its own length at 6 css px/s * a per-band speed multiplier, wrapping modulo 1.3x the canvas diagonal so no seam is visible. Softness comes from ctx.filter = blur(thickness*0.55px) on a plain fillRect, the same technique already used by ring-graze/agar-starve in this registry — no gradients needed. THROTTLING: the full field-pair render + interleave (the expensive part: two off-DOM canvas draws, two getImageData calls, a row-copy interleave, one putImageData) is throttled to 30Hz (DRAW_INTERVAL_MS = 1000/30) inside the rAF loop, not run every frame — the simulation clock itself still advances every rAF tick so DRAW_HZ throttling never desyncs the divergence curve from wall time. PALETTE: --foreground and --ns-muted read via getComputedStyle(document.documentElement), re-read on a documentElement class MutationObserver; bands alternate between the two tones (alpha 0.3-0.5) with no accent anywhere — the mechanic has no pointer interaction to justify one. HOST: DPR capped at 1.5 (this component's per-frame cost is two full-canvas raster ops plus row copies, not a single shader pass, so it stays conservative like weld-pool rather than flyback-tear's uncapped 2). ResizeObserver (120ms debounce) reallocates all three canvases (visible + both off-DOM field buffers) and recomputes pitch/maxOffset for the new size; IntersectionObserver (threshold 0) and visibilitychange both pause the rAF loop, since a full-bleed per-frame raster op running offscreen is the most expensive idle thing the page can carry. prefers-reduced-motion freezes the clock at STATIC_TIME = PERIOD/2 = 2.0s — the exact re-weave instant, chosen because it is the most structured frame (the three bands fully visible with zero comb) rather than t=0's already-diverged state. No colour literals anywhere. No pointer interaction of any kind — the mechanic is a fixed timing artifact, not a hover effect, and the showpiece recipe's accent-highlight failure mode does not apply because nothing here is pointer-driven."
  },
  "type": "registry:ui"
}