{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lens-reveal","type":"registry:component","title":"Lens Reveal","description":"A movable lens that reveals the layer beneath a surface.","dependencies":[],"categories":["media","reveal","comparison","annotation","lens"],"files":[{"path":"components/lab/lens-reveal/lens-reveal.tsx","target":"components/lab/lens-reveal/lens-reveal.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport \"./lens-reveal.css\";\n\nexport type LensRevealShape = \"circle\" | \"square\";\n\ntype LensRevealProps = {\n  /** The surface the reader sees first. */\n  base: ReactNode;\n  /**\n   * The registered layer revealed under the lens: annotations, a\n   * revised layout, measurement chrome. It must be presentational —\n   * see the accessibility note in the source.\n   */\n  lens: ReactNode;\n  /** Lens diameter (or side length) in pixels. */\n  size?: number;\n  /** Lens geometry. */\n  shape?: LensRevealShape;\n  /** Pixels moved per arrow-key press. */\n  keyboardStep?: number;\n  /** Accessible name for the lens region. */\n  label?: string;\n  /** What the lens reveals, read to assistive tech. */\n  description?: string;\n  /** Normalised lens centre (0–1), for syncing sidecars. */\n  onPositionChange?: (x: number, y: number) => void;\n  className?: string;\n};\n\n/** Shift makes each arrow step this fine. */\nconst FINE_STEP = 6;\n/** Per-frame catch-up factor (~0.25); reduced motion runs 1:1. */\nconst EASE = 0.25;\n\n/**\n * LENS REVEAL — a movable lens that reveals a second, registered\n * layer under a surface.\n *\n * Pointer and touch drag move it (clamped so the lens always stays\n * fully inside the container), arrow keys move it precisely, and\n * Home recentres it. The lens is drawn from the start so the\n * interaction is discoverable.\n *\n * Give the root a size (height, aspect-ratio, flex-grow…) — both the\n * base and lens layers are full-bleed inside it. If the container is\n * smaller than `size`, the lens clamps down to fit.\n *\n * ACCESSIBILITY — `lens` must not introduce interactive elements:\n * it is rendered `aria-hidden`, because the same information has to\n * exist elsewhere. Hosts should duplicate whatever the lens reveals\n * in the page itself (or in `description`), so the lens is an\n * accelerator rather than a gate.\n */\nexport function LensReveal({\n  base,\n  lens,\n  size = 190,\n  shape = \"circle\",\n  keyboardStep = 28,\n  label = \"Reveal layer\",\n  description,\n  onPositionChange,\n  className,\n}: LensRevealProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const posRef = useRef({ x: 0, y: 0 });\n  const targetRef = useRef({ x: 0, y: 0 });\n  const boxRef = useRef({ w: 0, h: 0 });\n  const readyRef = useRef(false);\n  const frameRef = useRef(0);\n  const apiRef = useRef<{\n    nudge: (dx: number, dy: number) => void;\n    reset: () => void;\n  } | null>(null);\n  const callbackRef = useRef(onPositionChange);\n  const [dragging, setDragging] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const descriptionId = useId();\n\n  useEffect(() => {\n    callbackRef.current = onPositionChange;\n  });\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n\n    const motion =\n      typeof window.matchMedia === \"function\"\n        ? window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n        : null;\n    const reduced = () => motion?.matches ?? false;\n\n    let half = Math.max(0, size) / 2;\n    let touchId: number | null = null;\n    let lastTouch = { x: 0, y: 0 };\n\n    const clamp = (x: number, y: number) => {\n      const { w, h } = boxRef.current;\n      if (w <= 0 || h <= 0) return { x, y };\n      return {\n        x: Math.min(Math.max(x, half), Math.max(half, w - half)),\n        y: Math.min(Math.max(y, half), Math.max(half, h - half)),\n      };\n    };\n\n    const write = () => {\n      const { w, h } = boxRef.current;\n      root.style.setProperty(\"--lr-x\", `${posRef.current.x}px`);\n      root.style.setProperty(\"--lr-y\", `${posRef.current.y}px`);\n      if (w > 0 && h > 0) {\n        callbackRef.current?.(posRef.current.x / w, posRef.current.y / h);\n      }\n    };\n\n    const tick = () => {\n      frameRef.current = 0;\n      const target = targetRef.current;\n      const pos = posRef.current;\n      if (reduced()) {\n        pos.x = target.x;\n        pos.y = target.y;\n        write();\n        return;\n      }\n      const dx = target.x - pos.x;\n      const dy = target.y - pos.y;\n      if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) {\n        pos.x = target.x;\n        pos.y = target.y;\n        write();\n        return;\n      }\n      pos.x += dx * EASE;\n      pos.y += dy * EASE;\n      write();\n      frameRef.current = window.requestAnimationFrame(tick);\n    };\n\n    const schedule = () => {\n      if (reduced()) {\n        if (frameRef.current) {\n          window.cancelAnimationFrame(frameRef.current);\n          frameRef.current = 0;\n        }\n        posRef.current = { ...targetRef.current };\n        write();\n        return;\n      }\n      if (!frameRef.current) {\n        frameRef.current = window.requestAnimationFrame(tick);\n      }\n    };\n\n    const moveTo = (x: number, y: number) => {\n      targetRef.current = clamp(x, y);\n      schedule();\n    };\n\n    const measure = () => {\n      const w = root.clientWidth;\n      const h = root.clientHeight;\n      boxRef.current = { w, h };\n      if (w > 0 && h > 0) {\n        half = Math.max(0, Math.min(size, w, h)) / 2;\n      } else {\n        half = Math.max(0, size) / 2;\n      }\n      root.style.setProperty(\"--lr-r\", `${half}px`);\n      if (!readyRef.current && w > 0 && h > 0) {\n        readyRef.current = true;\n        posRef.current = { x: w / 2, y: h / 2 };\n        targetRef.current = { x: w / 2, y: h / 2 };\n      } else {\n        posRef.current = clamp(posRef.current.x, posRef.current.y);\n        targetRef.current = clamp(targetRef.current.x, targetRef.current.y);\n      }\n      write();\n    };\n\n    apiRef.current = {\n      nudge: (dx, dy) =>\n        moveTo(targetRef.current.x + dx, targetRef.current.y + dy),\n      reset: () => moveTo(boxRef.current.w / 2, boxRef.current.h / 2),\n    };\n\n    const onPointerDown = (event: PointerEvent) => {\n      setDragging(true);\n      try {\n        root.setPointerCapture(event.pointerId);\n      } catch {\n        /* capture is an enhancement; ignoring keeps older browsers usable */\n      }\n      if (event.pointerType === \"touch\") {\n        /* touch only moves after a real drag: a plain tap never grabs. */\n        touchId = event.pointerId;\n        lastTouch = { x: event.clientX, y: event.clientY };\n      }\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (event.pointerType === \"touch\") {\n        if (touchId !== event.pointerId) return;\n        const dx = event.clientX - lastTouch.x;\n        const dy = event.clientY - lastTouch.y;\n        lastTouch = { x: event.clientX, y: event.clientY };\n        moveTo(targetRef.current.x + dx, targetRef.current.y + dy);\n        return;\n      }\n      const rect = root.getBoundingClientRect();\n      moveTo(event.clientX - rect.left, event.clientY - rect.top);\n    };\n\n    const endDrag = (event: PointerEvent) => {\n      if (touchId === event.pointerId) touchId = null;\n      setDragging(false);\n      try {\n        if (root.hasPointerCapture(event.pointerId)) {\n          root.releasePointerCapture(event.pointerId);\n        }\n      } catch {\n        /* nothing to release */\n      }\n    };\n\n    const observer =\n      typeof ResizeObserver === \"function\"\n        ? new ResizeObserver(() => measure())\n        : null;\n    observer?.observe(root);\n    measure();\n\n    root.addEventListener(\"pointerdown\", onPointerDown);\n    root.addEventListener(\"pointermove\", onPointerMove);\n    root.addEventListener(\"pointerup\", endDrag);\n    root.addEventListener(\"pointercancel\", endDrag);\n\n    return () => {\n      if (frameRef.current) window.cancelAnimationFrame(frameRef.current);\n      frameRef.current = 0;\n      observer?.disconnect();\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerup\", endDrag);\n      root.removeEventListener(\"pointercancel\", endDrag);\n      apiRef.current = null;\n    };\n  }, [size]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const api = apiRef.current;\n    if (!api) return;\n    const step = event.shiftKey ? FINE_STEP : keyboardStep;\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      api.nudge(-step, 0);\n    } else if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      api.nudge(step, 0);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      api.nudge(0, -step);\n    } else if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      api.nudge(0, step);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      api.reset();\n    }\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`lr-reveal${className ? ` ${className}` : \"\"}`}\n      style={{ \"--lr-size\": `${size}px` } as CSSProperties}\n      data-shape={shape}\n      data-dragging={dragging ? \"true\" : \"false\"}\n      data-focused={focused ? \"true\" : \"false\"}\n      role=\"group\"\n      tabIndex={0}\n      aria-label={label}\n      aria-describedby={description ? descriptionId : undefined}\n      onKeyDown={onKeyDown}\n      onFocus={() => setFocused(true)}\n      onBlur={() => setFocused(false)}\n    >\n      <div className=\"lr-base\">{base}</div>\n      {/* Presentational layer: nothing here is focusable or reachable —\n          everything it reveals must also live in the page or in\n          `description` (see the component note above). */}\n      <div className=\"lr-overlay\" aria-hidden=\"true\">\n        <div className=\"lr-layer\">{lens}</div>\n      </div>\n      <span className=\"lr-ring\" aria-hidden=\"true\" />\n      {description ? (\n        <p className=\"lr-sr\" id={descriptionId}>\n          {description}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/lab/lens-reveal/lens-reveal.css","target":"components/lab/lens-reveal/lens-reveal.css","type":"registry:file","content":"/* ============================================================\n   LENS REVEAL — self-contained styles.\n   Scoped tokens with fallbacks; no Lab tokens are read.\n   The component writes --lr-x / --lr-y / --lr-r as pixels once it\n   has measured its container; the CSS fallbacks below keep the lens\n   centred (and roughly sized) during the very first paint.\n   ============================================================ */\n\n.lr-reveal {\n  --lr-ink: #141418;\n  --lr-accent: #4f27e0;\n  --lr-ring: #ffffff;\n  --lr-size: 190px;\n  --lr-r: calc(var(--lr-size) / 2);\n  --lr-x: 50%;\n  --lr-y: 50%;\n  position: relative;\n  overflow: hidden;\n  touch-action: none;\n  cursor: crosshair;\n  isolation: isolate;\n  background: #ffffff;\n}\n\n.lr-reveal:focus-visible {\n  outline: 2px solid var(--lr-accent);\n  outline-offset: -3px;\n}\n\n.lr-reveal[data-dragging=\"true\"] {\n  cursor: grabbing;\n  user-select: none;\n  -webkit-user-select: none;\n}\n\n/* Base and lens layers are both full-bleed; the host gives the root\n   a size (height, aspect-ratio, flex-grow…), and the lens content is\n   registered with the base so the reveal lines up. */\n.lr-base,\n.lr-layer {\n  position: absolute;\n  inset: 0;\n}\n\n/* The overlay is the clipped window; everything inside it is the\n   `lens` node. aria-hidden: the revealed layer is presentational —\n   see the notes in the component source. */\n.lr-overlay {\n  position: absolute;\n  inset: 0;\n  clip-path: circle(var(--lr-r) at var(--lr-x) var(--lr-y));\n}\n\n.lr-reveal[data-shape=\"square\"] .lr-overlay {\n  clip-path: inset(\n    calc(var(--lr-y) - var(--lr-r))\n    calc(100% - var(--lr-x) - var(--lr-r))\n    calc(100% - var(--lr-y) - var(--lr-r))\n    calc(var(--lr-x) - var(--lr-r))\n  );\n}\n\n/* The ring makes the lens read as a physical object over the seam. */\n.lr-ring {\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: calc(var(--lr-r) * 2);\n  height: calc(var(--lr-r) * 2);\n  transform: translate(\n    calc(var(--lr-x) - var(--lr-r)),\n    calc(var(--lr-y) - var(--lr-r))\n  );\n  border: 2px solid var(--lr-ring);\n  border-radius: 999px;\n  box-shadow:\n    0 0 0 1px rgba(20, 20, 24, 0.22),\n    inset 0 0 16px rgba(20, 20, 24, 0.1),\n    0 16px 36px -20px rgba(20, 20, 24, 0.5);\n  pointer-events: none;\n}\n\n.lr-reveal[data-shape=\"square\"] .lr-ring {\n  border-radius: 14px;\n}\n\n.lr-reveal[data-focused=\"true\"] .lr-ring {\n  box-shadow:\n    0 0 0 2px var(--lr-accent),\n    inset 0 0 16px rgba(20, 20, 24, 0.1),\n    0 16px 36px -20px rgba(20, 20, 24, 0.5);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .lr-reveal {\n    cursor: default;\n  }\n}\n\n/* Visually hidden description of what the lens reveals. */\n.lr-sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  white-space: nowrap;\n  border: 0;\n}\n"}]}