{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-halftone-rosette",
  "title": "Background Halftone Rosette",
  "description": "A full-bleed ambient background of two same-ink halftone dot screens drifting at independent, non-commensurate angles: a real duotone/double-hit moiré rosette, with a coverage gradient keeping the frame edges readable and the visual center where the interference peaks.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/background-halftone-rosette/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BackgroundHalftoneRosette — a full-bleed ambient background: two halftone\n// dot screens of the SAME ink, each drifting at its own continuous angle,\n// the way duotone/double-hit offset printing lays one ink down twice at two\n// screen angles. The rosette is genuine dot-overlap moiré between two\n// same-ink coverage fields, not a color trick — both screens are painted in\n// --foreground on --background.\n//\n// Two screens, one Δθ. Real halftone practice cites a trio/quad of absolute\n// screen angles (15/45/75...) for MULTI-ink separations; with only two\n// screens here the meaningful parameter is the angular SEPARATION between\n// them, not an absolute angle. Resting separation is ~30° (a standard\n// print two-color offset); at rest each screen sits at ±15° so the visual\n// centerline stays put. Once running, screen A spins at +0.6deg/s and\n// screen B at -0.9deg/s — independent, non-commensurate rates, so Δθ drifts\n// forever and the beat pattern (wavelength ~ pitch / (2 sin(Δθ/2))) never\n// settles into a repeating state.\n//\n// Same-ink moiré peaks near 50% combined coverage and nearly vanishes near\n// the extremes, so a uniform-coverage field would wash the whole frame to\n// unreadable mid-grey — a bad backdrop for type. A third layer expresses a\n// coverage gradient instead: near-transparent at the visual center (full\n// dot contrast, the rosette is the point) diluting toward a background-\n// tinted scrim at the frame edges. The dilution ramp is deliberately steep\n// (see the stops below) rather than a slow fade: at hero/card scale, real\n// headline copy docked toward an edge still lands well inside where the\n// ramp has only reached its midpoint, not fully diluted — a slower fade\n// left the mid-coverage moiré zone (the ~50%-grey region the docblock above\n// warns is the least readable) sitting under real text instead of just\n// under the empty corners.\n//\n// Blend mode is chosen FROM THE THEME, never hardcoded. The invariant that\n// must hold in both themes: overlap value moves monotonically AWAY from\n// --background and TOWARD --foreground as coverage increases. `multiply`\n// darkens overlaps toward black — correct when ink is dark-on-light (light\n// theme), backward when ink is light-on-dark (multiplying two light layers\n// against a dark ground pulls overlaps toward the dark ground instead of\n// toward the ink). `screen` lightens overlaps toward white — the mirror\n// case, correct in dark theme. Which one applies is decided at mount (and\n// re-decided on every theme flip) by comparing --foreground's luminance\n// against --background's, read via getComputedStyle(document.documentElement)\n// and re-read on a MutationObserver watching documentElement's class.\n//\n// Substrate is pure CSS: two oversized (150%, negative-offset so a rotated\n// square still fully covers the container at any angle) tiled radial-\n// gradient dot layers, each under its own infinite CSS rotation keyframe,\n// blended per the rule above, plus the coverage-gradient scrim layer on\n// top. Zero rAF. prefers-reduced-motion (and the `paused` prop) freeze both\n// screens at the ~30° resting separation instead of an arbitrary mid-drift\n// crest — the print-safe, minimal-rosette state.\n// ---------------------------------------------------------------------------\n\nexport interface BackgroundHalftoneRosetteProps {\n  /** halftone screen pitch (dot-grid cell size), in px. @default 8 */\n  dotPitch?: number;\n  /** screen A's continuous rotation rate, degrees/second. @default 0.6 */\n  rateA?: number;\n  /** screen B's continuous rotation rate, degrees/second. @default -0.9 */\n  rateB?: number;\n  /** freeze both screens at their resting ~30deg separation. @default false */\n  paused?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: CSSProperties;\n}\n\ntype RGB = [number, number, number];\n\nfunction parseHex(raw: string): RGB | 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  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\nfunction luminance([r, g, b]: RGB): number {\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\n\n// resolved at mount + on every theme flip: \"multiply\" when --foreground is\n// darker than --background (light theme, dark ink on light paper), \"screen\"\n// when --foreground is lighter (dark theme, light ink on a dark ground).\nfunction resolveBlendMode(): \"multiply\" | \"screen\" {\n  if (typeof document === \"undefined\") return \"multiply\";\n  const cs = getComputedStyle(document.documentElement);\n  const bg = parseHex(cs.getPropertyValue(\"--background\")) ?? [1, 1, 1];\n  const fg = parseHex(cs.getPropertyValue(\"--foreground\")) ?? [0.09, 0.09, 0.09];\n  return luminance(fg) < luminance(bg) ? \"multiply\" : \"screen\";\n}\n\nfunction useReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onChange = () => setReduced(mq.matches);\n    onChange();\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduced;\n}\n\nconst REST_SEPARATION = 15; // each screen sits at +/-15deg -> 30deg apart at rest\n\nexport function BackgroundHalftoneRosette({\n  dotPitch = 8,\n  rateA = 0.6,\n  rateB = -0.9,\n  paused = false,\n  className = \"\",\n  style,\n}: BackgroundHalftoneRosetteProps) {\n  const [blendMode, setBlendMode] = useState<\"multiply\" | \"screen\">(\"multiply\");\n  const reducedMotion = useReducedMotion();\n  const isStatic = paused || reducedMotion;\n\n  useEffect(() => {\n    setBlendMode(resolveBlendMode());\n    const observer = new MutationObserver(() => setBlendMode(resolveBlendMode()));\n    observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n    return () => observer.disconnect();\n  }, []);\n\n  const dotRadius = dotPitch * 0.3; // ~28% raw coverage per screen alone, feathered ~0.4px to\n  // avoid rasterizer aliasing manufacturing false beat structure under continuous rotation\n  const durA = 360 / Math.max(0.001, Math.abs(rateA));\n  const durB = 360 / Math.max(0.001, Math.abs(rateB));\n  const dirA: \"normal\" | \"reverse\" = rateA < 0 ? \"reverse\" : \"normal\";\n  const dirB: \"normal\" | \"reverse\" = rateB < 0 ? \"reverse\" : \"normal\";\n  // negative animation-delay seeks each layer straight to the +/-15deg resting\n  // separation at t=0, instead of both starting at 0deg (Deltatheta=0, no rosette\n  // until the drift has had time to open up). For a \"reverse\" direction, p=0\n  // renders the keyframe's END (360deg), so the seek fraction is identical.\n  const delayA = -(REST_SEPARATION / 360) * durA;\n  const delayB = -(REST_SEPARATION / 360) * durB;\n\n  // sized off the larger container dimension via container queries, so the\n  // oversized square's half-diagonal covers the container's half-diagonal at\n  // every rotation angle regardless of aspect ratio (150% of the *smaller*\n  // axis alone can leave bare background strips sweeping in near 90/270deg).\n  const screenBase: CSSProperties = {\n    position: \"absolute\",\n    top: \"calc(50% - 75cqmax)\",\n    left: \"calc(50% - 75cqmax)\",\n    width: \"150cqmax\",\n    height: \"150cqmax\",\n    backgroundImage: `radial-gradient(circle, var(--foreground) 0px, var(--foreground) ${dotRadius}px, transparent ${dotRadius + 0.4}px)`,\n    backgroundSize: `${dotPitch}px ${dotPitch}px`,\n    backgroundRepeat: \"repeat\",\n  };\n\n  const layerAStyle: CSSProperties = isStatic\n    ? { ...screenBase, transform: `rotate(${REST_SEPARATION}deg)` }\n    : {\n        ...screenBase,\n        animationName: \"ns-halftone-rot\",\n        animationDuration: `${durA}s`,\n        animationDelay: `${delayA}s`,\n        animationDirection: dirA,\n        animationTimingFunction: \"linear\",\n        animationIterationCount: \"infinite\",\n      };\n\n  const layerBStyle: CSSProperties = isStatic\n    ? { ...screenBase, transform: `rotate(${-REST_SEPARATION}deg)`, mixBlendMode: blendMode }\n    : {\n        ...screenBase,\n        mixBlendMode: blendMode,\n        animationName: \"ns-halftone-rot\",\n        animationDuration: `${durB}s`,\n        animationDelay: `${delayB}s`,\n        animationDirection: dirB,\n        animationTimingFunction: \"linear\",\n        animationIterationCount: \"infinite\",\n      };\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      className={`relative h-full w-full overflow-hidden ${className}`}\n      style={{ backgroundColor: \"var(--background)\", isolation: \"isolate\", containerType: \"size\", ...style }}\n    >\n      <div style={layerAStyle} />\n      <div style={layerBStyle} />\n      {/* coverage-gradient scrim: ~transparent at the visual center (full\n          dot contrast, rosette is the point) diluting toward a\n          background-tinted edge (~8% effective coverage, a readable zone\n          for headline/CTA content). \"ellipse closest-side\" (not the\n          default farthest-corner circle) so all four edges — not just the\n          corners — reach the dilute stop. */}\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          backgroundImage: `radial-gradient(ellipse closest-side at 50% 50%,\n            transparent 0%,\n            transparent 20%,\n            color-mix(in srgb, var(--background) 55%, transparent) 45%,\n            color-mix(in srgb, var(--background) 85%, transparent) 70%,\n            color-mix(in srgb, var(--background) 95%, transparent) 100%)`,\n        }}\n      />\n      <style>{`\n@keyframes ns-halftone-rot { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }\n`}</style>\n    </div>\n  );\n}\n\nBackgroundHalftoneRosette.displayName = \"BackgroundHalftoneRosette\";\n",
      "type": "registry:ui",
      "target": "components/ui/background-halftone-rosette.tsx"
    }
  ],
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "halftone",
      "moire",
      "css",
      "print",
      "ambient",
      "duotone"
    ],
    "instruction": "Build <BackgroundHalftoneRosette dotPitch? rateA? rateB? paused? className?> as a full-bleed ambient background of two overlaid halftone dot screens — the same single ink at two independent, continuously drifting angles, the way duotone/double-hit offset printing lays one ink down twice at different screen angles. TWO PASSES, ONE INK: this is not a color trick — both screens render in --foreground on --background, and the moiré rosette is genuine dot-overlap interference between same-ink coverage fields, the print phenomenon printers historically fought and this component makes the point of. COVERAGE GRADIENT SOLVES THE MID-GREY PROBLEM: same-ink moiré is strongest near 50% combined coverage and nearly invisible near the extremes, so a uniform-coverage field would pull the whole frame toward an unreadable mid-grey; instead coverage ramps from ~8% at the frame edges (a low-coverage reading zone for headline content, expressed as a --background-tinted scrim) toward full dot contrast at the visual center, where the rosette is the point. TWO NON-COMMENSURATE RATES: screen A rotates continuously at +0.6deg/s (default rateA), screen B at -0.9deg/s (default rateB), so their separation drifts forever rather than settling into a repeating period — moiré beat wavelength scales as pitch/(2*sin(deltaTheta/2)), so even this slow a drift produces a large, clearly visible shift in the beat pattern within a couple of seconds, keeping the field genuinely alive at rest. THEME-DEPENDENT BLEND MODE, NOT HARDCODED: overlap value must move monotonically away from --background and toward --foreground as coverage increases, in BOTH themes — mix-blend-mode:multiply is used when --foreground is darker than --background (light theme), screen when --foreground is lighter (dark theme), decided by comparing token luminance via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class; a hardcoded multiply would silently invert the coverage cue in dark theme. CSS-driven: two oversized (150%, negative-offset so a rotated square always fully covers the container) tiled radial-gradient dot layers, each under an independent infinite CSS rotation keyframe, zero rAF cost. prefers-reduced-motion and the paused prop both freeze the rotation at the screens' ~30deg resting separation (each screen held at +/-15deg) — the calm, print-safe minimal-rosette state, not an arbitrary mid-drift crest. Props: dotPitch (halftone screen pitch in px, default 8), rateA/rateB (deg/s, defaults 0.6/-0.9), paused, className."
  },
  "type": "registry:ui"
}