{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-dot-gain-screen",
  "title": "Card Dot Gain Screen",
  "description": "A card backed by a contact-screen halftone that reproduces real dot gain: a nonlinear per-dot growth curve where the deepest tones grow past the point of staying separate circles and physically bridge into plugged solid ink.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/card-dot-gain-screen/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CardDotGainScreen — a card whose backing texture is a contact-screen\n// halftone reproduced with real ink behaviour, not a decorative dot grid.\n// Offset printing does not scale a halftone dot cleanly: the plate's\n// nominal tone value runs through a NONLINEAR dot-gain curve (ink spreads\n// past the plate's own edge, growing a dot's printed area beyond its\n// theoretical size — heaviest in the midtones, per Murray-Davies), and in\n// the deepest tones the grown dots physically touch their neighbours and\n// PLUG: the paper between adjacent dots disappears and a patch of \"texture\"\n// becomes a patch of solid ink. That plugging, not the dot shape, is the\n// mechanic this component reproduces — chart-bar-halftone and\n// chart-donut-halftone elsewhere in this registry both use a clean,\n// LINEARLY size-scaled dot per data value on a chart surface; this is a\n// card, and every dot's radius here comes off a nonlinear gain curve that\n// is allowed to push a dot's printed area past the point where it stays a\n// separate circle, so dense regions visibly bridge and merge instead of\n// just growing bigger.  background-halftone-rosette (loud) is the other\n// printing-family neighbour: two same-pitch dot screens at drifting angles\n// producing moiré interference — a two-screen registration effect, no gain\n// curve, no merging, and it runs full-bleed with no card content to\n// protect. This component is a single screen with per-dot growth.\n//\n// Ink math: a dot's PRINTED area comes from its plate tone t (0=highlight,\n// 1=shadow) via printedArea(t) = t + gain*midtoneBump(t) + gain*shadowPlug(t):\n//   midtoneBump  = 4t(1-t)              — Murray-Davies parabola, heaviest ~50%\n//   shadowPlug   = smoothstep(0.68,1,t)^2 — near-zero until t~0.68, then\n//                  accelerates hard toward 1 — models the extra ink-spread\n//                  that specifically hits shadow regions in real contact\n//                  printing, on top of the ordinary midtone bump\n// printedArea is allowed to exceed 1 (clamped to 1.6) so a dot's rendered\n// radius r = pitch/2 * sqrt(printedArea) can grow past pitch/2 — the radius\n// at which a dot on a square screen touches its orthogonal neighbours.\n// Past that point two filled, opaque circles drawn overlapping is what\n// \"plugging\" IS: the canvas fill covers the gap between them the same way\n// spreading ink would. Nothing special-cases the merge; it falls out of\n// letting the radius exceed tangency.\n//\n// Idle drift: `gain` above is not a constant. Two incommensurate sine terms\n// (periods 7.3s / 11.7s, never in phase together twice inside any short\n// window) walk it between roughly 0.22 and 0.38, standing in for plate\n// humidity and ink-feed variance drifting a real press's dot gain over a\n// run. A slow per-dot noise field (coordinates offset by time*0.05) adds\n// a small amount of jitter on top so individual dots swell and shrink out\n// of lockstep — the plug/bridge boundary between screen and solid visibly\n// creeps rather than holding a fixed shape, with no input required.\n//\n// Legibility: a card carries text. A screen printed at uniform mid-to-high\n// tone would plug solid under the copy and erase it. Rather than punching\n// a hole (an opacity mask reads as \"the effect turned off\"), the plate\n// tone itself is scaled down under the content block — the same \"coverage\n// gradient\" solution background-halftone-rosette uses for its full-bleed\n// scrim, applied locally: t is multiplied by a falloff that is 1 outside\n// the text's padded bounding box and ~0.32 inside it with a soft-edged\n// transition, so the copy sits over a sparse, still-visibly-printed\n// highlight patch while the card's margins run the full gain curve up\n// into plugged shadow — the mechanic stays fully on display, just not\n// directly behind the letters.\n// ---------------------------------------------------------------------------\n\nexport interface CardDotGainScreenProps {\n  /** card heading */\n  title?: string;\n  /** card body copy */\n  description?: string;\n  /** trailing link label; omit to render the card with no link */\n  linkLabel?: string;\n  /** link href, used only when linkLabel is set */\n  href?: string;\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 parseColor(raw: string): RGB | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    let hex = s.slice(1);\n    if (hex.length === 3) hex = hex.split(\"\").map((c) => c + c).join(\"\");\n    if (hex.length < 6) return null;\n    const r = parseInt(hex.slice(0, 2), 16);\n    const g = parseInt(hex.slice(2, 4), 16);\n    const b = parseInt(hex.slice(4, 6), 16);\n    return Number.isNaN(r + g + b) ? null : [r, g, b];\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\n// deterministic per-cell hash, coordinates offset by time elsewhere to drift\nfunction hash01(x: number, y: number): number {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\n}\n\nfunction smoothstep(edge0: number, edge1: number, x: number): number {\n  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));\n  return t * t * (3 - 2 * t);\n}\n\n// gain(t) walks between the two SEC constants below via two incommensurate\n// sine terms; freeze time (reduced motion) lands here for maximum plugging.\nconst GAIN_BASE = 0.3;\nconst GAIN_AMP1 = 0.05;\nconst GAIN_AMP2 = 0.035;\nconst PERIOD1 = 7.3; // seconds\nconst PERIOD2 = 11.7; // seconds\n// t at which sin(2*pi*t/PERIOD1) first peaks — chosen as the reduced-motion\n// freeze frame because both waves sit near their maxima there together\n// (PERIOD1/4 = 1.825s; PERIOD2 is not a multiple of it, so this is the\n// nearest simultaneous near-peak, not an exact one), giving the richest,\n// most legibly plugged shadow band to freeze on rather than an arbitrary t=0.\nconst STATIC_TIME = PERIOD1 / 4;\n\nexport function CardDotGainScreen({\n  title = \"Contact screen, plate 4\",\n  description = \"Dot gain is not a defect to correct out — past a threshold the screen stops separating tones and starts plugging solid.\",\n  linkLabel = \"Read the proof sheet\",\n  href = \"#\",\n  className = \"\",\n  style,\n}: CardDotGainScreenProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const content = contentRef.current;\n    if (!root || !canvas || !content) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n\n    // -- token-derived ink: read at mount, re-derived on theme class change --\n    let fg: RGB = [237, 237, 237];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n    };\n    derive();\n\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let pitch = 6;\n    let raf = 0;\n    let visible = true;\n    let clearBox = { x: 0, y: 0, w: 0, h: 0 };\n\n    const measureClearBox = () => {\n      const rootRect = root.getBoundingClientRect();\n      const cRect = content.getBoundingClientRect();\n      // padded a bit beyond the literal text box so the falloff has room to\n      // land at ~1 before the box edge instead of clipping hard at it\n      const pad = pitch * 2.2;\n      clearBox = {\n        x: cRect.left - rootRect.left - pad,\n        y: cRect.top - rootRect.top - pad,\n        w: cRect.width + pad * 2,\n        h: cRect.height + pad * 2,\n      };\n    };\n\n    // multiplies plate tone down to ~0.32 inside the padded content box,\n    // 1 outside it, with a soft-edged transition band one pitch wide\n    const clearFactor = (x: number, y: number): number => {\n      const dx = x < clearBox.x ? clearBox.x - x : x > clearBox.x + clearBox.w ? x - (clearBox.x + clearBox.w) : 0;\n      const dy = y < clearBox.y ? clearBox.y - y : y > clearBox.y + clearBox.h ? y - (clearBox.y + clearBox.h) : 0;\n      const dist = Math.sqrt(dx * dx + dy * dy);\n      const band = pitch * 3;\n      const inside = 1 - smoothstep(0, band, dist);\n      return 1 - inside * 0.68;\n    };\n\n    const draw = (now: number) => {\n      if (w <= 0 || h <= 0) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n\n      const time = reduced ? STATIC_TIME : now / 1000;\n      const gain =\n        GAIN_BASE +\n        GAIN_AMP1 * Math.sin((2 * Math.PI * time) / PERIOD1) +\n        GAIN_AMP2 * Math.sin((2 * Math.PI * time) / PERIOD2 + 1.1);\n      const noiseT = time * 0.05;\n\n      const cols = Math.ceil(w / pitch) + 1;\n      const rows = Math.ceil(h / pitch) + 1;\n      ctx.fillStyle = `rgb(${fg[0]},${fg[1]},${fg[2]})`;\n      ctx.beginPath();\n      for (let j = 0; j < rows; j++) {\n        const cy = j * pitch + pitch / 2;\n        for (let i = 0; i < cols; i++) {\n          const cx = i * pitch + pitch / 2;\n          // print-swatch base tone: diagonal ramp, highlight top-left to\n          // shadow bottom-right, so one card shows the whole gain curve\n          const baseT = Math.min(1, Math.max(0, (cx / w + cy / h) / 2));\n          const t = baseT * clearFactor(cx, cy);\n\n          const midtoneBump = 4 * t * (1 - t);\n          const shadowPlug = Math.pow(smoothstep(0.68, 1, t), 2);\n          let area = t + gain * midtoneBump * 0.55 + gain * shadowPlug * 0.9;\n          // per-dot jitter: slow-drifting noise field, +/-6% of area\n          const jitter = (hash01(i * 0.37 + noiseT, j * 0.53 - noiseT) - 0.5) * 0.12;\n          area = Math.min(1.6, Math.max(0, area + jitter));\n          if (area <= 0.002) continue;\n\n          const r = (pitch / 2) * Math.sqrt(area);\n          ctx.moveTo(cx + r, cy);\n          ctx.arc(cx, cy, r, 0, Math.PI * 2);\n        }\n      }\n      ctx.fill();\n    };\n\n    const loop = (now: number) => {\n      draw(now);\n      if (!reduced && visible) raf = requestAnimationFrame(loop);\n      else raf = 0;\n    };\n    const wake = () => {\n      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      // screen ruling proportional to the card's own smaller dimension, so\n      // it never coarsens into a polka-dot pattern on a small card and\n      // never over-fines on a large one\n      pitch = Math.min(9, Math.max(3.5, Math.min(w, h) / 32));\n      measureClearBox();\n      draw(reduced ? 0 : performance.now());\n    };\n\n    resize();\n    if (!reduced) wake();\n    else draw(0);\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      derive();\n      draw(reduced ? 0 : performance.now());\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const onReducedChange = () => {\n      reduced = mq.matches;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        draw(0);\n      } else {\n        wake();\n      }\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    const onVisibility = () => {\n      visible = document.visibilityState === \"visible\";\n      if (visible) {\n        wake();\n      } else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      mo.disconnect();\n      mq.removeEventListener(\"change\", onReducedChange);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`ns-cdgs relative w-full max-w-sm overflow-hidden rounded-[14px] border border-border bg-background ${className}`}\n      style={style}\n    >\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n      <div ref={contentRef} className=\"relative flex flex-col gap-3 p-6\">\n        <h3 className=\"text-balance font-sans text-lg font-medium text-foreground\">{title}</h3>\n        <p className=\"text-pretty font-mono text-xs leading-relaxed text-ns-muted\">{description}</p>\n        {linkLabel ? (\n          <a\n            href={href}\n            className=\"ns-cdgs-link mt-1 inline-flex w-fit items-center gap-1 rounded-sm font-mono text-xs font-medium text-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          >\n            {linkLabel}\n            <span aria-hidden=\"true\">&rarr;</span>\n          </a>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nCardDotGainScreen.displayName = \"CardDotGainScreen\";\n\nexport default CardDotGainScreen;\n",
      "type": "registry:ui",
      "target": "components/ui/card-dot-gain-screen.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": "core",
    "tags": [
      "card",
      "halftone",
      "dot-gain",
      "print",
      "canvas",
      "ambient",
      "monochrome"
    ],
    "instruction": "Build a card whose entire backing surface is a canvas-rendered contact-screen halftone, sourced from real offset-printing dot gain, not a decorative dot grid. Root is a `rounded-[14px] border border-border bg-background` card holding an absolutely positioned, `aria-hidden` canvas filling the card behind a normal content stack (heading, mono body copy, optional underlined link with a `focus-visible` ring in `--ns-accent`).\n\nPer dot, compute a plate tone `t` in [0,1] from a diagonal print-swatch gradient across the card (highlight top-left to shadow bottom-right, `(cx/w + cy/h)/2`) so a single card demonstrates the whole gain curve at once. Convert `t` to a PRINTED area via `printedArea = t + gain*midtoneBump(t) + gain*shadowPlug(t)`, where `midtoneBump = 4t(1-t)` is the classic Murray-Davies parabola (heaviest gain around 50% tone) and `shadowPlug = smoothstep(0.68,1,t)^2` is near-zero until deep shadow and then accelerates hard toward 1 — an explicit second term for the ink-spread that specifically hits shadow regions, on top of the ordinary midtone bump. Let `printedArea` exceed 1 (clamp at 1.6) and derive a dot's rendered radius as `r = pitch/2 * sqrt(printedArea)`. `pitch/2` is exactly the radius at which a dot on a square screen touches its orthogonal neighbours; letting the radius grow past that and simply filling the (now overlapping) circles is what makes adjacent dots visually PLUG — the merge is not special-cased, it falls out of overlapping opaque fills, same as ink spreading past a dot's plate edge in reality. This is the mechanic that distinguishes the component from chart-bar-halftone/chart-donut-halftone's clean, linearly SIZE-SCALED dots (no gain curve, no merge) and from background-halftone-rosette's two-screen moiré interference (registration between two screens, not per-dot growth).\n\n`gain` is not constant: walk it via two incommensurate sine terms (periods 7.3s and 11.7s, base ~0.30, amplitudes 0.05/0.035) so the plugged-shadow boundary visibly creeps over a period of several seconds with no input — standing in for plate humidity and ink-feed variance drifting a real press's dot gain over a run. Layer a small per-dot jitter on top from a deterministic hash noise field whose coordinates are offset by slow-moving time, so individual dots swell and shrink slightly out of lockstep rather than the whole screen breathing uniformly. Screen pitch is derived from the card's own smaller dimension (`clamp(minDim/32, 3.5, 9)` px) via `ResizeObserver`, never a fixed px value, so it stays a fine screen rather than coarsening into visible polka dots on a small card.\n\nLegibility: rather than punching an opacity hole over the text (which reads as the effect switching off), scale the plate tone `t` down to ~32% of its value inside the content block's padded bounding box, with a soft one-pitch-wide falloff back to 100% outside it — the same 'coverage gradient' idea background-halftone-rosette uses for its full-bleed scrim, applied locally, so the copy sits over a sparse but still-visibly-printed highlight patch while the card's corners run the full curve up into plugged shadow.\n\nInk color is `--foreground` read via `getComputedStyle(document.documentElement)` at mount and re-derived on a `MutationObserver` watching `documentElement`'s class — never a literal — so 'more ink' correctly means more `--foreground` coverage and the polarity is automatically right in both themes. `--ns-accent` never touches the screen itself, only the link's focus ring. DPR-capped (max 2) backing store sized off the card's own `getBoundingClientRect`. The animation loop is a plain `requestAnimationFrame`, paused via `visibilitychange` when the tab is hidden, and stopped entirely under `prefers-reduced-motion: reduce`, which freezes the clock at `PERIOD1/4` (≈1.825s) — the point nearest a simultaneous peak of both drift waves — so the reduced-motion frame lands on the richest, most legibly plugged shadow band rather than an arbitrary t=0. No dependencies."
  },
  "type": "registry:ui"
}