{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "relief-surface",
  "type": "registry:component",
  "title": "Relief Surface",
  "description": "An embossed or debossed surface with pointer and keyboard lighting. A controlled React component and standalone SVG renderer using an authored alpha height map.",
  "dependencies": [],
  "files": [
    {
      "path": "components/ReliefSurface.tsx",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n\"use client\";\nimport { useId, useRef, type KeyboardEvent, type PointerEvent } from \"react\";\nimport {\n  reliefSvg,\n  reliefLightAt,\n  type ReliefState,\n} from \"../lib/render/relief\";\n\nexport interface ReliefSurfaceProps {\n  state: ReliefState;\n  onChange?: (light: Pick<ReliefState, \"azimuth\" | \"elevation\">) => void;\n}\n/** Controlled light surface; no animation loop, external imagery or WebGL context. */\nexport function ReliefSurface({ state, onChange }: ReliefSurfaceProps) {\n  const id = useId();\n  const last = useRef(0);\n  function illuminate(event: PointerEvent<HTMLDivElement>) {\n    if (!onChange || (event.pointerType !== \"mouse\" && !event.buttons)) return;\n    const now = performance.now();\n    if (now - last.current < 32) return;\n    last.current = now;\n    const rect = event.currentTarget.getBoundingClientRect();\n    onChange(\n      reliefLightAt(\n        (event.clientX - rect.left) / rect.width,\n        (event.clientY - rect.top) / rect.height,\n      ),\n    );\n  }\n  function key(event: KeyboardEvent<HTMLDivElement>) {\n    if (\n      !onChange ||\n      ![\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(event.key)\n    )\n      return;\n    event.preventDefault();\n    onChange({\n      azimuth:\n        (state.azimuth +\n          (event.key === \"ArrowRight\"\n            ? 5\n            : event.key === \"ArrowLeft\"\n              ? -5\n              : 0) +\n          360) %\n        360,\n      elevation: Math.max(\n        10,\n        Math.min(\n          85,\n          state.elevation +\n            (event.key === \"ArrowUp\" ? 5 : event.key === \"ArrowDown\" ? -5 : 0),\n        ),\n      ),\n    });\n  }\n  return (\n    <div\n      role={onChange ? \"group\" : \"img\"}\n      aria-label={\n        onChange\n          ? \"Relief surface. Move the light with your pointer, or use arrow keys to change direction and elevation.\"\n          : \"Relief surface specimen\"\n      }\n      tabIndex={onChange ? 0 : undefined}\n      onPointerMove={illuminate}\n      onPointerDown={(event) => {\n        if (!onChange) return;\n        event.currentTarget.focus({ preventScroll: true });\n        event.currentTarget.setPointerCapture(event.pointerId);\n        last.current = 0;\n        illuminate(event);\n      }}\n      onPointerUp={(event) => {\n        if (event.currentTarget.hasPointerCapture(event.pointerId))\n          event.currentTarget.releasePointerCapture(event.pointerId);\n      }}\n      onKeyDown={key}\n      style={{\n        width: \"100%\",\n        aspectRatio: \"1\",\n        lineHeight: 0,\n        cursor: onChange ? \"crosshair\" : undefined,\n        touchAction: onChange ? \"none\" : \"auto\",\n        outlineOffset: 5,\n      }}\n      dangerouslySetInnerHTML={{\n        __html: reliefSvg(state, id).replace(\n          'width=\"800\" height=\"800\" viewBox=',\n          'width=\"100%\" height=\"100%\" viewBox=',\n        ),\n      }}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/ReliefSurface.tsx"
    },
    {
      "path": "lib/render/relief.ts",
      "content": "/* Experience Design System influenced by Chromologium (https://chromologium.com/) */\n/** Chromologium — authored bump-map study, not measured material coefficients.\n * SVG lighting uses the blurred alpha channel as surface height.\n * Method: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/feDiffuseLighting\n * Source: https://chromologium.com/lab/relief\n */\nexport interface ReliefState {\n  text: string;\n  motif: \"type\" | \"seal\";\n  material: \"cotton\" | \"graphite\" | \"copper\";\n  depth: number;\n  softness: number;\n  azimuth: number;\n  elevation: number;\n  recessed: boolean;\n}\nexport const RELIEF_DEFAULT: ReliefState = {\n  text: \"Aa\",\n  motif: \"type\",\n  material: \"cotton\",\n  depth: 4,\n  softness: 1.5,\n  azimuth: 225,\n  elevation: 28,\n  recessed: false,\n};\nexport const RELIEF_MATERIALS = {\n  cotton: {\n    name: \"Cotton paper\",\n    color: \"#ece5d5\",\n    label: \"#605a4b\",\n    specular: 0.05,\n    exponent: 9,\n  },\n  graphite: {\n    name: \"Graphite\",\n    color: \"#343b3e\",\n    label: \"#bac1bd\",\n    specular: 0.65,\n    exponent: 22,\n  },\n  copper: {\n    name: \"Copper\",\n    color: \"#c48a62\",\n    label: \"#4a3023\",\n    specular: 0.8,\n    exponent: 28,\n  },\n} as const;\nconst bound = (n: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, n));\nexport function parseRelief(value: unknown): ReliefState | null {\n  if (!value || typeof value !== \"object\") return null;\n  const s = value as Record<string, unknown>;\n  if (\n    typeof s.text !== \"string\" ||\n    s.text.length > 8 ||\n    !s.text.trim() ||\n    typeof s.recessed !== \"boolean\"\n  )\n    return null;\n  if (\n    !Array.from(s.text, (char) => (char.charCodeAt(0) < 32 ? \" \" : char))\n      .join(\"\")\n      .trim()\n  )\n    return null;\n  if (s.motif !== \"type\" && s.motif !== \"seal\") return null;\n  if (\n    s.material !== \"cotton\" &&\n    s.material !== \"graphite\" &&\n    s.material !== \"copper\"\n  )\n    return null;\n  if (\n    ![s.depth, s.softness, s.azimuth, s.elevation].every(\n      (v) => typeof v === \"number\" && Number.isFinite(v),\n    )\n  )\n    return null;\n  return {\n    text: Array.from(s.text, (char) =>\n      char.charCodeAt(0) < 32 ? \" \" : char,\n    ).join(\"\"),\n    motif: s.motif,\n    material: s.material,\n    recessed: s.recessed,\n    depth: bound(s.depth as number, 0, 18),\n    softness: bound(s.softness as number, 1, 8),\n    azimuth: (((s.azimuth as number) % 360) + 360) % 360,\n    elevation: bound(s.elevation as number, 10, 85),\n  };\n}\nexport function reliefLightAt(\n  x: number,\n  y: number,\n): Pick<ReliefState, \"azimuth\" | \"elevation\"> {\n  const dx = x - 0.5,\n    dy = y - 0.5;\n  return {\n    azimuth: Math.round(((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360),\n    elevation: Math.round(bound(85 - Math.hypot(dx, dy) * 125, 10, 85)),\n  };\n}\nconst escapeXml = (v: string) =>\n  v.replace(\n    /[<>&\"']/g,\n    (c) =>\n      ({\n        \"<\": \"&lt;\",\n        \">\": \"&gt;\",\n        \"&\": \"&amp;\",\n        '\"': \"&quot;\",\n        \"'\": \"&apos;\",\n      })[c]!,\n  );\nexport function reliefSvg(input: ReliefState, identifier = \"relief\"): string {\n  const s = parseRelief(input) ?? RELIEF_DEFAULT;\n  const id = identifier.replace(/[^a-zA-Z0-9_-]/g, \"\") || \"relief\";\n  const mat = RELIEF_MATERIALS[s.material];\n  const height = s.depth * (s.recessed ? -1 : 1);\n  const fontSize = Math.min(350, 560 / Math.max(1, s.text.length * 0.8));\n  const inscription =\n    s.motif === \"type\"\n      ? `<text x=\"400\" y=\"430\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"Georgia,serif\" font-size=\"${fontSize}\" letter-spacing=\"${-fontSize * 0.035}\" fill=\"white\">${escapeXml(s.text)}</text>`\n      : `<g fill=\"none\" stroke=\"white\"><circle cx=\"400\" cy=\"400\" r=\"215\" stroke-width=\"6\"/><circle cx=\"400\" cy=\"400\" r=\"198\" stroke-width=\"2\"/>${Array.from({ length: 36 }, (_, i) => `<path d=\"M400 236Q${440 + (i % 2) * 32} 350 400 400Q${360 - (i % 2) * 32} 450 400 564\" stroke-width=\"3\" transform=\"rotate(${i * 10} 400 400)\"/>`).join(\"\")}</g>`;\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"800\" viewBox=\"0 0 800 800\" role=\"img\" aria-label=\"${escapeXml(s.recessed ? \"Debossed\" : \"Embossed\")} ${escapeXml(s.motif === \"type\" ? s.text : \"ornamental seal\")} in ${mat.name}\"><title>The Relief Press · Chromologium</title><desc>Authored SVG bump-map study. Not a measured material simulation. Recipe: ${escapeXml(JSON.stringify(s))}. https://chromologium.com/lab/relief</desc><defs>\n    <filter id=\"${id}-relief\" filterUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"800\" height=\"800\" color-interpolation-filters=\"sRGB\">\n      <feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"${s.softness}\" result=\"height\"/>\n      <feDiffuseLighting in=\"height\" surfaceScale=\"${height}\" diffuseConstant=\"1\" lighting-color=\"white\" result=\"diffuse\"><feDistantLight azimuth=\"${s.azimuth}\" elevation=\"${s.elevation}\"/></feDiffuseLighting>\n      <feComponentTransfer in=\"diffuse\" result=\"ambient\"><feFuncR type=\"linear\" slope=\".55\" intercept=\"${1 - 0.55 * Math.sin((s.elevation * Math.PI) / 180)}\"/><feFuncG type=\"linear\" slope=\".55\" intercept=\"${1 - 0.55 * Math.sin((s.elevation * Math.PI) / 180)}\"/><feFuncB type=\"linear\" slope=\".55\" intercept=\"${1 - 0.55 * Math.sin((s.elevation * Math.PI) / 180)}\"/></feComponentTransfer>\n      <feFlood flood-color=\"${mat.color}\" result=\"paper\"/>\n      <feComposite in=\"paper\" in2=\"ambient\" operator=\"arithmetic\" k1=\"1\" k2=\"0\" k3=\"0\" k4=\"0\" result=\"matte\"/>\n      <feSpecularLighting in=\"height\" surfaceScale=\"${height}\" specularConstant=\"${mat.specular}\" specularExponent=\"${mat.exponent}\" lighting-color=\"white\" result=\"glint\"><feDistantLight azimuth=\"${s.azimuth}\" elevation=\"${s.elevation}\"/></feSpecularLighting>\n      <feComposite in=\"matte\" in2=\"glint\" operator=\"arithmetic\" k1=\"0\" k2=\"1\" k3=\".6\" k4=\"0\"/>\n    </filter>\n    <filter id=\"${id}-grain\" x=\"0\" y=\"0\" width=\"100%\" height=\"100%\"><feTurbulence type=\"fractalNoise\" baseFrequency=\".72\" numOctaves=\"3\" seed=\"16\"/><feColorMatrix type=\"saturate\" values=\"0\"/></filter>\n  </defs><rect width=\"800\" height=\"800\" fill=\"${mat.color}\"/><g filter=\"url(#${id}-relief)\">${inscription}</g><rect width=\"800\" height=\"800\" filter=\"url(#${id}-grain)\" opacity=\".045\" style=\"mix-blend-mode:multiply\"/>\n  <g fill=\"none\" stroke=\"${mat.label}\" opacity=\".4\"><rect x=\"40\" y=\"40\" width=\"720\" height=\"720\"/><path d=\"M40 74h720M40 726h720\"/></g><g fill=\"${mat.label}\" font-family=\"monospace\" font-size=\"11\" letter-spacing=\"2\"><text x=\"60\" y=\"62\">CHROMOLOGIUM / RELIEF STUDIES</text><text x=\"60\" y=\"751\">${s.recessed ? \"DEBOSSED\" : \"EMBOSSED\"} / ${mat.name.toUpperCase()}</text><text x=\"648\" y=\"751\">FIG. 016</text></g></svg>`;\n}\n",
      "type": "registry:lib",
      "target": "lib/render/relief.ts"
    }
  ],
  "docs": "Import ReliefSurface from components/ReliefSurface and RELIEF_DEFAULT, reliefSvg from lib/render/relief. Store ReliefState in React state and pass state plus onChange={light => setState(old => ({...old,...light}))}. Omit onChange for a static surface. reliefSvg(state) returns a complete SVG with recipe and source. No animation loop, external texture or WebGL required. Text depends on installed serif fonts; the seal is vector geometry. SVG filter support is required. Materials are authored, not measured. Source: https://chromologium.com/lab/relief.",
  "author": "The Chromologium",
  "homepage": "https://chromologium.com",
  "license": "MIT"
}
