{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "etching",
  "title": "Etching",
  "description": "Renders an image as a copperplate engraving on canvas: hatch lines that bow around the forms and swell with the shadows, cross-hatching in the darks, a third pass in the deepest shadows, and ink on paper with plate tone. The lines cut themselves in, stroke by stroke, when it scrolls into view, or follow a controlled progress value.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/etching/etching.tsx",
      "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\n/*\r\n * Canvas, not SVG. A plate is a few hundred hatch lines of a few hundred samples each, and every line\r\n * swells and thins along its length. SVG can only do that with a filled outline per line or a path per\r\n * segment: tens of thousands of nodes to lay out, style and keep in the DOM, and stroke-dashoffset does\r\n * not work on filled outlines anyway. Ink only ever adds, so the canvas draws just the newly cut stretch\r\n * of each line every frame, and over the whole animation each segment is drawn exactly once.\r\n */\r\n\r\ntype Source = HTMLImageElement | HTMLCanvasElement | ImageBitmap\r\n\r\n/** One cut: x, y, width triplets in CSS px, plus when it starts and how long it takes, as fractions of the run. */\r\ntype Line = { pts: Float32Array; start: number; dur: number }\r\n\r\n/** A tone map on a coarse grid. `k` is CSS px per cell. */\r\ntype Grid = { data: Float32Array; w: number; h: number; k: number }\r\n\r\n/** The three passes of the burin. Cross-hatching only enters the darker tones, the third pass only the deepest. */\r\nconst PASSES = [\r\n  // A hairline survives in the lights; only the brightest highlights are left as bare paper.\r\n  { turn: 0, from: 0, to: 0.62, weight: (t: number) => (t < 0.035 ? 0 : 0.1 + 0.8 * t) },\r\n  { turn: 62, from: 0.34, to: 0.88, weight: (t: number) => 0.7 * Math.max(0, (t - 0.48) / 0.52) },\r\n  { turn: -48, from: 0.6, to: 1, weight: (t: number) => 0.55 * Math.max(0, (t - 0.74) / 0.26) },\r\n]\r\n\r\n/** Thinner than this and the burin is lifted. */\r\nconst MIN_WIDTH = 0.3\r\n\r\nfunction prng(seed: number) {\r\n  let a = seed >>> 0 || 1\r\n  return () => {\r\n    a = (a + 0x6d2b79f5) | 0\r\n    let t = Math.imul(a ^ (a >>> 15), 1 | a)\r\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\r\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\r\n  }\r\n}\r\n\r\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n))\r\n\r\nfunction sourceSize(image: Source): [number, number] {\r\n  if (\"naturalWidth\" in image && image.naturalWidth) return [image.naturalWidth, image.naturalHeight]\r\n  return [image.width, image.height]\r\n}\r\n\r\n/** Separable box blur, in place. Two passes are close enough to a Gaussian for a tone map. */\r\nfunction boxBlur(src: Float32Array, w: number, h: number, r: number) {\r\n  if (r < 1) return src\r\n  const tmp = new Float32Array(src.length)\r\n  const span = r * 2 + 1\r\n  for (let pass = 0; pass < 2; pass++) {\r\n    for (let y = 0; y < h; y++) {\r\n      let acc = 0\r\n      for (let x = -r; x <= r; x++) acc += src[y * w + Math.min(w - 1, Math.max(0, x))]\r\n      for (let x = 0; x < w; x++) {\r\n        tmp[y * w + x] = acc / span\r\n        acc += src[y * w + Math.min(w - 1, x + r + 1)] - src[y * w + Math.max(0, x - r)]\r\n      }\r\n    }\r\n    for (let x = 0; x < w; x++) {\r\n      let acc = 0\r\n      for (let y = -r; y <= r; y++) acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x]\r\n      for (let y = 0; y < h; y++) {\r\n        src[y * w + x] = acc / span\r\n        acc += tmp[Math.min(h - 1, y + r + 1) * w + x] - tmp[Math.max(0, y - r) * w + x]\r\n      }\r\n    }\r\n  }\r\n  return src\r\n}\r\n\r\n/** Reads the image as darkness, 0 for paper to 1 for full ink. Throws if a cross-origin image has no CORS headers. */\r\nfunction readTone(image: Source, width: number, height: number, fit: \"cover\" | \"contain\"): [Grid, Grid] {\r\n  const k = 2\r\n  const w = Math.max(1, Math.ceil(width / k))\r\n  const h = Math.max(1, Math.ceil(height / k))\r\n  const off = document.createElement(\"canvas\")\r\n  off.width = w\r\n  off.height = h\r\n  const ctx = off.getContext(\"2d\", { willReadFrequently: true })\r\n  if (!ctx) throw new Error(\"No 2D context\")\r\n  const [iw, ih] = sourceSize(image)\r\n  const scale = fit === \"cover\" ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih)\r\n  ctx.imageSmoothingQuality = \"high\"\r\n  ctx.drawImage(image, (w - iw * scale) / 2, (h - ih * scale) / 2, iw * scale, ih * scale)\r\n  const px = ctx.getImageData(0, 0, w, h).data\r\n  const tone = new Float32Array(w * h)\r\n  const histogram = new Uint32Array(256)\r\n  for (let i = 0; i < tone.length; i++) {\r\n    const lum = (0.2126 * px[i * 4] + 0.7152 * px[i * 4 + 1] + 0.0722 * px[i * 4 + 2]) / 255\r\n    tone[i] = 1 - lum\r\n    if (px[i * 4 + 3] > 127) histogram[Math.round(tone[i] * 255)]++\r\n  }\r\n  // Auto levels: stretch the 2nd to 98th percentile to the full range, so a grey photo still gets bare\r\n  // paper in its lights and solid ink in its darks.\r\n  const total = histogram.reduce((a, b) => a + b, 0)\r\n  let lo = 0\r\n  let hi = 255\r\n  for (let n = 0; n < total * 0.02 && lo < 255; lo++) n += histogram[lo]\r\n  for (let n = 0; n < total * 0.02 && hi > 0; hi--) n += histogram[hi]\r\n  const range = Math.max(0.1, (hi - lo) / 255)\r\n  for (let i = 0; i < tone.length; i++) {\r\n    // Then lift the mid-tones: an engraver leaves them to a single open hatch and saves the crossing for shadow.\r\n    tone[i] = clamp01((tone[i] - lo / 255) / range) ** 1.2 * (px[i * 4 + 3] / 255)\r\n  }\r\n  const soft = boxBlur(Float32Array.from(tone), w, h, Math.max(2, Math.round(Math.min(w, h) / 40)))\r\n  return [\r\n    { data: boxBlur(tone, w, h, 1), w, h, k },\r\n    { data: soft, w, h, k },\r\n  ]\r\n}\r\n\r\nfunction sample(g: Grid, x: number, y: number) {\r\n  const gx = Math.min(g.w - 1.001, Math.max(0, x / g.k - 0.5))\r\n  const gy = Math.min(g.h - 1.001, Math.max(0, y / g.k - 0.5))\r\n  const x0 = gx | 0\r\n  const y0 = gy | 0\r\n  const fx = gx - x0\r\n  const fy = gy - y0\r\n  const i = y0 * g.w + x0\r\n  const top = g.data[i] + (g.data[i + 1] - g.data[i]) * fx\r\n  const bottom = g.data[i + g.w] + (g.data[i + g.w + 1] - g.data[i + g.w]) * fx\r\n  return top + (bottom - top) * fy\r\n}\r\n\r\n/**\r\n * Lays out every hatch line. Each runs at the pass angle, bows in a gentle arc, and swerves with the\r\n * blurred tone so the hatching wraps the forms the way an engraver's lines follow a cheek or a fold.\r\n */\r\nfunction cutPlate(\r\n  tone: Grid,\r\n  soft: Grid,\r\n  width: number,\r\n  height: number,\r\n  { spacing, angle, curve, layers, seed }: { spacing: number; angle: number; curve: number; layers: number; seed: number }\r\n) {\r\n  const rand = prng(seed)\r\n  const lines: Line[] = []\r\n  const sp = Math.max(2, spacing)\r\n  const cx = width / 2\r\n  const cy = height / 2\r\n  const reach = Math.hypot(width, height) / 2 + sp * 2\r\n  const step = Math.min(3, Math.max(1.5, sp * 0.5))\r\n  const c = clamp01(curve)\r\n\r\n  PASSES.slice(0, Math.min(3, Math.max(1, Math.round(layers)))).forEach((pass, index) => {\r\n    const a = ((angle + pass.turn) * Math.PI) / 180\r\n    const dx = Math.cos(a)\r\n    const dy = Math.sin(a)\r\n    const bow = (index % 2 ? -1 : 1) * c * reach * 0.12\r\n    const runs: Float32Array[] = []\r\n\r\n    for (let v = -reach + (index * sp) / 3; v <= reach; v += sp) {\r\n      let run: number[] = []\r\n      let inked = false\r\n      const phase = rand() * 100\r\n      const flush = () => {\r\n        if (inked && run.length >= 12) runs.push(Float32Array.from(run))\r\n        run = []\r\n        inked = false\r\n      }\r\n      for (let u = -reach; u <= reach; u += step) {\r\n        const s = u / reach\r\n        let x = cx + dx * u - dy * v\r\n        let y = cy + dy * u + dx * v\r\n        // Bow, contour swerve, and a slight tremor of the hand.\r\n        const shift =\r\n          bow * (s * s - 0.35) + c * sp * 2.4 * (sample(soft, x, y) - 0.5) + Math.sin(u * 0.03 + phase) * sp * 0.06\r\n        x -= dy * shift\r\n        y += dx * shift\r\n        if (x < -sp || y < -sp || x > width + sp || y > height + sp) {\r\n          flush()\r\n          continue\r\n        }\r\n        const pressure = 0.9 + 0.2 * (0.5 + 0.5 * Math.sin(u * 0.045 + phase * 1.7))\r\n        const w = sp * pass.weight(sample(tone, x, y)) * pressure\r\n        if (w >= MIN_WIDTH) inked = true\r\n        run.push(x, y, w)\r\n      }\r\n      flush()\r\n    }\r\n\r\n    // Cut in order across the plate, each line overlapping the next few, with a little irregularity.\r\n    const span = pass.to - pass.from\r\n    runs.forEach((pts, i) => {\r\n      const dur = span * (0.3 + rand() * 0.12)\r\n      const at = runs.length > 1 ? i / (runs.length - 1) : 0\r\n      const start = Math.max(0, pass.from + at * (span - dur) + (rand() - 0.5) * 0.05)\r\n      lines.push({ pts, start, dur: Math.min(dur, 1 - start) })\r\n    })\r\n  })\r\n  return lines\r\n}\r\n\r\n/** How far along a line the burin is at plate time `p`. Each stroke eases out as the cut finishes. */\r\nfunction along(line: Line, p: number) {\r\n  const x = clamp01((p - line.start) / line.dur)\r\n  return 1 - (1 - x) * (1 - x)\r\n}\r\n\r\n/**\r\n * Inks everything cut between plate times `p0` and `p1`. Segments are filled as overlapping quads in\r\n * one path, so they union cleanly with no seams where the width changes.\r\n */\r\nfunction engrave(ctx: CanvasRenderingContext2D, lines: Line[], p0: number, p1: number) {\r\n  let quads = 0\r\n  ctx.beginPath()\r\n  for (const line of lines) {\r\n    const a = along(line, p0)\r\n    const b = along(line, p1)\r\n    if (b <= a) continue\r\n    const pts = line.pts\r\n    const last = pts.length / 3 - 1\r\n    const end = Math.floor(b * last)\r\n    for (let j = Math.floor(a * last); j < end; j++) {\r\n      const i = j * 3\r\n      const w0 = pts[i + 2]\r\n      const w1 = pts[i + 5]\r\n      if (w0 < MIN_WIDTH && w1 < MIN_WIDTH) continue\r\n      const x0 = pts[i]\r\n      const y0 = pts[i + 1]\r\n      const x1 = pts[i + 3]\r\n      const y1 = pts[i + 4]\r\n      const len = Math.hypot(x1 - x0, y1 - y0) || 1\r\n      // Overshoot each end a touch so neighbouring quads, even from different frames, overlap.\r\n      const ux = ((x1 - x0) / len) * 0.35\r\n      const uy = ((y1 - y0) / len) * 0.35\r\n      const nx = -(y1 - y0) / len\r\n      const ny = (x1 - x0) / len\r\n      const h0 = w0 / 2\r\n      const h1 = w1 / 2\r\n      ctx.moveTo(x0 - ux + nx * h0, y0 - uy + ny * h0)\r\n      ctx.lineTo(x1 + ux + nx * h1, y1 + uy + ny * h1)\r\n      ctx.lineTo(x1 + ux - nx * h1, y1 + uy - ny * h1)\r\n      ctx.lineTo(x0 - ux - nx * h0, y0 - uy - ny * h0)\r\n      ctx.closePath()\r\n      // Very long paths get slow to rasterise; flush in batches.\r\n      if (++quads % 4000 === 0) {\r\n        ctx.fill()\r\n        ctx.beginPath()\r\n      }\r\n    }\r\n  }\r\n  ctx.fill()\r\n}\r\n\r\n/** Paper, then plate tone: the thin film of ink left after wiping, heavier toward the edges, and a little grain. */\r\nfunction prepare(ctx: CanvasRenderingContext2D, width: number, height: number, ink: string, paper: string, seed: number) {\r\n  ctx.clearRect(0, 0, width, height)\r\n  if (paper === \"transparent\") return\r\n  ctx.fillStyle = paper\r\n  ctx.fillRect(0, 0, width, height)\r\n  ctx.fillStyle = ink\r\n  const tone = ctx.createRadialGradient(width / 2, height / 2, Math.min(width, height) * 0.25, width / 2, height / 2, Math.hypot(width, height) / 2)\r\n  tone.addColorStop(0, \"rgba(0,0,0,0)\")\r\n  tone.addColorStop(1, ink)\r\n  ctx.globalAlpha = 0.07\r\n  ctx.fillStyle = tone\r\n  ctx.fillRect(0, 0, width, height)\r\n  const rand = prng(seed + 17)\r\n  ctx.fillStyle = ink\r\n  for (const alpha of [0.05, 0.09, 0.14]) {\r\n    ctx.globalAlpha = alpha\r\n    for (let n = (width * height) / 260; n > 0; n--) {\r\n      const s = 0.4 + rand() * 0.7\r\n      ctx.fillRect(rand() * width, rand() * height, s, s)\r\n    }\r\n  }\r\n  ctx.globalAlpha = 1\r\n}\r\n\r\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\"\r\nfunction subscribeReduced(onChange: () => void) {\r\n  const mq = window.matchMedia(reducedQuery)\r\n  mq.addEventListener(\"change\", onChange)\r\n  return () => mq.removeEventListener(\"change\", onChange)\r\n}\r\nfunction useReducedMotion() {\r\n  return React.useSyncExternalStore(\r\n    subscribeReduced,\r\n    () => window.matchMedia(reducedQuery).matches,\r\n    () => false\r\n  )\r\n}\r\n\r\nexport interface EtchingProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\r\n  /** Image URL. Cross-origin images must be served with CORS headers, or the canvas cannot read them. */\r\n  src: string\r\n  /** Accessible description. Leave empty for decorative images. */\r\n  alt?: string\r\n  /** Gap between hatch lines in CSS px. Smaller is finer and slower to cut. Default 4. */\r\n  lineSpacing?: number\r\n  /** Angle of the first pass of lines, in degrees. Default 35. */\r\n  angle?: number\r\n  /** How much the lines bow and swerve around the forms, 0 to 1. 0 is ruled straight. Default 0.5. */\r\n  curve?: number\r\n  /** Passes of the burin: 1 hatches, 2 adds cross-hatching in the darks, 3 a third pass in the deepest shadows. Default 3. */\r\n  layers?: 1 | 2 | 3\r\n  /** Ink colour. Any CSS colour, or \"currentColor\". Default \"#241d17\". */\r\n  ink?: string\r\n  /** Paper colour, or \"transparent\". Default \"#f4f1ea\". */\r\n  paper?: string\r\n  /** Engrave the lines in the first time it scrolls into view. Default true. */\r\n  draw?: boolean\r\n  /** Length of the engraving in ms. Default 2600. */\r\n  duration?: number\r\n  /** Controlled progress, 0 to 1. When set, it replaces the scroll-in animation. */\r\n  progress?: number\r\n  /** How the image fills the plate. Default \"cover\". */\r\n  fit?: \"cover\" | \"contain\"\r\n  /** Seed for the hand tremor and stroke order. Default 5. */\r\n  seed?: number\r\n  /** Default \"anonymous\". Pass undefined for same-origin images that must not send a CORS request. */\r\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\r\n  /** Content layered over the plate (captions, badges). */\r\n  children?: React.ReactNode\r\n}\r\n\r\n/**\r\n * An image as a copperplate engraving: hatch lines that swell with the shadows, cross-hatching in\r\n * the darks, ink on paper with plate tone. The lines cut themselves in when it scrolls into view.\r\n */\r\nexport function Etching({\r\n  src,\r\n  alt = \"\",\r\n  lineSpacing = 4,\r\n  angle = 35,\r\n  curve = 0.5,\r\n  layers = 3,\r\n  ink = \"#241d17\",\r\n  paper = \"#f4f1ea\",\r\n  draw = true,\r\n  duration = 2600,\r\n  progress,\r\n  fit = \"cover\",\r\n  seed = 5,\r\n  crossOrigin = \"anonymous\",\r\n  className,\r\n  style,\r\n  children,\r\n  ...props\r\n}: EtchingProps) {\r\n  const ref = React.useRef<HTMLDivElement>(null)\r\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\r\n  const [image, setImage] = React.useState<HTMLImageElement | null>(null)\r\n  const [ratio, setRatio] = React.useState<number | undefined>(undefined)\r\n  const reduced = useReducedMotion()\r\n  const controlled = progress !== undefined\r\n\r\n  const progressRef = React.useRef(progress ?? 0)\r\n  const paintRef = React.useRef<(() => void) | undefined>(undefined)\r\n  // Survives re-renders and option changes, so changing line spacing after the run does not replay it.\r\n  const clockRef = React.useRef({ elapsed: 0, done: false })\r\n\r\n  React.useEffect(() => {\r\n    let cancelled = false\r\n    const img = new Image()\r\n    if (crossOrigin) img.crossOrigin = crossOrigin\r\n    img.decoding = \"async\"\r\n    img.onload = () => {\r\n      if (cancelled) return\r\n      setImage(img)\r\n      setRatio(img.naturalWidth / img.naturalHeight)\r\n    }\r\n    img.onerror = () => {\r\n      if (!cancelled) setImage(null)\r\n    }\r\n    img.src = src\r\n    return () => {\r\n      cancelled = true\r\n    }\r\n  }, [src, crossOrigin])\r\n\r\n  React.useEffect(() => {\r\n    const el = ref.current\r\n    const canvas = canvasRef.current\r\n    const ctx = canvas?.getContext(\"2d\")\r\n    if (!el || !canvas || !ctx || !image) return\r\n\r\n    const clock = clockRef.current\r\n    const animate = draw && !reduced && !controlled\r\n    let lines: Line[] = []\r\n    let ready = false\r\n    let drawn = 0\r\n    let width = 0\r\n    let height = 0\r\n    let colors = { ink, paper }\r\n    let raf = 0\r\n    let build = 0\r\n    let last = 0\r\n    let visible = false\r\n    let seen = false\r\n\r\n    const target = () => {\r\n      if (controlled) return clamp01(progressRef.current)\r\n      if (!animate || clock.done) return 1\r\n      // A soft ease-out: brisk at first, still cutting near the end, never a mechanical constant rate.\r\n      return 1 - (1 - clamp01(clock.elapsed / duration)) ** 1.4\r\n    }\r\n\r\n    const reset = () => {\r\n      prepare(ctx, width, height, colors.ink, colors.paper, seed)\r\n      ctx.fillStyle = colors.ink\r\n      drawn = 0\r\n    }\r\n\r\n    const paint = () => {\r\n      if (!ready) return\r\n      const p = target()\r\n      if (p < drawn) reset()\r\n      if (p > drawn) {\r\n        engrave(ctx, lines, drawn, p)\r\n        drawn = p\r\n      }\r\n    }\r\n\r\n    const rebuild = () => {\r\n      const rect = el.getBoundingClientRect()\r\n      if (!rect.width || !rect.height) return\r\n      width = rect.width\r\n      height = rect.height\r\n      const dpr = Math.min(window.devicePixelRatio || 1, 2)\r\n      canvas.width = Math.round(width * dpr)\r\n      canvas.height = Math.round(height * dpr)\r\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\r\n      const style = getComputedStyle(el)\r\n      colors = {\r\n        ink: ink === \"currentColor\" ? style.color : ink,\r\n        paper: paper === \"currentColor\" ? style.color : paper,\r\n      }\r\n      try {\r\n        const [tone, soft] = readTone(image, width, height, fit)\r\n        lines = cutPlate(tone, soft, width, height, { spacing: lineSpacing, angle, curve, layers, seed })\r\n      } catch (error) {\r\n        console.warn(\"Etching: could not read the image pixels. Serve it from the same origin or with CORS headers.\", error)\r\n        return\r\n      }\r\n      ready = true\r\n      reset()\r\n      paint()\r\n    }\r\n\r\n    const tick = (now: number) => {\r\n      clock.elapsed += Math.min(64, now - last)\r\n      last = now\r\n      if (clock.elapsed >= duration) clock.done = true\r\n      paint()\r\n      raf = clock.done ? 0 : requestAnimationFrame(tick)\r\n    }\r\n    const play = () => {\r\n      if (!animate || clock.done || raf || !seen || !visible || document.hidden) return\r\n      last = performance.now()\r\n      raf = requestAnimationFrame(tick)\r\n    }\r\n    const pause = () => {\r\n      cancelAnimationFrame(raf)\r\n      raf = 0\r\n    }\r\n\r\n    const ro = new ResizeObserver(() => {\r\n      cancelAnimationFrame(build)\r\n      build = requestAnimationFrame(rebuild)\r\n    })\r\n    ro.observe(el)\r\n    // Starts once a good part of the plate is on screen; pauses whenever it leaves.\r\n    const io = new IntersectionObserver(\r\n      ([entry]) => {\r\n        visible = entry.isIntersecting\r\n        if (entry.intersectionRatio >= 0.35) seen = true\r\n        if (visible) play()\r\n        else pause()\r\n      },\r\n      { threshold: [0, 0.35] }\r\n    )\r\n    io.observe(el)\r\n    const onVisibility = () => (document.hidden ? pause() : play())\r\n    document.addEventListener(\"visibilitychange\", onVisibility)\r\n    // Re-ink when the theme flips and a colour is inherited.\r\n    const mo =\r\n      ink === \"currentColor\" || paper === \"currentColor\"\r\n        ? new MutationObserver(() => {\r\n            cancelAnimationFrame(build)\r\n            build = requestAnimationFrame(rebuild)\r\n          })\r\n        : null\r\n    mo?.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] })\r\n    paintRef.current = paint\r\n\r\n    return () => {\r\n      pause()\r\n      cancelAnimationFrame(build)\r\n      ro.disconnect()\r\n      io.disconnect()\r\n      mo?.disconnect()\r\n      document.removeEventListener(\"visibilitychange\", onVisibility)\r\n      paintRef.current = undefined\r\n    }\r\n  }, [image, lineSpacing, angle, curve, layers, ink, paper, draw, duration, fit, seed, reduced, controlled])\r\n\r\n  React.useEffect(() => {\r\n    progressRef.current = progress ?? 0\r\n    paintRef.current?.()\r\n  }, [progress])\r\n\r\n  return (\r\n    <div\r\n      ref={ref}\r\n      data-slot=\"etching\"\r\n      // The image's own ratio is only a fallback class, so an aspect-* class or a height wins.\r\n      className={cn(\"relative aspect-(--etching-ratio) overflow-hidden\", className)}\r\n      style={{ \"--etching-ratio\": ratio, backgroundColor: paper, ...style } as React.CSSProperties}\r\n      {...props}\r\n    >\r\n      <canvas\r\n        ref={canvasRef}\r\n        className=\"absolute inset-0 size-full\"\r\n        role={alt ? \"img\" : undefined}\r\n        aria-label={alt || undefined}\r\n        aria-hidden={alt ? undefined : true}\r\n      />\r\n      {children}\r\n    </div>\r\n  )\r\n}\r\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "addedAt": "2026-10-27"
  },
  "type": "registry:component"
}