{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-upload",
  "type": "registry:ui",
  "title": "Avatar Upload",
  "description": "Pick, crop and zoom a photo, then watch a ring close around it.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/avatar-upload.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\n/** \"Dana Whitfield\" → \"DW\", \"cher\" → \"C\". */\nexport function initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (!words.length) return \"\";\n  const first = words[0][0] ?? \"\";\n  const last = words.length > 1 ? (words[words.length - 1][0] ?? \"\") : \"\";\n  return (first + last).toUpperCase();\n}\n\nconst formatBytes = (b: number) => (b >= 1024 * 1024 ? `${Math.round(b / 1024 / 1024)}\\u00a0MB` : `${Math.round(b / 1024)}\\u00a0KB`);\n\nconst ACCEPT = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\"];\n\nexport type AvatarUploader = (blob: Blob, ctx: { signal: AbortSignal; onProgress: (fraction: number) => void }) => Promise<string | void>;\n\n/** Draws the chosen circle of the image into a square canvas and encodes it. */\nasync function renderCrop(img: HTMLImageElement, crop: Crop, frame: number, output: number) {\n  const s = baseScale(img, frame) * crop.z;\n  const side = frame / s;\n  const sx = img.naturalWidth / 2 + (-frame / 2 - crop.x) / s;\n  const sy = img.naturalHeight / 2 + (-frame / 2 - crop.y) / s;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = output;\n  canvas.height = output;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"Canvas unavailable\");\n  ctx.imageSmoothingQuality = \"high\";\n  ctx.drawImage(img, sx, sy, side, side, 0, 0, output, output);\n  // WebP where the browser can encode it; the browser falls back to PNG where it can't.\n  return new Promise<Blob>((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error(\"Encode failed\"))), \"image/webp\", 0.9));\n}\n\ntype Crop = { x: number; y: number; z: number };\nconst baseScale = (img: { naturalWidth: number; naturalHeight: number }, frame: number) => frame / Math.min(img.naturalWidth, img.naturalHeight);\n\n// The image always covers the circle: never a sliver of background inside the crop.\nfunction clamp(c: Crop, img: { naturalWidth: number; naturalHeight: number }, frame: number, maxZoom: number): Crop {\n  const z = Math.min(maxZoom, Math.max(1, c.z));\n  const s = baseScale(img, frame) * z;\n  const mx = Math.max(0, (img.naturalWidth * s - frame) / 2);\n  const my = Math.max(0, (img.naturalHeight * s - frame) / 2);\n  return { z, x: Math.min(mx, Math.max(-mx, c.x)), y: Math.min(my, Math.max(-my, c.y)) };\n}\n\n/* ------------------------------------------------------------------ */\n/* The component                                                       */\n/* ------------------------------------------------------------------ */\n\nexport type AvatarUploadProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** Used for the initials fallback and the image's alt text. */\n  name: string;\n  /** The photo URL, or null for none. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (url: string | null) => void;\n  /** Sends the cropped image. Resolve with the stored URL, or nothing to keep the local preview. Report progress as 0–1. */\n  upload?: AvatarUploader;\n  /** Called with the cropped image before it uploads. */\n  onCrop?: (blob: Blob) => void;\n  size?: \"md\" | \"lg\";\n  /** Title beside the avatar. */\n  label?: string;\n  /** Line under the title at rest. */\n  hint?: React.ReactNode;\n  /** Show the title, status line and buttons beside the avatar. Off leaves just the avatar. */\n  details?: boolean;\n  /** Largest file accepted before cropping, in bytes. */\n  maxSize?: number;\n  /** Smallest side, in pixels, for a photo to look sharp. */\n  minDimension?: number;\n  /** Side of the square image that's produced, in pixels. */\n  outputSize?: number;\n  disabled?: boolean;\n  /** Where the crop dialog renders. Pass a positioned element to keep it inside a region instead of the viewport. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n};\n\ntype Phase =\n  { kind: \"idle\" } | { kind: \"uploading\"; progress: number } | { kind: \"done\" } | { kind: \"error\"; message: string; retry?: Blob } | { kind: \"removed\"; previous: string };\n\nexport function AvatarUpload({\n  name,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  upload,\n  onCrop,\n  size = \"md\",\n  label = \"Profile photo\",\n  hint,\n  details = true,\n  maxSize = 10 * 1024 * 1024,\n  minDimension = 256,\n  outputSize = 512,\n  disabled = false,\n  container,\n  className,\n  ...rest\n}: AvatarUploadProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const [value, setValue] = useControllableState<string | null>({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [phase, setPhase] = useState<Phase>({ kind: \"idle\" });\n  // The cropped image stays on screen after upload for as long as the value is the URL it became,\n  // so a remote URL never flashes the initials while it loads.\n  const [preview, setPreview] = useState<{ url: string; for?: string | null } | null>(null);\n  const [source, setSource] = useState<{ url: string; img: HTMLImageElement; name: string } | null>(null);\n  const [dragOver, setDragOver] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const abortRef = useRef<AbortController | null>(null);\n  const timer = useRef<number>(undefined);\n  const urls = useRef(new Set<string>());\n  const previewRef = useRef<string | null>(null);\n\n  // Every object URL made here is revoked on unmount.\n  useEffect(() => {\n    const set = urls.current;\n    return () => {\n      window.clearTimeout(timer.current);\n      abortRef.current?.abort();\n      set.forEach((u) => URL.revokeObjectURL(u));\n    };\n  }, []);\n  const makeUrl = (b: Blob) => {\n    const u = URL.createObjectURL(b);\n    urls.current.add(u);\n    return u;\n  };\n  const dropUrl = (u: string | null) => {\n    if (u && urls.current.has(u)) {\n      URL.revokeObjectURL(u);\n      urls.current.delete(u);\n    }\n  };\n\n  const shown = phase.kind === \"uploading\" ? (preview?.url ?? value) : preview && preview.for === value ? preview.url : value;\n  const uploading = phase.kind === \"uploading\";\n\n  const choose = useCallback(\n    (file: File | undefined) => {\n      if (!file) return;\n      window.clearTimeout(timer.current);\n      if (!ACCEPT.includes(file.type)) {\n        setPhase({ kind: \"error\", message: `${file.name} isn’t supported. Use PNG, JPG or WebP.` });\n        return;\n      }\n      if (file.size > maxSize) {\n        setPhase({ kind: \"error\", message: `That image is ${formatBytes(file.size)}. The limit is ${formatBytes(maxSize)}.` });\n        return;\n      }\n      const url = makeUrl(file);\n      const img = new Image();\n      img.onload = () => {\n        if (Math.min(img.naturalWidth, img.naturalHeight) < minDimension) {\n          dropUrl(url);\n          setPhase({ kind: \"error\", message: `That image is ${img.naturalWidth} × ${img.naturalHeight} px. Use one at least ${minDimension}\\u00a0px on each side.` });\n          return;\n        }\n        setPhase({ kind: \"idle\" });\n        setSource({ url, img, name: file.name });\n      };\n      img.onerror = () => {\n        dropUrl(url);\n        setPhase({ kind: \"error\", message: \"Couldn’t read that image. Try another file.\" });\n      };\n      img.src = url;\n    },\n    [maxSize, minDimension],\n  );\n\n  const send = useCallback(\n    async (blob: Blob) => {\n      const local = makeUrl(blob);\n      if (previewRef.current !== value) dropUrl(previewRef.current);\n      previewRef.current = local;\n      setPreview({ url: local });\n      if (!upload) {\n        setValue(local);\n        setPreview({ url: local, for: local });\n        setPhase({ kind: \"done\" });\n        timer.current = window.setTimeout(() => setPhase({ kind: \"idle\" }), 1600);\n        return;\n      }\n      const c = new AbortController();\n      abortRef.current = c;\n      setPhase({ kind: \"uploading\", progress: 0 });\n      try {\n        const url = await upload(blob, { signal: c.signal, onProgress: (p) => !c.signal.aborted && setPhase({ kind: \"uploading\", progress: Math.max(0, Math.min(1, p)) }) });\n        if (c.signal.aborted) return;\n        setPhase({ kind: \"uploading\", progress: 1 });\n        setValue(url || local);\n        setPreview({ url: local, for: url || local });\n        // Let the ring close before it fades and the tick appears.\n        timer.current = window.setTimeout(() => {\n          setPhase({ kind: \"done\" });\n          timer.current = window.setTimeout(() => setPhase({ kind: \"idle\" }), 1600);\n        }, 320);\n      } catch {\n        if (c.signal.aborted) return;\n        setPhase({ kind: \"error\", message: \"Couldn’t upload your photo.\", retry: blob });\n      }\n    },\n    [upload, setValue, value],\n  );\n\n  const cancel = () => {\n    abortRef.current?.abort();\n    setPhase({ kind: \"idle\" });\n    if (previewRef.current !== value) dropUrl(previewRef.current);\n    previewRef.current = null;\n    setPreview(null);\n  };\n\n  const remove = () => {\n    if (!value) return;\n    window.clearTimeout(timer.current);\n    const previous = value;\n    setValue(null);\n    // Undo instead of a confirm: the photo comes back with one press for a few seconds.\n    setPhase({ kind: \"removed\", previous });\n    timer.current = window.setTimeout(() => setPhase({ kind: \"idle\" }), 6000);\n    buttonRef.current?.focus();\n  };\n\n  const undo = () => {\n    if (phase.kind !== \"removed\") return;\n    window.clearTimeout(timer.current);\n    setValue(phase.previous);\n    setPhase({ kind: \"idle\" });\n  };\n\n  const md = size === \"md\";\n  const px = md ? 64 : 96;\n  const initials = initialsOf(name);\n  const statusId = `${id}-status`;\n  const ringR = px / 2 + 4;\n  const ringBox = px + 12;\n  const progress = phase.kind === \"uploading\" ? phase.progress : 0;\n\n  const status: React.ReactNode =\n    phase.kind === \"error\" ? (\n      <span className=\"text-danger text-pretty\">{phase.message}</span>\n    ) : phase.kind === \"uploading\" ? (\n      <span className=\"tabular\">\n        Uploading · <NumberFlow value={Math.round(progress * 100)} suffix=\"%\" />\n      </span>\n    ) : phase.kind === \"done\" ? (\n      <span>Photo updated</span>\n    ) : phase.kind === \"removed\" ? (\n      <span>Photo removed</span>\n    ) : (\n      <span>{hint ?? `PNG, JPG or WebP, at least ${minDimension}\\u00a0px`}</span>\n    );\n\n  return (\n    <div\n      data-slot=\"avatar-upload\"\n      data-state={phase.kind}\n      data-size={size}\n      data-disabled={disabled || undefined}\n      className={cn(\"flex min-w-0 items-center gap-4\", disabled && \"opacity-50\", className)}\n      {...rest}\n    >\n      <div className=\"relative shrink-0\" style={{ width: px, height: px }}>\n        {/* The upload ring: it draws around the avatar as bytes go up, then closes and fades. */}\n        <svg\n          aria-hidden\n          width={ringBox}\n          height={ringBox}\n          viewBox={`0 0 ${ringBox} ${ringBox}`}\n          className={cn(\n            \"pointer-events-none absolute -left-1.5 -top-1.5 -rotate-90 transition-[opacity,scale] duration-300 ease-out-expo\",\n            uploading || (phase.kind === \"error\" && phase.retry) ? \"scale-100 opacity-100\" : \"scale-[1.06] opacity-0\",\n          )}\n        >\n          <circle cx={ringBox / 2} cy={ringBox / 2} r={ringR} fill=\"none\" strokeWidth=\"2\" className=\"stroke-fg/10\" />\n          <circle\n            cx={ringBox / 2}\n            cy={ringBox / 2}\n            r={ringR}\n            fill=\"none\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            pathLength={1}\n            strokeDasharray=\"1 1\"\n            className={cn(\"transition-[stroke-dashoffset,stroke] duration-300 ease-out\", phase.kind === \"error\" ? \"stroke-danger\" : \"stroke-fg\")}\n            style={{ strokeDashoffset: 1 - (phase.kind === \"error\" ? 1 : progress) }}\n          />\n        </svg>\n\n        <button\n          ref={buttonRef}\n          type=\"button\"\n          disabled={disabled || uploading}\n          aria-label={value ? `Change ${label.toLowerCase()}` : `Upload ${label.toLowerCase()}`}\n          aria-describedby={details ? statusId : undefined}\n          onClick={() => inputRef.current?.click()}\n          onDragOver={(e) => {\n            if (disabled || uploading || !Array.from(e.dataTransfer.types).includes(\"Files\")) return;\n            e.preventDefault();\n            e.dataTransfer.dropEffect = \"copy\";\n            if (!dragOver) setDragOver(true);\n          }}\n          onDragLeave={() => setDragOver(false)}\n          onDrop={(e) => {\n            if (disabled || uploading) return;\n            e.preventDefault();\n            setDragOver(false);\n            choose(e.dataTransfer.files[0]);\n          }}\n          data-drag={dragOver || undefined}\n          className={cn(\n            \"group/avatar relative block size-full cursor-pointer overflow-hidden rounded-full select-none\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[3px] focus-visible:outline-fg-3\",\n            \"transition-[scale] duration-150 ease-out active:scale-[0.96] disabled:cursor-default disabled:active:scale-100\",\n          )}\n        >\n          <Avatar.Root className={cn(\"grid size-full place-items-center rounded-full bg-hover text-fg-2 ring-1 ring-inset ring-line-2\", md ? \"text-[20px]\" : \"text-[30px]\")}>\n            {shown && (\n              <Avatar.Image\n                key={shown}\n                src={shown}\n                alt={name}\n                className={cn(\n                  \"col-start-1 row-start-1 size-full object-cover transition-[opacity,filter] duration-300 ease-out\",\n                  \"data-starting-style:opacity-0\",\n                  uploading && \"opacity-70\",\n                )}\n              />\n            )}\n            <Avatar.Fallback className=\"col-start-1 row-start-1 font-medium tracking-[0.01em]\">{initials || <PersonGlyph size={md ? 26 : 38} />}</Avatar.Fallback>\n          </Avatar.Root>\n\n          {/* Hover (and a file dragged over) reveals the change overlay. Touch uses the badge instead. */}\n          <span\n            aria-hidden\n            className={cn(\n              // A dark scrim with light text in both themes: it sits on a photo, not on the page.\n              \"absolute inset-0 grid place-items-center rounded-full bg-fg/50 text-frame opacity-0 backdrop-blur-[2px] transition-opacity duration-150 dark:bg-overlay dark:text-fg\",\n              \"group-hover/avatar:opacity-100 group-data-drag/avatar:opacity-100 group-disabled/avatar:opacity-0\",\n            )}\n          >\n            <span className=\"flex flex-col items-center gap-0.5 text-[11px] font-medium transition-transform duration-200 ease-out-expo group-hover/avatar:-translate-y-px\">\n              <CameraGlyph size={md ? 16 : 20} />\n              {!md && <span>{dragOver ? \"Drop\" : \"Change\"}</span>}\n            </span>\n          </span>\n        </button>\n\n        {/* The badge: a camera at rest (the affordance on touch), a tick once the new photo is in. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute grid place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]\",\n            md ? \"-bottom-0.5 -right-0.5 size-6\" : \"bottom-0.5 right-0.5 size-7\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={phase.kind === \"done\" ? \"done\" : \"camera\"}\n              className={cn(\"grid place-items-center\", phase.kind === \"done\" && \"text-success\")}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              {phase.kind === \"done\" ? (\n                <svg width=\"12\" height=\"12\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <motion.path\n                    d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                    initial={reduce ? false : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.06 }}\n                  />\n                </svg>\n              ) : (\n                <CameraGlyph size={md ? 12 : 14} />\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </div>\n\n      {details && (\n        <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <span className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">{label}</span>\n            <span id={statusId} className=\"grid min-h-[18px] text-[12px] leading-[18px] text-fg-3\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={phase.kind === \"error\" ? `e-${phase.message}` : phase.kind}\n                  className=\"col-start-1 row-start-1\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  transition={{ duration: 0.2, ease: ease.out }}\n                >\n                  {status}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </div>\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            {uploading ? (\n              <Btn onClick={cancel}>Cancel</Btn>\n            ) : phase.kind === \"error\" && phase.retry ? (\n              <>\n                <Btn variant=\"secondary\" onClick={() => phase.retry && send(phase.retry)}>\n                  Try again\n                </Btn>\n                <Btn onClick={cancel}>Cancel</Btn>\n              </>\n            ) : (\n              <>\n                <Btn variant=\"secondary\" disabled={disabled} onClick={() => inputRef.current?.click()}>\n                  {value ? \"Change photo\" : \"Upload photo\"}\n                </Btn>\n                {phase.kind === \"removed\" ? (\n                  <Btn onClick={undo}>Undo</Btn>\n                ) : (\n                  value && (\n                    <Btn disabled={disabled} onClick={remove}>\n                      Remove\n                    </Btn>\n                  )\n                )}\n              </>\n            )}\n          </div>\n        </div>\n      )}\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept={ACCEPT.join(\",\")}\n        hidden\n        tabIndex={-1}\n        onChange={(e) => {\n          choose(e.currentTarget.files?.[0]);\n          e.currentTarget.value = \"\";\n        }}\n      />\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {phase.kind === \"done\" ? \"Photo updated\" : phase.kind === \"error\" ? phase.message : phase.kind === \"removed\" ? \"Photo removed. Undo is available.\" : \"\"}\n      </span>\n\n      <CropDialog\n        source={source}\n        container={container}\n        outputSize={outputSize}\n        onCancel={() => {\n          dropUrl(source?.url ?? null);\n          setSource(null);\n        }}\n        onSave={async (blob) => {\n          dropUrl(source?.url ?? null);\n          setSource(null);\n          onCrop?.(blob);\n          await send(blob);\n        }}\n        finalFocus={buttonRef}\n      />\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* The crop dialog                                                     */\n/* ------------------------------------------------------------------ */\n\nconst FRAME = 200; // the crop circle\nconst PAD = 20; // context shown around it\nconst MAX_ZOOM = 4;\n\nfunction CropDialog({\n  source,\n  container,\n  outputSize,\n  onCancel,\n  onSave,\n  finalFocus,\n}: {\n  source: { url: string; img: HTMLImageElement; name: string } | null;\n  container: AvatarUploadProps[\"container\"];\n  outputSize: number;\n  onCancel: () => void;\n  onSave: (blob: Blob) => Promise<void> | void;\n  finalFocus: React.RefObject<HTMLButtonElement | null>;\n}) {\n  const [crop, setCrop] = useState<Crop>({ x: 0, y: 0, z: 1 });\n  const [dragging, setDragging] = useState(false);\n  const [saving, setSaving] = useState(false);\n  const [glide, setGlide] = useState(false);\n  const [shownFor, setShownFor] = useState<string | null>(null);\n  const viewRef = useRef<HTMLDivElement>(null);\n  const popupRef = useRef<HTMLDivElement>(null);\n  const pointers = useRef(new Map<number, { x: number; y: number }>());\n  const pinch = useRef<{ dist: number; z: number } | null>(null);\n  const inline = !!container;\n\n  // A new image starts centered at 1×; adjusted while rendering, not in an effect.\n  if (source && shownFor !== source.url) {\n    setShownFor(source.url);\n    setCrop({ x: 0, y: 0, z: 1 });\n    setSaving(false);\n  }\n\n  const img = source?.img;\n  const apply = useCallback((next: Crop) => img && setCrop(clamp(next, img, FRAME, MAX_ZOOM)), [img]);\n\n  // Zoom around a point (relative to the circle's center) so what's under the cursor stays put.\n  const zoomAt = useCallback(\n    (z: number, fx = 0, fy = 0) => {\n      setCrop((c) => {\n        if (!img) return c;\n        const nz = Math.min(MAX_ZOOM, Math.max(1, z));\n        const k = nz / c.z;\n        return clamp({ z: nz, x: fx - (fx - c.x) * k, y: fy - (fy - c.y) * k }, img, FRAME, MAX_ZOOM);\n      });\n    },\n    [img],\n  );\n\n  // Wheel and trackpad pinch zoom. Needs a non-passive listener to stop the page scrolling.\n  useEffect(() => {\n    const el = viewRef.current;\n    if (!el || !img) return;\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      const r = el.getBoundingClientRect();\n      const fx = e.clientX - r.left - r.width / 2;\n      const fy = e.clientY - r.top - r.height / 2;\n      setCrop((c) => {\n        const nz = Math.min(MAX_ZOOM, Math.max(1, c.z * Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015))));\n        const k = nz / c.z;\n        return clamp({ z: nz, x: fx - (fx - c.x) * k, y: fy - (fy - c.y) * k }, img, FRAME, MAX_ZOOM);\n      });\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => el.removeEventListener(\"wheel\", onWheel);\n  }, [img, source]);\n\n  const s = img ? baseScale(img, FRAME) : 1;\n  const w = img ? img.naturalWidth * s : FRAME;\n  const h = img ? img.naturalHeight * s : FRAME;\n\n  const box = FRAME + PAD * 2;\n\n  return (\n    <Dialog.Root\n      open={!!source}\n      onOpenChange={(open) => {\n        if (!open) onCancel();\n      }}\n    >\n      <Dialog.Portal container={container ?? undefined}>\n        <Dialog.Backdrop\n          className={cn(\n            inline ? \"absolute rounded-2xl\" : \"fixed\",\n            \"inset-0 z-(--z-overlay) bg-overlay transition-opacity duration-200 ease-out data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:opacity-0\",\n          )}\n        />\n        <Dialog.Popup\n          ref={popupRef}\n          // Focus lands on the dialog itself; one Tab reaches the crop area, so a pointer user never sees a ring on open.\n          initialFocus={popupRef}\n          finalFocus={finalFocus}\n          className={cn(\n            inline ? \"absolute\" : \"fixed\",\n            \"left-1/2 top-1/2 z-(--z-dialog) flex w-[min(320px,calc(100%-32px))] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-2xl border border-line-2 bg-raised p-4 text-fg shadow-pop outline-none\",\n            \"transition-[opacity,scale] duration-[220ms] ease-out-expo data-starting-style:scale-[0.96] data-starting-style:opacity-0\",\n            \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150\",\n          )}\n        >\n          <div className=\"flex flex-col gap-1\">\n            <Dialog.Title className=\"text-[14px] font-medium tracking-[-0.015em]\">Crop photo</Dialog.Title>\n            <Dialog.Description className=\"text-[12.5px] text-fg-3\">\n              <span className=\"pointer-coarse:hidden\">Drag to reposition. Scroll to zoom.</span>\n              <span className=\"hidden pointer-coarse:inline\">Drag to reposition. Pinch to zoom.</span>\n            </Dialog.Description>\n          </div>\n\n          <div\n            ref={viewRef}\n            tabIndex={0}\n            role=\"group\"\n            aria-roledescription=\"crop area\"\n            aria-label=\"Photo position. Arrow keys move it, plus and minus zoom, 0 resets.\"\n            data-dragging={dragging || undefined}\n            onPointerDown={(e) => {\n              e.currentTarget.setPointerCapture(e.pointerId);\n              pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n              if (pointers.current.size === 2) {\n                const [a, b] = [...pointers.current.values()];\n                pinch.current = { dist: Math.hypot(a.x - b.x, a.y - b.y), z: crop.z };\n              }\n              setDragging(true);\n              setGlide(false);\n            }}\n            onPointerMove={(e) => {\n              const prev = pointers.current.get(e.pointerId);\n              if (!prev) return;\n              const next = { x: e.clientX, y: e.clientY };\n              pointers.current.set(e.pointerId, next);\n              if (pointers.current.size === 2 && pinch.current) {\n                const [a, b] = [...pointers.current.values()];\n                zoomAt((pinch.current.z * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.current.dist);\n              } else {\n                // Moves 1:1 with the finger; no easing, or it feels like dragging through syrup.\n                setCrop((c) => (img ? clamp({ ...c, x: c.x + next.x - prev.x, y: c.y + next.y - prev.y }, img, FRAME, MAX_ZOOM) : c));\n              }\n            }}\n            onPointerUp={(e) => {\n              pointers.current.delete(e.pointerId);\n              if (pointers.current.size < 2) pinch.current = null;\n              if (!pointers.current.size) setDragging(false);\n            }}\n            onPointerCancel={(e) => {\n              pointers.current.delete(e.pointerId);\n              pinch.current = null;\n              if (!pointers.current.size) setDragging(false);\n            }}\n            onKeyDown={(e) => {\n              setGlide(false);\n              const step = e.shiftKey ? 24 : 6;\n              const moves: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };\n              if (moves[e.key]) {\n                e.preventDefault();\n                apply({ ...crop, x: crop.x + moves[e.key][0], y: crop.y + moves[e.key][1] });\n              } else if (e.key === \"+\" || e.key === \"=\") {\n                e.preventDefault();\n                zoomAt(crop.z + 0.2);\n              } else if (e.key === \"-\" || e.key === \"_\") {\n                e.preventDefault();\n                zoomAt(crop.z - 0.2);\n              } else if (e.key === \"0\") {\n                e.preventDefault();\n                apply({ x: 0, y: 0, z: 1 });\n              }\n            }}\n            className={cn(\n              \"group/crop relative mx-auto touch-none overflow-hidden rounded-xl bg-page select-none\",\n              \"cursor-grab data-dragging:cursor-grabbing\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            )}\n            style={{ width: box, height: box }}\n          >\n            {source && (\n              <motion.img\n                key={source.url}\n                src={source.url}\n                alt=\"\"\n                draggable={false}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.22, ease: ease.out }}\n                className={cn(\n                  \"pointer-events-none absolute left-1/2 top-1/2 max-w-none\",\n                  // Button zooms glide; drags, pinches, the slider and keys follow the input directly.\n                  glide && \"transition-transform duration-200 ease-out-expo motion-reduce:transition-none\",\n                )}\n                style={{ width: w, height: h, transform: `translate(-50%, -50%) translate(${crop.x}px, ${crop.y}px) scale(${crop.z})` }}\n              />\n            )}\n            {/* The mask: everything outside the circle dims, the circle gets a hairline edge. */}\n            <div\n              aria-hidden\n              className=\"pointer-events-none absolute rounded-full shadow-[0_0_0_999px_color-mix(in_oklab,var(--page)_68%,transparent)] ring-1 ring-fg/35\"\n              style={{ inset: PAD }}\n            />\n            {/* Thirds appear only while dragging, as an alignment aid. */}\n            <div\n              aria-hidden\n              className=\"pointer-events-none absolute overflow-hidden rounded-full opacity-0 transition-opacity duration-200 group-data-dragging/crop:opacity-100\"\n              style={{ inset: PAD }}\n            >\n              {[1, 2].map((i) => (\n                <span key={`v${i}`} className=\"absolute inset-y-0 w-px bg-fg/25\" style={{ left: `${(i * 100) / 3}%` }} />\n              ))}\n              {[1, 2].map((i) => (\n                <span key={`h${i}`} className=\"absolute inset-x-0 h-px bg-fg/25\" style={{ top: `${(i * 100) / 3}%` }} />\n              ))}\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <ZoomButton\n              label=\"Zoom out\"\n              disabled={crop.z <= 1}\n              onClick={() => {\n                setGlide(true);\n                zoomAt(crop.z - 0.25);\n              }}\n            >\n              <path d=\"M5 8h6\" />\n            </ZoomButton>\n            <Slider.Root\n              value={crop.z}\n              min={1}\n              max={MAX_ZOOM}\n              step={0.01}\n              onValueChange={(v) => {\n                setGlide(false);\n                zoomAt(v as number);\n              }}\n              className=\"flex-1\"\n              aria-label=\"Zoom\"\n            >\n              <Slider.Control className=\"group/zs relative flex h-6 cursor-pointer touch-none items-center select-none\">\n                <Slider.Track className=\"relative h-1 w-full rounded-full bg-fg/10\">\n                  <Slider.Indicator className=\"rounded-full bg-fg\" />\n                  <Slider.Thumb\n                    aria-label=\"Zoom\"\n                    getAriaValueText={(_, v) => `${Math.round(v * 100)}%`}\n                    className=\"group/thumb size-4 outline-none before:absolute before:-inset-3.5 before:content-['']\"\n                  >\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"absolute inset-0 rounded-full bg-fg shadow-[var(--shadow)] transition-[scale,box-shadow] duration-150 ease-out-expo\",\n                        \"outline-offset-2 group-has-focus-visible/thumb:outline-1 group-has-focus-visible/thumb:outline-solid group-has-focus-visible/thumb:outline-fg-3\",\n                        \"group-hover/zs:scale-110 group-data-dragging/zs:scale-125 group-data-dragging/zs:ring-4 group-data-dragging/zs:ring-fg/10\",\n                      )}\n                    />\n                  </Slider.Thumb>\n                </Slider.Track>\n              </Slider.Control>\n            </Slider.Root>\n            <ZoomButton\n              label=\"Zoom in\"\n              disabled={crop.z >= MAX_ZOOM}\n              onClick={() => {\n                setGlide(true);\n                zoomAt(crop.z + 0.25);\n              }}\n            >\n              <path d=\"M5 8h6M8 5v6\" />\n            </ZoomButton>\n          </div>\n\n          <div className=\"flex justify-end gap-2\">\n            <Dialog.Close render={<Btn variant=\"secondary\" size=\"md\" />}>Cancel</Dialog.Close>\n            <Btn\n              variant=\"primary\"\n              size=\"md\"\n              busy={saving}\n              onClick={async () => {\n                if (!img || saving) return;\n                setSaving(true);\n                try {\n                  const blob = await renderCrop(img, crop, FRAME, outputSize);\n                  await onSave(blob);\n                } catch {\n                  setSaving(false);\n                }\n              }}\n            >\n              Save photo\n            </Btn>\n          </div>\n        </Dialog.Popup>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Pieces                                                              */\n/* ------------------------------------------------------------------ */\n\nfunction Btn({\n  variant = \"ghost\",\n  size = \"sm\",\n  busy,\n  className,\n  children,\n  ...rest\n}: React.ComponentProps<\"button\"> & { variant?: \"primary\" | \"secondary\" | \"ghost\"; size?: \"sm\" | \"md\"; busy?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      aria-busy={busy || undefined}\n      className={cn(\n        \"relative inline-flex shrink-0 items-center justify-center font-medium tracking-[-0.005em] whitespace-nowrap\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50\",\n        size === \"sm\" ? \"h-7 rounded-md px-2.5 text-[12px]\" : \"h-8 rounded-lg px-3 text-[12.5px]\",\n        variant === \"primary\" && \"bg-fg text-frame hover:bg-fg/90\",\n        variant === \"secondary\" && \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n        variant === \"ghost\" && \"text-fg-2 hover:bg-hover hover:text-fg\",\n        busy && \"pointer-events-none\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* The label keeps its width while a spinner sits over it. */}\n      <span className={cn(\"transition-opacity duration-150\", busy && \"opacity-0\")}>{children}</span>\n      {busy && (\n        <svg aria-hidden width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" className=\"absolute animate-spin\">\n          <path d=\"M8 2.25a5.75 5.75 0 1 0 5.75 5.75\" />\n        </svg>\n      )}\n    </button>\n  );\n}\n\nfunction ZoomButton({ label, children, ...rest }: React.ComponentProps<\"button\"> & { label: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      className={cn(\n        \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] disabled:pointer-events-none disabled:opacity-40\",\n        \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n      )}\n      {...rest}\n    >\n      <svg aria-hidden width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\">\n        <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nfunction CameraGlyph({ size = 16 }: { size?: number }) {\n  return (\n    <svg aria-hidden width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M2.5 5.75c0-.55.45-1 1-1h1.7l1.05-1.5h3.5l1.05 1.5h1.7c.55 0 1 .45 1 1v6.25c0 .55-.45 1-1 1h-9c-.55 0-1-.45-1-1z\" />\n      <circle cx=\"8\" cy=\"8.6\" r=\"2.2\" />\n    </svg>\n  );\n}\n\nfunction PersonGlyph({ size = 24 }: { size?: number }) {\n  return (\n    <svg aria-hidden width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" strokeLinecap=\"round\">\n      <circle cx=\"8\" cy=\"5.75\" r=\"2.5\" />\n      <path d=\"M3.25 13.25c.5-2.4 2.4-3.75 4.75-3.75s4.25 1.35 4.75 3.75\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/avatar-upload"
}
