{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-hotspots","type":"registry:component","title":"Image hotspots","description":"Draws a leader line to one label card that travels between a watch's features, never covering the product.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/image-hotspots.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: one label card for every hotspot. The picture sits\n   inside a larger stage, and the card is placed wherever it covers the\n   least: never another hotspot, never the product's subject if there is\n   free stage around it, and otherwise as close to its point as it can be.\n   A leader line is recomputed every frame from the point to the nearest\n   spot on the card's edge, so when the card travels to the next hotspot\n   the line bends with it instead of being redrawn. */\n\nexport type Hotspot = {\n  // Position on the image, as a fraction of its width and height.\n  x: number;\n  y: number;\n  title: string;\n  body: string;\n};\n\n// A rectangle on the image, as fractions of its width and height.\nexport type Region = { x: number; y: number; w: number; h: number };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// A little give on the glide so the card reads as carried, not teleported.\nconst GLIDE = { type: \"spring\", visualDuration: 0.35, bounce: 0.12 } as const;\n// Below this stage width the card spans the stage instead of sitting in a\n// gutter beside the product.\nconst NARROW = 520;\nconst CARD_W = 188;\n// Fixed per layout so the card never resizes mid-glide; each fits a\n// counter, a title and the body at that width.\nconst CARD_H = 136;\nconst CARD_H_NARROW = 112;\n// Distance between a hotspot and the near edge of its card.\nconst GAP = 48;\n// Breathing room kept between the card and the subject.\nconst SUBJECT_GAP = 12;\n// Nothing gets closer than this to the stage edge.\nconst MARGIN = 12;\n// The line starts just outside the hotspot's dot rather than at its center.\nconst DOT_R = 9;\n\ntype Side = \"left\" | \"right\" | \"top\" | \"bottom\";\ntype Rect = { x: number; y: number; w: number; h: number };\n\nfunction overlap(a: Rect, b: Rect) {\n  const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);\n  const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);\n  return w > 0 && h > 0 ? w * h : 0;\n}\n\nfunction place(\n  px: number,\n  py: number,\n  W: number,\n  H: number,\n  w: number,\n  h: number,\n  others: { x: number; y: number }[],\n  subject: Rect | null,\n) {\n  const clampX = (x: number) => Math.min(Math.max(x, MARGIN), W - w - MARGIN);\n  const clampY = (y: number) => Math.min(Math.max(y, MARGIN), H - h - MARGIN);\n  const s = subject;\n  // Each side three ways: tucked in next to the point, pushed just clear\n  // of the subject, and pushed all the way to the stage edge.\n  const raw: { side: Side; x: number; y: number }[] = [\n    { side: \"right\", x: px + GAP, y: py - h / 2 },\n    { side: \"right\", x: s ? Math.max(px + GAP, s.x + s.w + SUBJECT_GAP) : W, y: py - h / 2 },\n    { side: \"right\", x: W, y: py - h / 2 },\n    { side: \"left\", x: px - GAP - w, y: py - h / 2 },\n    { side: \"left\", x: s ? Math.min(px - GAP - w, s.x - SUBJECT_GAP - w) : 0, y: py - h / 2 },\n    { side: \"left\", x: 0, y: py - h / 2 },\n    { side: \"bottom\", x: px - w / 2, y: py + GAP },\n    { side: \"bottom\", x: px - w / 2, y: s ? Math.max(py + GAP, s.y + s.h + SUBJECT_GAP) : H },\n    { side: \"bottom\", x: px - w / 2, y: H },\n    { side: \"top\", x: px - w / 2, y: py - GAP - h },\n    { side: \"top\", x: px - w / 2, y: s ? Math.min(py - GAP - h, s.y - SUBJECT_GAP - h) : 0 },\n    { side: \"top\", x: px - w / 2, y: 0 },\n  ];\n  const scored = raw.map((o) => {\n    const x = clampX(o.x);\n    const y = clampY(o.y);\n    const card = { x, y, w, h };\n    // Hiding a hotspot, including the one being described, costs more than\n    // anything else; covering the subject comes next; then the shortest\n    // leader line wins.\n    const hidden = [{ x: px, y: py }, ...others].filter(\n      (p) => p.x > x - 8 && p.x < x + w + 8 && p.y > y - 8 && p.y < y + h + 8,\n    ).length;\n    const covered = s ? overlap(card, s) : 0;\n    const ex = Math.min(Math.max(px, x), x + w);\n    const ey = Math.min(Math.max(py, y), y + h);\n    const cost = hidden * 1e7 + covered * 4 + Math.hypot(ex - px, ey - py);\n    return { side: o.side, x, y, cost };\n  });\n  return scored.reduce((a, b) => (b.cost < a.cost ? b : a));\n}\n\nconst STYLES = `\n@keyframes hotspot-breathe {\n  0% { scale: 1; opacity: 0.7; }\n  70%, 100% { scale: 2.4; opacity: 0; }\n}\n.hotspot-ring { animation: hotspot-breathe 2.8s cubic-bezier(0.23, 1, 0.32, 1) infinite; }\n@media (prefers-reduced-motion: reduce) { .hotspot-ring { animation: none; opacity: 0; } }\n`;\n\nexport function ImageHotspots({\n  hotspots,\n  image,\n  label,\n  aspect = 3 / 4,\n  subject,\n  defaultActive = null,\n  className,\n}: {\n  hotspots: Hotspot[];\n  // The picture itself, fitted into a box of `aspect` (width / height),\n  // centered across the stage and pinned to its top.\n  image: React.ReactNode;\n  label: string;\n  aspect?: number;\n  // The part of the image the card should keep clear of when it can.\n  subject?: Region;\n  defaultActive?: number | null;\n  // Give the stage its shape here (an aspect ratio or a height).\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const id = useId();\n  const stage = useRef<HTMLDivElement>(null);\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const [size, setSize] = useState({ w: 0, h: 0 });\n  const [active, setActive] = useState<number | null>(defaultActive);\n  // The last opened hotspot, so the roving tab stop and the card's content\n  // survive closing.\n  const [focusIndex, setFocusIndex] = useState(defaultActive ?? 0);\n  const wasOpen = useRef(false);\n\n  const hx = useMotionValue(0);\n  const hy = useMotionValue(0);\n  const cx = useMotionValue(0);\n  const cy = useMotionValue(0);\n  const narrow = size.w > 0 && size.w < NARROW;\n  const cardW = narrow ? size.w - MARGIN * 2 : CARD_W;\n  const cardH = narrow ? CARD_H_NARROW : CARD_H;\n  // Read inside the line's transform, which only follows motion values.\n  const cw = useMotionValue(CARD_W);\n  const ch = useMotionValue(CARD_H);\n\n  // The line runs from the hotspot to the closest point on the card, so it\n  // stays attached whichever side the card has moved to.\n  const endX = useTransform(() =>\n    Math.min(Math.max(hx.get(), cx.get()), cx.get() + cw.get()),\n  );\n  const endY = useTransform(() =>\n    Math.min(Math.max(hy.get(), cy.get()), cy.get() + ch.get()),\n  );\n  const line = useTransform(() => {\n    const px = hx.get();\n    const py = hy.get();\n    const ex = endX.get();\n    const ey = endY.get();\n    const len = Math.hypot(ex - px, ey - py) || 1;\n    const sx = px + ((ex - px) / len) * DOT_R;\n    const sy = py + ((ey - py) / len) * DOT_R;\n    return `M${sx} ${sy}L${ex} ${ey}`;\n  });\n\n  useLayoutEffect(() => {\n    const el = stage.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      const { width, height } = entry.contentRect;\n      setSize({ w: width, h: height });\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Same geometry the CSS gives the image box: fitted, centered, top.\n  const bw = Math.min(size.w, size.h * aspect);\n  const bh = bw / aspect;\n  const bx = (size.w - bw) / 2;\n  const toStage = useCallback(\n    (h: { x: number; y: number }) => ({ x: bx + h.x * bw, y: h.y * bh }),\n    [bx, bw, bh],\n  );\n  const subjectRect = subject\n    ? { x: bx + subject.x * bw, y: subject.y * bh, w: subject.w * bw, h: subject.h * bh }\n    : null;\n\n  const layout = (i: number) => {\n    const p = toStage(hotspots[i]);\n    const others = hotspots.filter((_, j) => j !== i).map(toStage);\n    return place(p.x, p.y, size.w, size.h, cardW, cardH, others, subjectRect);\n  };\n\n  // The card grows out of, and closes toward, the edge facing its hotspot.\n  // Based on the last opened point, so a closing card keeps its side.\n  const target = size.w ? layout(active ?? focusIndex) : null;\n  const side: Side = target?.side ?? \"right\";\n  const tx = target?.x ?? 0;\n  const ty = target?.y ?? 0;\n  const point = size.w ? toStage(hotspots[active ?? focusIndex]) : { x: 0, y: 0 };\n  const px = point.x;\n  const py = point.y;\n\n  // Moves the point and the card before paint, so the first frame is\n  // already right. Opening jumps; switching glides and keeps velocity.\n  useLayoutEffect(() => {\n    cw.set(cardW);\n    ch.set(cardH);\n    if (active === null || !size.w) {\n      wasOpen.current = false;\n      return;\n    }\n    if (!wasOpen.current || reduceMotion) {\n      hx.jump(px);\n      hy.jump(py);\n      cx.jump(tx);\n      cy.jump(ty);\n    } else {\n      animate(hx, px, GLIDE);\n      animate(hy, py, GLIDE);\n      animate(cx, tx, GLIDE);\n      animate(cy, ty, GLIDE);\n    }\n    wasOpen.current = true;\n  }, [active, size.w, px, py, tx, ty, cardW, cardH, reduceMotion, hx, hy, cx, cy, cw, ch]);\n\n  useEffect(\n    () => () => {\n      hx.stop();\n      hy.stop();\n      cx.stop();\n      cy.stop();\n    },\n    [hx, hy, cx, cy],\n  );\n\n  const open = useCallback((i: number) => {\n    setActive(i);\n    setFocusIndex(i);\n  }, []);\n\n  const close = useCallback(() => setActive(null), []);\n\n  // Clicking anywhere outside the stage puts the card away.\n  useEffect(() => {\n    if (active === null) return;\n    const onDown = (e: PointerEvent) => {\n      if (!stage.current?.contains(e.target as Node)) close();\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [active, close]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const n = hotspots.length;\n    const from = active ?? focusIndex;\n    let next: number | null = null;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (from + 1) % n;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (from - 1 + n) % n;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = n - 1;\n    else if (e.key === \"Escape\" && active !== null) {\n      e.preventDefault();\n      close();\n      buttons.current[from]?.focus();\n      return;\n    }\n    if (next === null) return;\n    e.preventDefault();\n    open(next);\n    buttons.current[next]?.focus();\n  };\n\n  const shown = active ?? focusIndex;\n  const isOpen = active !== null && size.w > 0;\n  const spot = hotspots[shown];\n  const origin = {\n    right: \"left center\",\n    left: \"right center\",\n    bottom: \"center top\",\n    top: \"center bottom\",\n  }[side];\n  const lineAnimate = isOpen\n    ? { pathLength: 1, opacity: 1, transition: { duration: reduceMotion ? 0 : 0.28, ease: EASE_OUT } }\n    : { pathLength: reduceMotion ? 1 : 0, opacity: 0, transition: { duration: 0.15, ease: EASE_OUT } };\n\n  return (\n    <div\n      ref={stage}\n      role=\"group\"\n      aria-label={label}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"relative aspect-[8/5] w-full overflow-hidden rounded-[20px] bg-muted select-none @container-[size]\",\n        className,\n      )}\n    >\n      <style>{STYLES}</style>\n      {/* Fitted like object-fit: contain, pinned to the top, so on a tall\n          narrow stage the free room collects below for the card. */}\n      <div\n        className=\"absolute top-0 left-1/2 -translate-x-1/2\"\n        style={{\n          width: `min(100cqw, ${aspect * 100}cqh)`,\n          aspectRatio: String(aspect),\n        }}\n      >\n        <div className=\"absolute inset-0\" aria-hidden>\n          {image}\n        </div>\n        {hotspots.map((h, i) => {\n          const current = active === i;\n          return (\n            <button\n              key={h.title}\n              ref={(el) => {\n                buttons.current[i] = el;\n              }}\n              type=\"button\"\n              aria-label={h.title}\n              aria-expanded={current}\n              aria-controls={`${id}-card`}\n              tabIndex={i === focusIndex ? 0 : -1}\n              onClick={() => (current ? close() : open(i))}\n              onFocus={() => setFocusIndex(i)}\n              className={cn(\n                \"group/spot absolute z-10 flex size-10 -translate-x-1/2 -translate-y-1/2 touch-manipulation items-center justify-center rounded-full outline-hidden\",\n                \"transition-[scale] duration-150 ease-out active:scale-[0.96]\",\n                \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n              )}\n              style={{ left: `${h.x * 100}%`, top: `${h.y * 100}%` }}\n            >\n              {/* Staggered so the points breathe out of step, like a room\n                  of people rather than a metronome. */}\n              <span\n                className={cn(\n                  \"hotspot-ring absolute size-3.5 rounded-full bg-background\",\n                  current && \"hidden\",\n                )}\n                style={{ animationDelay: `${i * 0.55}s` }}\n              />\n              <span\n                className={cn(\n                  \"relative size-3.5 rounded-full border-2 border-background bg-foreground shadow-[0_1px_3px_oklch(0_0_0/0.4)]\",\n                  \"transition-[scale] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                  current ? \"scale-125\" : \"group-hover/spot:scale-110\",\n                )}\n              />\n            </button>\n          );\n        })}\n      </div>\n\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 z-10 size-full overflow-visible\"\n      >\n        {/* A pale halo under the line keeps it readable where it crosses\n            dark parts of the picture. */}\n        <motion.path\n          d={line}\n          fill=\"none\"\n          stroke=\"var(--background)\"\n          strokeOpacity={0.7}\n          strokeWidth={3.5}\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={lineAnimate}\n        />\n        <motion.path\n          d={line}\n          fill=\"none\"\n          stroke=\"var(--foreground)\"\n          strokeWidth={1.25}\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={lineAnimate}\n        />\n        <motion.circle\n          cx={endX}\n          cy={endY}\n          r={2.5}\n          fill=\"var(--foreground)\"\n          initial={false}\n          animate={{\n            opacity: isOpen ? 1 : 0,\n            transition: { duration: isOpen ? 0.2 : 0.12, delay: isOpen && !reduceMotion ? 0.2 : 0 },\n          }}\n        />\n      </svg>\n\n      <motion.div\n        id={`${id}-card`}\n        aria-live=\"polite\"\n        style={{ x: cx, y: cy, width: cardW, height: cardH, transformOrigin: origin }}\n        initial={false}\n        animate={\n          isOpen\n            ? { opacity: 1, scale: 1, filter: \"blur(0px)\", transition: { duration: 0.22, ease: EASE_OUT } }\n            : { opacity: 0, scale: reduceMotion ? 1 : 0.97, filter: \"blur(2px)\", transition: { duration: 0.14, ease: EASE_OUT } }\n        }\n        className={cn(\n          \"absolute top-0 left-0 z-20 overflow-hidden rounded-[14px] bg-background p-3.5 shadow-raised\",\n          !isOpen && \"pointer-events-none\",\n        )}\n      >\n        {isOpen && (\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.div\n              key={shown}\n              initial={{ opacity: 0, filter: \"blur(4px)\", y: reduceMotion ? 0 : 4 }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", y: 0, transition: { duration: 0.24, ease: EASE_OUT } }}\n              exit={{ opacity: 0, filter: \"blur(2px)\", transition: { duration: 0.12, ease: EASE_OUT } }}\n            >\n              <p className=\"text-xs font-medium text-muted-foreground tabular-nums\">\n                {String(shown + 1).padStart(2, \"0\")} / {String(hotspots.length).padStart(2, \"0\")}\n              </p>\n              <p className=\"mt-1 text-[15px]/5 font-semibold text-foreground\">{spot.title}</p>\n              <p className=\"mt-1 text-sm/5 text-pretty text-muted-foreground\">{spot.body}</p>\n            </motion.div>\n          </AnimatePresence>\n        )}\n      </motion.div>\n    </div>\n  );\n}\n\n// Physical materials, the same in both themes as a product photo would be:\n// brushed steel, a deep green sunray dial, cream lume, tan leather and an\n// orange seconds hand.\nconst STEEL_HI = \"oklch(0.95 0.004 250)\";\nconst STEEL_MID = \"oklch(0.74 0.008 250)\";\nconst STEEL_LO = \"oklch(0.58 0.01 250)\";\nconst DIAL_IN = \"oklch(0.47 0.075 162)\";\nconst DIAL_OUT = \"oklch(0.25 0.045 165)\";\nconst LUME = \"oklch(0.95 0.03 95)\";\nconst LEATHER = \"oklch(0.55 0.1 55)\";\nconst LEATHER_DARK = \"oklch(0.42 0.085 50)\";\nconst HOLE = \"oklch(0.22 0.04 50)\";\nconst THREAD = \"oklch(0.9 0.04 85)\";\nconst SECONDS = \"oklch(0.7 0.17 45)\";\nconst GLARE = \"oklch(1 0 0 / 0.16)\";\n\n// The classic 10:09 shop-window time: the hands frame the logo.\nconst HOUR_ANGLE = (10 + 9 / 60) * 30;\nconst MINUTE_ANGLE = 9 * 6;\n\nconst WATCH_STYLES = `\n@keyframes watch-sweep { to { rotate: 360deg; } }\n.watch-seconds { animation: watch-sweep 60s linear infinite; transform-origin: 150px 200px; }\n@media (prefers-reduced-motion: reduce) { .watch-seconds { animation: none; } }\n`;\n\n/* A field watch lying flat, seen from above, in a 300 x 400 frame. */\nfunction Watch() {\n  const uid = `w${useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const steel = `url(#${uid}s)`;\n  return (\n    <svg viewBox=\"0 0 300 400\" className=\"size-full overflow-visible\">\n      <style>{WATCH_STYLES}</style>\n      <defs>\n        <linearGradient id={`${uid}s`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n          <stop offset=\"0\" stopColor={STEEL_HI} />\n          <stop offset=\"0.55\" stopColor={STEEL_MID} />\n          <stop offset=\"1\" stopColor={STEEL_HI} />\n        </linearGradient>\n        <radialGradient id={`${uid}d`} cx=\"0.42\" cy=\"0.38\" r=\"0.7\">\n          <stop offset=\"0\" stopColor={DIAL_IN} />\n          <stop offset=\"1\" stopColor={DIAL_OUT} />\n        </radialGradient>\n        <linearGradient id={`${uid}l`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          <stop offset=\"0\" stopColor={LEATHER_DARK} />\n          <stop offset=\"0.3\" stopColor={LEATHER} />\n          <stop offset=\"0.75\" stopColor={LEATHER} />\n          <stop offset=\"1\" stopColor={LEATHER_DARK} />\n        </linearGradient>\n      </defs>\n\n      <g className=\"[filter:drop-shadow(0_12px_14px_oklch(0_0_0/0.28))]\">\n        {/* Straps: the buckle end above, the holes below. Both run well\n            past the frame, so a tall stage never shows where they end. */}\n        <rect x=\"112\" y=\"-200\" width=\"76\" height=\"340\" rx=\"6\" fill={`url(#${uid}l)`} />\n        <rect x=\"112\" y=\"262\" width=\"76\" height=\"340\" rx=\"6\" fill={`url(#${uid}l)`} />\n        {[-200, 262].map((y) => (\n          <g key={y} stroke={THREAD} strokeWidth=\"1\" strokeDasharray=\"4 3\" opacity=\"0.75\">\n            <line x1=\"118\" x2=\"118\" y1={y + 4} y2={y + 334} />\n            <line x1=\"182\" x2=\"182\" y1={y + 4} y2={y + 334} />\n          </g>\n        ))}\n        {[322, 344, 366].map((y) => (\n          <circle key={y} cx=\"150\" cy={y} r=\"3.6\" fill={HOLE} />\n        ))}\n        {/* Keeper loop and a brushed pin buckle. */}\n        <rect x=\"108\" y=\"66\" width=\"84\" height=\"12\" rx=\"3\" fill={LEATHER_DARK} />\n        <rect x=\"104\" y=\"18\" width=\"92\" height=\"34\" rx=\"9\" fill=\"none\" stroke={steel} strokeWidth=\"6\" />\n        <rect x=\"147\" y=\"14\" width=\"6\" height=\"42\" rx=\"3\" fill={steel} />\n\n        {/* Lugs. */}\n        {[\n          [102, 112],\n          [184, 112],\n          [102, 258],\n          [184, 258],\n        ].map(([x, y]) => (\n          <rect key={`${x}-${y}`} x={x} y={y} width=\"14\" height=\"30\" rx=\"5\" fill={steel} />\n        ))}\n\n        {/* Crown, with its grip. */}\n        <rect x=\"226\" y=\"186\" width=\"20\" height=\"28\" rx=\"4\" fill={steel} />\n        {[191, 196, 201, 206, 211].map((y) => (\n          <line key={y} x1=\"236\" x2=\"246\" y1={y} y2={y} stroke={STEEL_LO} strokeWidth=\"1\" />\n        ))}\n\n        {/* Case, polished bezel, then the dial. */}\n        <circle cx=\"150\" cy=\"200\" r=\"80\" fill={steel} />\n        <circle cx=\"150\" cy=\"200\" r=\"73\" fill=\"none\" stroke={STEEL_LO} strokeWidth=\"1.5\" opacity=\"0.6\" />\n        <circle cx=\"150\" cy=\"200\" r=\"68\" fill={`url(#${uid}d)`} />\n      </g>\n\n      {/* Minute track and applied hour markers, 3 o'clock giving way to\n          the date window. */}\n      {Array.from({ length: 60 }, (_, i) => (\n        <line\n          key={i}\n          x1=\"150\"\n          x2=\"150\"\n          y1=\"135\"\n          y2=\"138.5\"\n          stroke={LUME}\n          strokeOpacity={i % 5 ? 0.35 : 0.7}\n          strokeWidth=\"0.8\"\n          transform={`rotate(${i * 6} 150 200)`}\n        />\n      ))}\n      {Array.from({ length: 12 }, (_, i) =>\n        i === 3 ? null : (\n          <rect\n            key={i}\n            x={i === 0 ? 144 : 148}\n            y=\"142\"\n            width={i === 0 ? 12 : 4}\n            height=\"12\"\n            rx=\"1.2\"\n            fill={LUME}\n            transform={`rotate(${i * 30} 150 200)`}\n          />\n        ),\n      )}\n      <rect x=\"190\" y=\"193\" width=\"18\" height=\"14\" rx=\"2\" fill={LUME} />\n      <text x=\"199\" y=\"203.5\" textAnchor=\"middle\" fontSize=\"9\" fontWeight=\"600\" fill={DIAL_OUT}>\n        23\n      </text>\n      <text x=\"150\" y=\"172\" textAnchor=\"middle\" fontSize=\"9\" fontWeight=\"700\" letterSpacing=\"2.4\" fill={LUME}>\n        AUREL\n      </text>\n      <text x=\"150\" y=\"238\" textAnchor=\"middle\" fontSize=\"5.5\" letterSpacing=\"1.6\" fill={LUME} opacity=\"0.7\">\n        AUTOMATIC\n      </text>\n\n      {/* Hands: hour, minute, then the sweeping seconds. */}\n      <g transform={`rotate(${HOUR_ANGLE} 150 200)`}>\n        <rect x=\"146.5\" y=\"164\" width=\"7\" height=\"42\" rx=\"3.5\" fill={steel} />\n        <rect x=\"148.5\" y=\"168\" width=\"3\" height=\"24\" rx=\"1.5\" fill={LUME} />\n      </g>\n      <g transform={`rotate(${MINUTE_ANGLE} 150 200)`}>\n        <rect x=\"147.5\" y=\"142\" width=\"5\" height=\"64\" rx=\"2.5\" fill={steel} />\n        <rect x=\"149\" y=\"146\" width=\"2\" height=\"40\" rx=\"1\" fill={LUME} />\n      </g>\n      <g className=\"watch-seconds\">\n        <rect x=\"149.3\" y=\"138\" width=\"1.4\" height=\"76\" rx=\"0.7\" fill={SECONDS} />\n        <circle cx=\"150\" cy=\"210\" r=\"3\" fill={SECONDS} />\n      </g>\n      <circle cx=\"150\" cy=\"200\" r=\"3.4\" fill={steel} />\n\n      {/* Sapphire glare across the upper left of the crystal. */}\n      <path d=\"M92 176A62 62 0 0 1 176 138A72 72 0 0 0 92 176Z\" fill={GLARE} />\n    </svg>\n  );\n}\n\nconst SPOTS: Hotspot[] = [\n  {\n    x: 150 / 300,\n    y: 35 / 400,\n    title: \"Pin buckle\",\n    body: \"Brushed steel, sized to the strap so it sits flat on the wrist.\",\n  },\n  {\n    x: 112 / 300,\n    y: 168 / 400,\n    title: \"Sapphire crystal\",\n    body: \"Domed, with anti-glare inside, so the dial reads at any angle.\",\n  },\n  {\n    x: 238 / 300,\n    y: 200 / 400,\n    title: \"Screw-down crown\",\n    body: \"Seals the case to 100 m. Pull once for the date, twice for time.\",\n  },\n  {\n    x: 181 / 300,\n    y: 176 / 400,\n    title: \"Lume-filled hands\",\n    body: \"Charge them in daylight and they glow through the night.\",\n  },\n  {\n    x: 150 / 300,\n    y: 344 / 400,\n    title: \"Leather strap\",\n    body: \"Vegetable tanned, so it darkens with wear. Quick-release pins.\",\n  },\n];\n\n// The case, crown and lugs: what the card should never sit on.\nconst CASE: Region = { x: 100 / 300, y: 112 / 400, w: 148 / 300, h: 176 / 400 };\n\nexport default function ImageHotspotsDemo() {\n  return (\n    <div className=\"flex w-[640px] max-w-full flex-col gap-4\">\n      <ImageHotspots\n        label=\"Aurel Field watch, feature points\"\n        image={<Watch />}\n        hotspots={SPOTS}\n        subject={CASE}\n        defaultActive={1}\n        className=\"aspect-[4/7] sm:aspect-[8/5]\"\n      />\n      <div className=\"flex items-baseline justify-between gap-4 px-1\">\n        <div className=\"min-w-0\">\n          <p className=\"text-[15px] font-semibold text-foreground\">Aurel Field 38</p>\n          <p className=\"text-sm text-muted-foreground\">Automatic, 38 mm steel case</p>\n        </div>\n        <p className=\"text-[15px] font-medium text-foreground tabular-nums\">$495</p>\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/image-hotspots"}