{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-crop-mat",
  "title": "Image Crop Mat",
  "description": "Image cropping as a passe-partout: four mat boards slide over a photo to define the crop window, snap to ratio presets with a settle, and show a rule-of-thirds grid only while dragging.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/image-crop-mat/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// MatCrop — image cropping as a passe-partout: four mat boards slide over a\n// photo to define the crop window, rather than a bounding-box overlay with\n// dashed handles. Dragging any edge or corner moves that mat directly\n// (1:1 with the pointer — a deliberate simplification of \"paper friction\",\n// reserved below for the one place it's most visible); ratio presets snap\n// the mats to the nearest valid rect at that ratio with a short, deliberately\n// no-overshoot ease (standing in for \"critically damped, no bounce\" without\n// a literal spring simulation). The exposed window shows a rule-of-thirds\n// grid only while actively dragging.\n// ---------------------------------------------------------------------------\n\nexport interface CropRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  ratio: string;\n}\n\nexport interface MatCropProps {\n  /** called with the new crop rect on every drag/resize */\n  onChange?: (rect: CropRect) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DISPLAY_W = 480;\nconst DISPLAY_H = 300;\nconst NATURAL_SCALE = 4; // 1 display px = 4 \"real photo\" px, for a realistic readout\nconst MIN_SIZE = 60;\n\ntype Rect = { top: number; left: number; width: number; height: number };\ntype EdgeHandle = \"top\" | \"right\" | \"bottom\" | \"left\";\ntype CornerHandle = \"tl\" | \"tr\" | \"bl\" | \"br\";\n\nconst RATIO_PRESETS: { label: string; ratio: number | null }[] = [\n  { label: \"1:1\", ratio: 1 },\n  { label: \"4:5\", ratio: 4 / 5 },\n  { label: \"16:9\", ratio: 16 / 9 },\n  { label: \"Free\", ratio: null },\n];\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, n));\n}\n\nfunction ratioLabelFor(width: number, height: number): string {\n  const r = width / height;\n  for (const preset of RATIO_PRESETS) {\n    if (preset.ratio && Math.abs(r - preset.ratio) < 0.02) return preset.label;\n  }\n  return \"Free\";\n}\n\nfunction fitRatio(current: Rect, ratio: number): Rect {\n  const cx = current.left + current.width / 2;\n  const cy = current.top + current.height / 2;\n  let w = DISPLAY_W * 0.82;\n  let h = w / ratio;\n  if (h > DISPLAY_H * 0.82) {\n    h = DISPLAY_H * 0.82;\n    w = h * ratio;\n  }\n  const left = clamp(cx - w / 2, 0, DISPLAY_W - w);\n  const top = clamp(cy - h / 2, 0, DISPLAY_H - h);\n  return { top, left, width: w, height: h };\n}\n\nexport function MatCrop({ onChange, className = \"\" }: MatCropProps) {\n  const autoId = useId().replace(/:/g, \"\");\n  const stageRef = useRef<HTMLDivElement | null>(null);\n  const topMatRef = useRef<HTMLDivElement | null>(null);\n  const rightMatRef = useRef<HTMLDivElement | null>(null);\n  const bottomMatRef = useRef<HTMLDivElement | null>(null);\n  const leftMatRef = useRef<HTMLDivElement | null>(null);\n  const windowRef = useRef<HTMLDivElement | null>(null);\n  const rectRef = useRef<Rect>({ top: 40, left: 60, width: 360, height: 220 });\n  const dragRef = useRef<{ handle: EdgeHandle | CornerHandle; startX: number; startY: number; start: Rect } | null>(\n    null\n  );\n  const reducedRef = useRef(false);\n\n  const [rect, setRect] = useState<Rect>(rectRef.current);\n  const [dragging, setDragging] = useState(false);\n  const [activePreset, setActivePreset] = useState(\"Free\");\n  const [announce, setAnnounce] = useState(\"\");\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChangeMq = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChangeMq);\n    return () => mq.removeEventListener(\"change\", onChangeMq);\n  }, []);\n\n  const paint = useCallback((r: Rect, animate: boolean) => {\n    const ms = animate && !reducedRef.current ? 180 : 0;\n    const ease = \"cubic-bezier(0.2, 0.75, 0.25, 1)\"; // no-overshoot ease-out\n    const apply = (el: HTMLElement | null, styles: Partial<CSSStyleDeclaration>) => {\n      if (!el) return;\n      el.style.transition = ms > 0 ? `top ${ms}ms ${ease}, left ${ms}ms ${ease}, right ${ms}ms ${ease}, bottom ${ms}ms ${ease}, width ${ms}ms ${ease}, height ${ms}ms ${ease}` : \"none\";\n      Object.assign(el.style, styles);\n    };\n    apply(topMatRef.current, { height: `${r.top}px` });\n    apply(bottomMatRef.current, { top: `${r.top + r.height}px`, height: `${DISPLAY_H - r.top - r.height}px` });\n    apply(leftMatRef.current, { top: `${r.top}px`, height: `${r.height}px`, width: `${r.left}px` });\n    apply(rightMatRef.current, {\n      top: `${r.top}px`,\n      height: `${r.height}px`,\n      left: `${r.left + r.width}px`,\n      width: `${DISPLAY_W - r.left - r.width}px`,\n    });\n    apply(windowRef.current, { top: `${r.top}px`, left: `${r.left}px`, width: `${r.width}px`, height: `${r.height}px` });\n  }, []);\n\n  const commit = useCallback(\n    (r: Rect, animate: boolean) => {\n      rectRef.current = r;\n      setRect(r);\n      paint(r, animate);\n      const label = ratioLabelFor(r.width, r.height);\n      setActivePreset(label);\n      onChange?.({\n        x: Math.round(r.left * NATURAL_SCALE),\n        y: Math.round(r.top * NATURAL_SCALE),\n        width: Math.round(r.width * NATURAL_SCALE),\n        height: Math.round(r.height * NATURAL_SCALE),\n        ratio: label,\n      });\n    },\n    [onChange, paint]\n  );\n\n  useEffect(() => {\n    paint(rectRef.current, false);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const applyPreset = useCallback(\n    (label: string, ratio: number | null) => {\n      const next = ratio ? fitRatio(rectRef.current, ratio) : rectRef.current;\n      commit(next, true);\n      setAnnounce(`Crop set to ${label}.`);\n    },\n    [commit]\n  );\n\n  const onEdgePointerDown = useCallback((handle: EdgeHandle | CornerHandle) => (e: ReactPointerEvent) => {\n    (e.target as Element).setPointerCapture(e.pointerId);\n    dragRef.current = { handle, startX: e.clientX, startY: e.clientY, start: { ...rectRef.current } };\n    setDragging(true);\n  }, []);\n\n  const onEdgePointerMove = useCallback(\n    (e: ReactPointerEvent) => {\n      const drag = dragRef.current;\n      if (!drag) return;\n      const dx = e.clientX - drag.startX;\n      const dy = e.clientY - drag.startY;\n      const s = drag.start;\n      let { top, left, width, height } = s;\n\n      const adjustTop = () => {\n        const newTop = clamp(s.top + dy, 0, s.top + s.height - MIN_SIZE);\n        height = s.height - (newTop - s.top);\n        top = newTop;\n      };\n      const adjustBottom = () => {\n        height = clamp(s.height + dy, MIN_SIZE, DISPLAY_H - s.top);\n      };\n      const adjustLeft = () => {\n        const newLeft = clamp(s.left + dx, 0, s.left + s.width - MIN_SIZE);\n        width = s.width - (newLeft - s.left);\n        left = newLeft;\n      };\n      const adjustRight = () => {\n        width = clamp(s.width + dx, MIN_SIZE, DISPLAY_W - s.left);\n      };\n\n      if (drag.handle === \"top\" || drag.handle === \"tl\" || drag.handle === \"tr\") adjustTop();\n      if (drag.handle === \"bottom\" || drag.handle === \"bl\" || drag.handle === \"br\") adjustBottom();\n      if (drag.handle === \"left\" || drag.handle === \"tl\" || drag.handle === \"bl\") adjustLeft();\n      if (drag.handle === \"right\" || drag.handle === \"tr\" || drag.handle === \"br\") adjustRight();\n\n      const next = { top, left, width, height };\n      rectRef.current = next;\n      paint(next, false);\n      setRect(next);\n    },\n    [paint]\n  );\n\n  const onEdgePointerUp = useCallback(() => {\n    if (!dragRef.current) return;\n    dragRef.current = null;\n    setDragging(false);\n    commit(rectRef.current, false);\n  }, [commit]);\n\n  const nudge = useCallback(\n    (handle: EdgeHandle, deltaPx: number) => {\n      const s = rectRef.current;\n      let { top, left, width, height } = s;\n      if (handle === \"top\") {\n        const newTop = clamp(s.top + deltaPx, 0, s.top + s.height - MIN_SIZE);\n        height = s.height - (newTop - s.top);\n        top = newTop;\n      } else if (handle === \"bottom\") {\n        height = clamp(s.height + deltaPx, MIN_SIZE, DISPLAY_H - s.top);\n      } else if (handle === \"left\") {\n        const newLeft = clamp(s.left + deltaPx, 0, s.left + s.width - MIN_SIZE);\n        width = s.width - (newLeft - s.left);\n        left = newLeft;\n      } else {\n        width = clamp(s.width + deltaPx, MIN_SIZE, DISPLAY_W - s.left);\n      }\n      commit({ top, left, width, height }, false);\n    },\n    [commit]\n  );\n\n  const onSliderKeyDown = useCallback(\n    (handle: EdgeHandle) => (e: ReactKeyboardEvent) => {\n      const step = e.shiftKey ? 10 : 1;\n      if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        nudge(handle, -step);\n      } else if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") {\n        e.preventDefault();\n        nudge(handle, step);\n      }\n    },\n    [nudge]\n  );\n\n  const readout = useMemo(() => {\n    const w = Math.round(rect.width * NATURAL_SCALE);\n    const h = Math.round(rect.height * NATURAL_SCALE);\n    return `${w} x ${h} — ${activePreset}`;\n  }, [rect, activePreset]);\n\n  const edgeAria = (handle: EdgeHandle, label: string, value: number, max: number) => ({\n    role: \"slider\" as const,\n    tabIndex: 0,\n    \"aria-label\": label,\n    \"aria-valuemin\": 0,\n    \"aria-valuemax\": Math.round(max * NATURAL_SCALE),\n    \"aria-valuenow\": Math.round(value * NATURAL_SCALE),\n    onKeyDown: onSliderKeyDown(handle),\n  });\n\n  return (\n    <div className={`w-full max-w-md ${className}`}>\n      <style>{CSS}</style>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announce}\n      </span>\n\n      <div\n        ref={stageRef}\n        className=\"ns-mc-stage relative select-none overflow-hidden rounded-[12px] border border-border\"\n        style={{ width: DISPLAY_W, height: DISPLAY_H }}\n        onPointerMove={onEdgePointerMove}\n        onPointerUp={onEdgePointerUp}\n        onPointerCancel={onEdgePointerUp}\n      >\n        <img src={PHOTO_SRC} alt=\"\" aria-hidden=\"true\" className=\"absolute inset-0 h-full w-full\" />\n\n        {/* rule-of-thirds grid, visible only while dragging */}\n        <div\n          ref={windowRef}\n          aria-hidden=\"true\"\n          className={`ns-mc-window pointer-events-none absolute ${dragging ? \"ns-mc-grid\" : \"\"}`}\n        />\n\n        {/* mat boards */}\n        <div ref={topMatRef} className=\"ns-mc-mat absolute inset-x-0 top-0 border-b border-border bg-background\" />\n        <div ref={bottomMatRef} className=\"ns-mc-mat absolute inset-x-0 border-t border-border bg-background\" />\n        <div ref={leftMatRef} className=\"ns-mc-mat absolute left-0 border-r border-border bg-background\" />\n        <div ref={rightMatRef} className=\"ns-mc-mat absolute border-l border-border bg-background\" />\n\n        {/* edge grip strips — drag + keyboard sliders */}\n        <div\n          {...edgeAria(\"top\", \"Top edge\", rect.top, DISPLAY_H)}\n          onPointerDown={onEdgePointerDown(\"top\")}\n          className=\"ns-mc-grip ns-mc-grip-h absolute inset-x-0 cursor-ns-resize\"\n          style={{ top: rect.top - 4 }}\n        />\n        <div\n          {...edgeAria(\"bottom\", \"Bottom edge\", DISPLAY_H - rect.top - rect.height, DISPLAY_H)}\n          onPointerDown={onEdgePointerDown(\"bottom\")}\n          className=\"ns-mc-grip ns-mc-grip-h absolute inset-x-0 cursor-ns-resize\"\n          style={{ top: rect.top + rect.height - 4 }}\n        />\n        <div\n          {...edgeAria(\"left\", \"Left edge\", rect.left, DISPLAY_W)}\n          onPointerDown={onEdgePointerDown(\"left\")}\n          className=\"ns-mc-grip ns-mc-grip-v absolute inset-y-0 cursor-ew-resize\"\n          style={{ left: rect.left - 4 }}\n        />\n        <div\n          {...edgeAria(\"right\", \"Right edge\", DISPLAY_W - rect.left - rect.width, DISPLAY_W)}\n          onPointerDown={onEdgePointerDown(\"right\")}\n          className=\"ns-mc-grip ns-mc-grip-v absolute inset-y-0 cursor-ew-resize\"\n          style={{ left: rect.left + rect.width - 4 }}\n        />\n\n        {/* corner handles */}\n        {(\n          [\n            [\"tl\", rect.left - 5, rect.top - 5, \"nwse-resize\"],\n            [\"tr\", rect.left + rect.width - 5, rect.top - 5, \"nesw-resize\"],\n            [\"bl\", rect.left - 5, rect.top + rect.height - 5, \"nesw-resize\"],\n            [\"br\", rect.left + rect.width - 5, rect.top + rect.height - 5, \"nwse-resize\"],\n          ] as [CornerHandle, number, number, string][]\n        ).map(([handle, left, top, cursor]) => (\n          <div\n            key={handle}\n            aria-hidden=\"true\"\n            onPointerDown={onEdgePointerDown(handle)}\n            className=\"ns-mc-corner absolute h-2.5 w-2.5\"\n            style={{ left, top, cursor }}\n          />\n        ))}\n      </div>\n\n      <div className=\"mt-4 flex items-center justify-between gap-4\">\n        <div className=\"flex gap-1.5\" role=\"group\" aria-label=\"Crop ratio presets\">\n          {RATIO_PRESETS.map((p) => (\n            <button\n              key={p.label}\n              type=\"button\"\n              onClick={() => applyPreset(p.label, p.ratio)}\n              aria-pressed={activePreset === p.label}\n              className={`rounded-[6px] border px-2.5 py-1 font-mono text-[11px] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent ${\n                activePreset === p.label\n                  ? \"border-foreground bg-foreground text-background\"\n                  : \"border-border text-ns-muted hover:border-foreground hover:text-foreground\"\n              }`}\n            >\n              {p.label}\n            </button>\n          ))}\n        </div>\n        <p className={`ns-mc-readout-${autoId} font-mono text-xs text-ns-muted`}>{readout}</p>\n      </div>\n    </div>\n  );\n}\n\n// Abstract monochrome placeholder \"photo\" (mountains + sun), fixed content\n// tones — a photo's own pixels aren't chrome, so they're exempt from the\n// theme-token rule the surrounding UI follows.\nconst PHOTO_SRC = `data:image/svg+xml,${encodeURIComponent(\n  '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"480\" height=\"300\" viewBox=\"0 0 480 300\"><rect width=\"480\" height=\"300\" fill=\"#3a3a3a\"/><circle cx=\"380\" cy=\"70\" r=\"34\" fill=\"#565656\"/><polygon points=\"0,300 140,140 230,230 300,120 480,300\" fill=\"#2a2a2a\"/><polygon points=\"0,300 90,200 180,300\" fill=\"#232323\"/></svg>'\n)}`;\n\nconst CSS = `\n.ns-mc-mat{ box-shadow: inset 0 0 10px rgba(0,0,0,0.35); }\n.ns-mc-window.ns-mc-grid::before, .ns-mc-window.ns-mc-grid::after{ content:\"\"; position:absolute; background: rgba(255,255,255,0.35); }\n.ns-mc-window::before{ left: 33.333%; top:0; bottom:0; width:1px; }\n.ns-mc-window::after{ left: 66.666%; top:0; bottom:0; width:1px; }\n.ns-mc-grip{ z-index: 5; }\n.ns-mc-grip-h{ height: 8px; }\n.ns-mc-grip-v{ width: 8px; }\n.ns-mc-grip-h::after{ content:\"\"; position:absolute; left:0; right:0; top:50%; height:0; border-top: 2px solid transparent; transform: translateY(-1px); }\n.ns-mc-grip-v::after{ content:\"\"; position:absolute; top:0; bottom:0; left:50%; width:0; border-left: 2px solid transparent; transform: translateX(-1px); }\n.ns-mc-grip:hover.ns-mc-grip-h::after, .ns-mc-grip:focus-visible.ns-mc-grip-h::after{ border-top-color: var(--ns-accent); }\n.ns-mc-grip:hover.ns-mc-grip-v::after, .ns-mc-grip:focus-visible.ns-mc-grip-v::after{ border-left-color: var(--ns-accent); }\n.ns-mc-grip:focus-visible{ outline: none; }\n.ns-mc-corner{ z-index: 6; }\n.ns-mc-corner:hover{ background: var(--ns-accent); border-radius: 9999px; }\n@media (prefers-reduced-motion: reduce){\n  .ns-mc-mat, .ns-mc-window{ transition: none !important; }\n}\n`;\n\nexport default MatCrop;\n",
      "type": "registry:ui",
      "target": "components/ui/image-crop-mat.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": [
      "image",
      "crop",
      "photo",
      "editor",
      "gesture",
      "accessibility"
    ],
    "instruction": "Build an image-crop control styled as a physical passe-partout (picture-frame mat), not a dashed-rectangle overlay with corner squares. A fixed-size stage (e.g. 480x300 display px, representing a photo scaled down from a larger 'real' resolution — pick a NATURAL_SCALE constant, e.g. 4x, and multiply every display-px measurement by it for the readout and the `onChange` payload, so the numbers look like a real photo's pixel dimensions rather than tiny CSS px values) holds a placeholder photo (a small inline monochrome SVG landscape rendered via a real `<img src=\"data:image/svg+xml,...\">`, alt=\"\", aria-hidden — never `dangerouslySetInnerHTML` for static markup, and never a literal photo asset dependency) and four mat boards (`bg-background` panels — real theme tokens, since the mats are UI chrome even though the photo's own pixels are exempt content) positioned top/right/bottom/left to cover everything outside the current crop rect, each with a hairline border on its inner edge and a soft inset box-shadow reads as pressing gently onto the photo.\n\nCrop state is one rect `{top, left, width, height}` in display px. Each mat's size is derived from it (top mat height = rect.top, bottom mat spans from rect.top+rect.height to the stage bottom, etc.) and painted via direct ref `style` writes, not by re-rendering four positioned divs from React on every pixel of drag — React state updates on drag are fine for cheap re-renders elsewhere (the readout text), but the four mat elements' actual position must be imperative writes so dragging never taxes React's reconciler per pointermove.\n\nDragging: a thin invisible 'grip' strip sits along each edge (and a small square at each corner, combining two edges) as the actual pointer-drag target — not the mat itself, so the hit area stays a predictable few px regardless of mat size. During an active drag, tracking is 1:1 with the pointer (a deliberate simplification — true continuous 'paper friction' physics on every pointermove was cut for scope; if revisited, exponential-smoothing the painted rect toward the raw pointer target each rAF tick would add it). Ratio presets (1:1, 4:5, 16:9, Free — real buttons, `aria-pressed` reflecting the currently-matching ratio) DO get the explicit 'settle' the brief calls for: fit the largest centered rect at that ratio within the stage, then paint it with a short (~180ms) no-overshoot ease-out transition (a stand-in for 'critically damped, no bounce' — an actual spring simulation is unnecessary to read as non-bouncy). Free does not force a resize, it just relabels. Reduced motion drops that transition — every preset and drag-release lands instantly.\n\nThe crop window shows a rule-of-thirds grid (two vertical hairlines via CSS pseudo-elements, never SVG dashes) ONLY while a drag is in progress (a `dragging` boolean toggling a class) — it must not be visible at rest.\n\nReadout: a Geist Mono line below the stage reads like `1240 x 775 — 16:9` (or 'Free' when the current rect doesn't match a preset within a small tolerance), computed from the NATURAL-scaled width/height.\n\nKeyboard: each edge doubles as a labeled `role=\"slider\"` (`aria-label` 'Top edge'/'Right edge'/etc., `aria-valuemin/max/now` in natural-scale units, `tabIndex=0`) layered on the same grip strip element used for pointer dragging. Arrow keys nudge that edge by 1 display px, Shift+Arrow by 10 — for simplicity every edge responds to both axis pairs (ArrowUp/Left decrease, ArrowDown/Right increase) rather than requiring the 'correct' physical axis per edge, since the meaningful accessibility contract is 'reliably nudge each edge independently by a known amount,' not which specific key increases vs decreases.\n\nHover on a grip strip reveals a thin (2px) `var(--ns-accent)` line along that edge via a CSS `:hover`/`:focus-visible` rule (no JS), plus the matching resize cursor (`ns-resize` for top/bottom, `ew-resize` for left/right, corner cursors for corners). `onChange` fires with `{x, y, width, height, ratio}` in natural-scale units on every committed change — preset click, drag release, and keyboard nudge — not continuously mid-drag. No dependencies."
  },
  "type": "registry:ui"
}