{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-truchet-weave",
  "title": "Background Truchet Weave",
  "description": "An ambient Truchet arc weave whose tile orientation is the SIGN of a slow harmonic field, so the frame reads as interlaced domains of continuous arcs separated by pale seams along the domain walls, and the pointer bends the field so the walls re-route outward from the cursor and retreat when it leaves.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/background-truchet-weave/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TruchetWeave — an ambient Truchet arc weave. Every cell carries one of two\n// quarter-arc tiles, and both tilings are edge-compatible (each cell edge is\n// crossed at its midpoint), so the arcs join across every boundary into one\n// continuous interlaced curve — the classic Truchet \"maze of loops\". What\n// picks the tile is not a coin flip but the SIGN of a slow three-term harmonic\n// field, so the frame organises into large soft domains of consistent\n// orientation. Sparsity is the other half: stroke alpha falls off with the\n// field's DISTANCE FROM ZERO, so cells sitting on the zero set — the domain\n// walls, exactly where the orientation is about to flip — fade out entirely\n// and the weave reads as torn open along pale seams rather than as a uniform\n// mat. The pointer adds a gaussian bias to the field BEFORE the sign test, so\n// cells with small |f| flip first: a new domain wall opens as a ring around\n// the cursor and re-routes the arcs outward from it, then retreats when the\n// pointer leaves.\n// ---------------------------------------------------------------------------\n\nconst ALPHA_BUCKETS = 5;\nconst MIN_ALPHA = 0.06; // below this a cell is skipped entirely\nconst FADE_SCALE = 1.2; // |f| at which a cell reaches full ink\nconst FADE_POW = 1.6; // steepness of the seam falloff\nconst ANGLE_A = 0.42; // rad — first harmonic rotation\nconst ANGLE_B = 1.93; // rad — second harmonic rotation\nconst BIAS_RADIUS = 5.5; // cells — gaussian sigma of the pointer bias\nconst BIAS_PEAK = 1.35; // peak added to the field under the pointer\nconst BIAS_TAU = 0.5; // s — ease-in / relax-back time constant\nconst BIAS_CUT2 = (BIAS_RADIUS * 4) * (BIAS_RADIUS * 4);\nconst LINE_WIDTH = 1.25;\nconst DT_MAX = 1 / 30;\n\nconst COS_A = Math.cos(ANGLE_A);\nconst SIN_A = Math.sin(ANGLE_A);\nconst SIN_B = Math.sin(ANGLE_B);\nconst COS_B = Math.cos(ANGLE_B);\n\nconst HALF_PI = Math.PI / 2;\n\n/** Three-term harmonic sum evaluated at two fixed rotation angles. */\nfunction field(cx: number, cy: number, t: number): number {\n  const u = cx * COS_A + cy * SIN_A;\n  const v = -cx * SIN_B + cy * COS_B;\n  return (\n    Math.sin(u * 0.26 + 0.11 * t) +\n    0.7 * Math.sin(v * 0.19 - 0.09 * t) +\n    0.45 * Math.sin((u + v) * 0.11 + 0.05 * t)\n  );\n}\n\nexport interface TruchetWeaveProps {\n  /** square tile size in px */\n  tileSize?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function TruchetWeave({\n  tileSize = 28,\n  className = \"\",\n}: TruchetWeaveProps) {\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(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    const TILE = Math.max(8, tileSize);\n    const R = TILE / 2;\n\n    let fg = \"currentColor\";\n    let cols = 0;\n    let rows = 0;\n    let sized = false;\n    let disposed = false;\n\n    // per-bucket cell-index lists, reused every frame\n    const bucketLists: number[][] = Array.from(\n      { length: ALPHA_BUCKETS },\n      () => []\n    );\n\n    const readTokens = () => {\n      fg = getComputedStyle(canvas).color;\n    };\n\n    const resize = () => {\n      const { width, height } = canvas.getBoundingClientRect();\n      if (width < 4 || height < 4) {\n        sized = false;\n        return;\n      }\n      const 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      cols = Math.max(2, Math.ceil(width / TILE));\n      rows = Math.max(2, Math.ceil(height / TILE));\n      sized = true;\n    };\n\n    const draw = (t: number, px: number, py: number, strength: number) => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, cols * TILE + TILE, rows * TILE + TILE);\n\n      for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;\n\n      const sigma2 = 2 * BIAS_RADIUS * BIAS_RADIUS;\n      const biased = strength > 0.01;\n\n      // pass one — classify every cell into an alpha bucket\n      let i = 0;\n      for (let gy = 0; gy < rows; gy++) {\n        for (let gx = 0; gx < cols; gx++, i++) {\n          const cx = gx + 0.5;\n          const cy = gy + 0.5;\n          let f = field(cx, cy, t);\n          if (biased) {\n            const dx = cx - px;\n            const dy = cy - py;\n            const d2 = dx * dx + dy * dy;\n            if (d2 < BIAS_CUT2) {\n              f += BIAS_PEAK * strength * Math.exp(-d2 / sigma2);\n            }\n          }\n          const a = Math.pow(\n            Math.min(1, Math.abs(f) / FADE_SCALE),\n            FADE_POW\n          );\n          if (a < MIN_ALPHA) continue;\n          const b = Math.min(ALPHA_BUCKETS - 1, Math.floor(a * ALPHA_BUCKETS));\n          // sign of the field picks the tile; pack it into the stored index\n          bucketLists[b]!.push(f > 0 ? i : -i - 1);\n        }\n      }\n\n      // pass two — one path, one globalAlpha write and one stroke per bucket\n      ctx.strokeStyle = fg;\n      ctx.lineWidth = LINE_WIDTH;\n      ctx.lineCap = \"butt\";\n      for (let b = 0; b < ALPHA_BUCKETS; b++) {\n        const list = bucketLists[b]!;\n        if (list.length === 0) continue;\n        ctx.beginPath();\n        for (let k = 0; k < list.length; k++) {\n          const packed = list[k]!;\n          const bit = packed >= 0;\n          const idx = bit ? packed : -packed - 1;\n          const gx = idx % cols;\n          const gy = (idx - gx) / cols;\n          const x = gx * TILE;\n          const y = gy * TILE;\n          if (bit) {\n            // tile A — arcs centred on the top-left and bottom-right corners\n            ctx.moveTo(x + R, y);\n            ctx.arc(x, y, R, 0, HALF_PI);\n            ctx.moveTo(x + TILE - R, y + TILE);\n            ctx.arc(x + TILE, y + TILE, R, Math.PI, Math.PI + HALF_PI);\n          } else {\n            // tile B — arcs centred on the top-right and bottom-left corners\n            ctx.moveTo(x + TILE, y + R);\n            ctx.arc(x + TILE, y, R, HALF_PI, Math.PI);\n            ctx.moveTo(x, y + TILE - R);\n            ctx.arc(x, y + TILE, R, -HALF_PI, 0);\n          }\n        }\n        ctx.globalAlpha = 0.1 + (b / (ALPHA_BUCKETS - 1)) * 0.62;\n        ctx.stroke();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // -- hot-path state -------------------------------------------------------\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const ptr = { x: -1e5, y: -1e5, has: false, strength: 0 };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      const target = ptr.has ? 1 : 0;\n      ptr.strength += (target - ptr.strength) * Math.min(1, dt / BIAS_TAU);\n      draw(t, ptr.x, ptr.y, ptr.strength);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      ptr.x = (e.clientX - rect.left) / TILE;\n      ptr.y = (e.clientY - rect.top) / TILE;\n      ptr.has = true;\n    };\n    const onPointerLeave = () => {\n      ptr.has = false;\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced && !disposed) {\n        last = 0;\n        cancelAnimationFrame(raf);\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw(0, -1e5, -1e5, 0);\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const ro = new ResizeObserver(() => {\n      resize();\n      if (reduced) draw(0, -1e5, -1e5, 0);\n    });\n    ro.observe(canvas);\n\n    readTokens();\n    resize();\n    if (reduced) {\n      draw(0, -1e5, -1e5, 0);\n    } else {\n      canvas.addEventListener(\"pointermove\", onPointerMove);\n      canvas.addEventListener(\"pointerleave\", onPointerLeave);\n      raf = requestAnimationFrame(loop);\n    }\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      mo.disconnect();\n      ro.disconnect();\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerleave\", onPointerLeave);\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [tileSize]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      className={`block h-full w-full text-foreground ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-truchet-weave.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "background",
      "canvas",
      "truchet",
      "tiling",
      "generative",
      "cursor",
      "weave",
      "ambient"
    ],
    "instruction": "Build <TruchetWeave tileSize? className?> as a full-bleed <canvas>. GRID: square cells of TILE px (prop tileSize, default 28), cols = ceil(w / TILE), rows = ceil(h / TILE). FIELD: for each cell take its centre in cell units (cx = gx + 0.5, cy = gy + 0.5) and evaluate a three-term harmonic sum at two fixed rotation angles, 0.42 and 1.93 rad — u = cx*cos(0.42) + cy*sin(0.42), v = -cx*sin(1.93) + cy*cos(1.93), f = sin(u*0.26 + 0.11*t) + 0.70*sin(v*0.19 - 0.09*t) + 0.45*sin((u+v)*0.11 + 0.05*t). ORIENTATION IS THE SIGN, NOT A COIN FLIP: bit = (f + bias) > 0, so consistent orientation extends over large soft domains instead of dissolving into per-cell noise. Tile A draws two quarter-circle arcs of radius TILE/2 centred on the cell's top-left and bottom-right corners; tile B centres them on the top-right and bottom-left. Both tilings cross every cell edge at exactly its midpoint, so the two are edge-compatible and the arcs join across every boundary into one continuous interlaced curve — the classic Truchet 'maze of loops'. That continuity IS the mechanism: a single flipped tile visibly re-routes the whole curve threading through it, which is why the pointer's effect reads as structural rather than cosmetic. SPARSITY (the negative-space control): stroke alpha = pow(min(1, abs(f + bias) / 1.2), 1.6), so cells near the zero set — the domain walls, exactly where the orientation is about to flip — fade to nothing. Cells whose alpha falls under 0.06 are skipped entirely — that is |f + bias| < ~0.21, measured at ~11% of the frame — and the bucket below them strokes at 0.10 alpha, so a band roughly two cells wide either side of each zero crossing is blank-to-ghost and the result is a weave torn open along pale seams rather than a uniform mat. RENDER: 5 alpha buckets. Pass one classifies every cell into a bucket and pushes its packed index (tile orientation encoded in the sign) into a per-bucket array; pass two does ctx.beginPath() once per bucket, arcs every cell of that bucket into the single path, sets ctx.globalAlpha ONCE and ctx.stroke()s — 5 stroke calls per frame total, never one globalAlpha write per cell. lineWidth 1.25, lineCap 'butt'. POINTER: bias(cell) = P * exp(-d^2 / (2 * 5.5^2)) with d the cell-space distance to the pointer and P = 1.35 * S, where S is a 0..1 scalar eased toward 1 while the pointer is over the canvas and back to 0 on leave via S += (target - S) * min(1, dt / 0.5), a 0.5s time constant; the gaussian is cut off beyond 22 cells (4 sigma). At full strength that flips on the order of 40 tiles in a ring around the cursor — small in count, large in effect, because every flip re-routes the continuous curve threading through it. Because the bias is added BEFORE the sign test, cells whose |f| is already small flip first, so a fresh domain wall opens as a ring around the cursor and spreads outward, then retreats and the original weave re-forms on the same time constant. No click, no discrete re-roll, no React state on the hot path — direct-DOM rAF only. TOKENS: the canvas is the drawing medium, so ink is read once via getComputedStyle(canvas).color and re-derived on a MutationObserver watching documentElement's class attribute, making theme flips live in both light and dark; there is no colour literal anywhere in the source. dpr is clamped to 2, a ResizeObserver re-sizes with a <4px zero-size guard, the rAF loop pauses on document.hidden and resumes on visibilitychange, and dt is clamped to 1/30 s so a backgrounded tab cannot jump the field. prefers-reduced-motion draws exactly one frame at t = 0 with P = 0 and binds no pointer listeners and no rAF loop. The canvas is aria-hidden — it is decoration and carries no content a screen reader should announce. Props: tileSize (square tile px, default 28), className."
  },
  "type": "registry:ui"
}