{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halftone-plate",
  "type": "registry:component",
  "title": "Halftone Plate",
  "description": "A monochrome SVG screen with optional controlled inspection. Convert a bounded grayscale sample or authored geometry into isolated vector marks, with geometric ink coverage.",
  "dependencies": [],
  "files": [
    {
      "path": "components/HalftonePlate.tsx",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n\"use client\";\nimport { useMemo, useRef, type PointerEvent } from \"react\";\nimport {\n  halftoneSvg,\n  parseHalftone,\n  HALFTONE_DEFAULT,\n  type HalftoneState,\n  type ToneImage,\n} from \"../lib/render/halftone\";\nexport interface ScreenProbe {\n  x: number;\n  y: number;\n}\nexport interface HalftonePlateProps {\n  state: HalftoneState;\n  image?: ToneImage;\n  probe?: ScreenProbe;\n  onInspect?: (probe: ScreenProbe) => void;\n}\n/** Portable SVG specimen. Optional controlled inspection; no animation loop or provider. */\nexport function HalftonePlate({\n  state,\n  image,\n  probe = { x: 0.5, y: 0.5 },\n  onInspect,\n}: HalftonePlateProps) {\n  const last = useRef(0);\n  const screen = useMemo(\n    () => parseHalftone(state) ?? HALFTONE_DEFAULT,\n    [state],\n  );\n  const markup = useMemo(\n    () =>\n      halftoneSvg(screen, image).replace(\n        'width=\"800\" height=\"800\" viewBox=',\n        'width=\"100%\" height=\"100%\" viewBox=',\n      ),\n    [screen, image],\n  );\n  const edge = 3 / screen.columns;\n  const bound = (n: number) =>\n    Number.isFinite(n) ? Math.max(edge, Math.min(1 - edge, n)) : 0.5;\n  const inspect = (event: PointerEvent<HTMLDivElement>) => {\n    if (!onInspect || (event.pointerType !== \"mouse\" && !event.buttons)) return;\n    const now = performance.now();\n    if (now - last.current < 32) return;\n    last.current = now;\n    const r = event.currentTarget.getBoundingClientRect();\n    onInspect({\n      x: bound(((event.clientX - r.left) / r.width - 0.1) / 0.8),\n      y: bound(((event.clientY - r.top) / r.height - 0.1) / 0.8),\n    });\n  };\n  return (\n    <div\n      role={onInspect ? \"group\" : undefined}\n      aria-label={\n        onInspect\n          ? \"Inspect the printed screen. Move your pointer or use arrow keys.\"\n          : undefined\n      }\n      tabIndex={onInspect ? 0 : undefined}\n      onPointerMove={inspect}\n      onPointerDown={(e) => {\n        if (!onInspect) return;\n        e.currentTarget.focus({ preventScroll: true });\n        e.currentTarget.setPointerCapture(e.pointerId);\n        last.current = 0;\n        inspect(e);\n      }}\n      onPointerUp={(e) => {\n        if (e.currentTarget.hasPointerCapture(e.pointerId))\n          e.currentTarget.releasePointerCapture(e.pointerId);\n      }}\n      onKeyDown={(e) => {\n        if (onInspect && [\"Home\", \"Escape\"].includes(e.key)) {\n          e.preventDefault();\n          onInspect({ x: 0.5, y: 0.5 });\n          return;\n        }\n        if (\n          !onInspect ||\n          ![\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key)\n        )\n          return;\n        e.preventDefault();\n        onInspect({\n          x: bound(\n            probe.x +\n              (e.key === \"ArrowLeft\" ? -1 : e.key === \"ArrowRight\" ? 1 : 0) /\n                screen.columns,\n          ),\n          y: bound(\n            probe.y +\n              (e.key === \"ArrowUp\" ? -1 : e.key === \"ArrowDown\" ? 1 : 0) /\n                screen.columns,\n          ),\n        });\n      }}\n      style={{\n        width: \"100%\",\n        aspectRatio: \"1\",\n        lineHeight: 0,\n        position: \"relative\",\n        cursor: onInspect ? \"crosshair\" : undefined,\n        touchAction: onInspect ? \"none\" : \"auto\",\n        outlineOffset: 4,\n      }}\n    >\n      <div dangerouslySetInnerHTML={{ __html: markup }} />\n      {onInspect && (\n        <span\n          aria-hidden=\"true\"\n          style={{\n            position: \"absolute\",\n            left: `${10 + bound(probe.x) * 80}%`,\n            top: `${10 + bound(probe.y) * 80}%`,\n            width: `${480 / screen.columns}%`,\n            aspectRatio: \"1\",\n            transform: \"translate(-50%,-50%)\",\n            border: \"1px solid white\",\n            boxShadow: \"0 0 0 1px #0008\",\n            mixBlendMode: \"difference\",\n            pointerEvents: \"none\",\n          }}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/HalftonePlate.tsx"
    },
    {
      "path": "lib/render/halftone.ts",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n/** Chromologium — authored monochrome screen, https://chromologium.com/lab/halftone.\n * Mark area is proportional to darkness. Isolated marks intentionally preserve paper gaps.\n * This is an sRGB tonal study, not a measured press or dot-gain simulation.\n */\nexport interface HalftoneState {\n  source: \"orb\" | \"fold\" | \"steps\";\n  columns: number;\n  gain: number;\n  shape: \"round\" | \"square\";\n  ink: \"carbon\" | \"carmine\" | \"cobalt\";\n  invert: boolean;\n}\nexport interface ToneImage {\n  width: number;\n  height: number;\n  values: readonly number[];\n}\nexport const HALFTONE_DEFAULT: HalftoneState = {\n  source: \"orb\",\n  columns: 48,\n  gain: 1.1,\n  shape: \"round\",\n  ink: \"carbon\",\n  invert: false,\n};\nexport const HALFTONE_INKS = {\n  carbon: { name: \"Carbon\", color: \"#242822\" },\n  carmine: { name: \"Carmine\", color: \"#8f2830\" },\n  cobalt: { name: \"Cobalt\", color: \"#204cb0\" },\n} as const;\nconst clamp = (n: number, min: number, max: number) =>\n  Math.max(min, Math.min(max, n));\nexport function parseHalftone(value: unknown): HalftoneState | null {\n  if (!value || typeof value !== \"object\") return null;\n  const s = value as Record<string, unknown>;\n  if (\n    ![\"orb\", \"fold\", \"steps\"].includes(s.source as string) ||\n    ![\"round\", \"square\"].includes(s.shape as string) ||\n    ![\"carbon\", \"carmine\", \"cobalt\"].includes(s.ink as string) ||\n    typeof s.invert !== \"boolean\"\n  )\n    return null;\n  if (\n    typeof s.columns !== \"number\" ||\n    !Number.isFinite(s.columns) ||\n    typeof s.gain !== \"number\" ||\n    !Number.isFinite(s.gain)\n  )\n    return null;\n  return {\n    source: s.source as HalftoneState[\"source\"],\n    shape: s.shape as HalftoneState[\"shape\"],\n    ink: s.ink as HalftoneState[\"ink\"],\n    invert: s.invert,\n    columns: Math.round(clamp(s.columns, 16, 80)),\n    gain: clamp(s.gain, 0.5, 1.8),\n  };\n}\nexport function validToneImage(\n  image: ToneImage | undefined,\n): image is ToneImage {\n  return (\n    !!image &&\n    Number.isInteger(image.width) &&\n    Number.isInteger(image.height) &&\n    image.width > 0 &&\n    image.height > 0 &&\n    image.width <= 256 &&\n    image.height <= 256 &&\n    Array.isArray(image.values) &&\n    image.values.length === image.width * image.height &&\n    Array.from(image.values).every(\n      (v) => Number.isFinite(v) && v >= 0 && v <= 1,\n    )\n  );\n}\n/** Authored demonstration geometry. 0 = dark, 1 = light; not a physical lighting model. */\nexport function studyTone(\n  source: HalftoneState[\"source\"],\n  x: number,\n  y: number,\n): number {\n  if (source === \"fold\") {\n    const wave = Math.sin((x * 2.3 + y * 0.65) * Math.PI * 2);\n    return clamp(\n      0.45 + 0.4 * wave + 0.13 * Math.sin(y * 9 - x * 3),\n      0.03,\n      0.98,\n    );\n  }\n  if (source === \"steps\") {\n    const edge = Math.max(Math.abs(x - 0.5), Math.abs(y - 0.5));\n    return clamp(0.1 + Math.floor(edge * 14) * 0.13, 0.05, 0.98);\n  }\n  const dx = (x - 0.5) / 0.38,\n    dy = (y - 0.46) / 0.38,\n    d = dx * dx + dy * dy;\n  if (d > 1) {\n    const shadow = Math.exp(\n      -((x - 0.57) ** 2 / 0.055 + (y - 0.86) ** 2 / 0.003),\n    );\n    return 0.98 - shadow * 0.42;\n  }\n  const z = Math.sqrt(1 - d),\n    light = clamp(-dx * 0.48 - dy * 0.55 + z * 0.68, 0, 1);\n  return 0.06 + 0.91 * light;\n}\nexport interface HalftoneMark {\n  x: number;\n  y: number;\n  size: number;\n  tone: number;\n}\nexport function halftoneMarks(input: HalftoneState, image?: ToneImage) {\n  const state = parseHalftone(input) ?? HALFTONE_DEFAULT;\n  const source = validToneImage(image) ? image : undefined;\n  const pitch = 640 / state.columns;\n  const marks: HalftoneMark[] = [];\n  let area = 0;\n  for (let row = 0; row < state.columns; row++)\n    for (let col = 0; col < state.columns; col++) {\n      const x = (col + 0.5) / state.columns,\n        y = (row + 0.5) / state.columns;\n      let tone = source\n        ? source.values[\n            Math.min(source.height - 1, Math.floor(y * source.height)) *\n              source.width +\n              Math.min(source.width - 1, Math.floor(x * source.width))\n          ]\n        : studyTone(state.source, x, y);\n      if (state.invert) tone = 1 - tone;\n      const darkness = clamp((1 - tone) * state.gain, 0, 1);\n      const size = pitch * 0.92 * Math.sqrt(darkness);\n      area += size * size * (state.shape === \"round\" ? Math.PI / 4 : 1);\n      marks.push({ x: 80 + x * 640, y: 80 + y * 640, size, tone });\n    }\n  return { marks, pitch, coverage: area / (640 * 640), state };\n}\nexport function halftoneSvg(input: HalftoneState, image?: ToneImage): string {\n  const { marks, state, coverage } = halftoneMarks(input, image);\n  const shapes = marks\n    .filter((m) => m.size > 0.04)\n    .map((m) =>\n      state.shape === \"round\"\n        ? `<circle cx=\"${m.x.toFixed(2)}\" cy=\"${m.y.toFixed(2)}\" r=\"${(m.size / 2).toFixed(2)}\"/>`\n        : `<rect x=\"${(m.x - m.size / 2).toFixed(2)}\" y=\"${(m.y - m.size / 2).toFixed(2)}\" width=\"${m.size.toFixed(2)}\" height=\"${m.size.toFixed(2)}\"/>`,\n    )\n    .join(\"\");\n  const recipe = JSON.stringify(state).replaceAll('\"', \"&quot;\");\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"800\" viewBox=\"0 0 800 800\" role=\"img\" aria-label=\"Halftone print in ${HALFTONE_INKS[state.ink].name}\"><title>The Halftone Camera · Chromologium</title><desc>Authored isolated-mark tonal study. Settings: ${recipe}. ${validToneImage(image) ? \"Local image sampled by the visitor; original image is not included.\" : \"Authored source geometry.\"} Source: https://chromologium.com/lab/halftone</desc><rect width=\"800\" height=\"800\" fill=\"#f0ecdf\"/><g fill=\"${HALFTONE_INKS[state.ink].color}\">${shapes}</g><g fill=\"none\" stroke=\"#8b887b\" stroke-width=\".7\"><path d=\"M60 80H42M80 60V42M720 60V42M740 80H758M60 720H42M80 740V758M720 740V758M740 720H758\"/></g><g font-family=\"monospace\" font-size=\"10\" letter-spacing=\"1.8\" fill=\"#56594f\"><text x=\"80\" y=\"51\">CHROMOLOGIUM / SCREEN STUDIES</text><text x=\"80\" y=\"755\">${state.columns} × ${state.columns} / ${Math.round(coverage * 100)}% INK</text><text x=\"631\" y=\"755\">FIG. 017</text></g></svg>`;\n}\n",
      "type": "registry:lib",
      "target": "lib/render/halftone.ts"
    }
  ],
  "docs": "Import HalftonePlate from components/HalftonePlate and HALFTONE_DEFAULT, halftoneSvg, halftoneMarks from lib/render/halftone. Pass state: HalftoneState. Optional image: ToneImage accepts width and height up to 256 with width*height grayscale values in [0,1]. Omit it for authored source geometry. Pass probe={{x:.5,y:.5}} and onInspect={setProbe} for pointer and keyboard inspection; omit onInspect for a still. halftoneSvg(state,image) returns a standalone print. halftoneMarks supplies geometric coverage. No animation loop, WebGL or image service required. Original image files are not embedded. Source: https://chromologium.com/lab/halftone.",
  "author": "The Chromologium",
  "homepage": "https://chromologium.com",
  "license": "MIT"
}
