{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hover-link-peek","type":"registry:component","title":"Link Peek Preview","description":"An index list of links that floats a mini rendering of each destination beside the pointer: spring-lagged, leaning with sweep speed, shown on keyboard focus and on a touch long-press.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/hover-link-peek.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\ntype Row = {\n  id: string;\n  title: string;\n  /** Optional screenshot for the peek card. Without it the card paints a small generated page. */\n  image?: string;\n  meta?: string;\n  /** Where the row links. Defaults to \"#\" + id. */\n  href?: string;\n  /** One or two sentences shown on the peek card (and read out as the link's description). */\n  description?: string;\n  /** Shown beside the favicon monogram. Derived from the title when omitted. */\n  domain?: string;\n  /** Hex colour for the favicon and the generated art. */\n  accent?: string;\n};\n\ntype Anchor = \"pointer\" | \"row\" | \"touch\";\n\ntype Engine = {\n  rawX: number;\n  rawY: number;\n  rowH: number;\n  anchor: Anchor;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  open: boolean;\n  shown: boolean;\n  closedAt: number;\n  scale: number;\n  tilt: number;\n  reduce: boolean;\n  last: number;\n  raf: number;\n  wake: () => void;\n  lp: boolean;\n  suppress: boolean;\n  startX: number;\n  startY: number;\n  timer: number;\n  hideTimer: number;\n  suppressTimer: number;\n};\n\nconst CARD_W = 240;\nconst CARD_H = 176;\nconst SPRING_K = 260;\nconst SPRING_C = 26;\nconst SPRING_M = 0.5;\nconst LAG = 90;\nconst LONG_PRESS_MS = 380;\nconst MOVE_SLOP = 10;\n\nconst PALETTE = [\"#6f8cff\", \"#e5a45c\", \"#58d0a8\", \"#d77aa6\", \"#8ab4f8\"];\n\nconst DEFAULT_ROWS: Row[] = [\n  { id: \"01\", title: \"Meridian Atlas\", meta: \"Case study\", domain: \"meridian.atlas\", description: \"Interactive tide charts for coastal pilots, rebuilt from a decade of sonar logs.\" },\n  { id: \"02\", title: \"Halcyon Type\", meta: \"Specimen\", domain: \"halcyon.type\", description: \"A variable serif drawn for long reading on small, bright screens.\" },\n  { id: \"03\", title: \"Parallel Kitchen\", meta: \"Identity\", domain: \"parallel.kitchen\", description: \"Brand system for a supper club that cooks two menus at once.\" },\n  { id: \"04\", title: \"Lumen Ledger\", meta: \"Product\", domain: \"lumen.ledger\", description: \"Bookkeeping that explains itself, one reconciled line at a time.\" },\n  { id: \"05\", title: \"Tidepool Audio\", meta: \"Case study\", domain: \"tidepool.audio\", description: \"A field-recording library you can browse by place, season and weather.\" },\n];\n\nconst TONES = {\n  dark: {\n    bg: \"#0d0f13\",\n    border: \"rgba(255,255,255,0.13)\",\n    text: \"#f3f4f6\",\n    sub: \"rgba(243,244,246,0.62)\",\n    bar: \"rgba(255,255,255,0.05)\",\n    barLine: \"rgba(255,255,255,0.08)\",\n    shadow: \"0 28px 56px -14px rgba(0,0,0,0.62), 0 10px 22px -8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)\",\n    sheen: \"rgba(255,255,255,0.2)\",\n  },\n  light: {\n    bg: \"#ffffff\",\n    border: \"rgba(15,23,42,0.12)\",\n    text: \"#0f172a\",\n    sub: \"rgba(15,23,42,0.62)\",\n    bar: \"#f5f6f8\",\n    barLine: \"rgba(15,23,42,0.08)\",\n    shadow: \"0 26px 50px -16px rgba(15,23,42,0.32), 0 8px 18px -8px rgba(15,23,42,0.18)\",\n    sheen: \"rgba(255,255,255,0.7)\",\n  },\n} as const;\n\nconst CSS = [\n  \".hlp-row{transition:opacity .35s cubic-bezier(.16,1,.3,1);-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;touch-action:pan-y}\",\n  '.hlp[data-open=\"true\"] .hlp-row:not([data-on=\"true\"]){opacity:.36}',\n  \".hlp-title{display:inline-block;transition:transform .45s cubic-bezier(.16,1,.3,1)}\",\n  '.hlp-row[data-on=\"true\"] .hlp-title{transform:translateX(8px)}',\n  \".hlp-arrow{opacity:0;transform:translate(-6px,6px);transition:opacity .3s ease,transform .45s cubic-bezier(.16,1,.3,1)}\",\n  '.hlp-row[data-on=\"true\"] .hlp-arrow{opacity:.9;transform:none}',\n  \".hlp-shell{position:absolute;left:0;top:0;z-index:30;pointer-events:none;will-change:transform}\",\n  \".hlp-shell:not([data-ready]){visibility:hidden}\",\n  \".hlp-tilt{width:100%;height:100%;transform-origin:50% 50%;--lx:50%}\",\n  \".hlp-pop{width:100%;height:100%;opacity:0;transform:scale(.86) translateY(8px);filter:blur(6px);transition:opacity .14s ease,transform .14s ease,filter .14s ease}\",\n  '.hlp-shell[data-open=\"true\"] .hlp-pop{opacity:1;transform:none;filter:none;transition-duration:.34s;transition-timing-function:cubic-bezier(.16,1,.3,1)}',\n  \".hlp-pane{position:absolute;inset:0;opacity:0;transform:translateY(8px);transition:opacity .22s ease,transform .38s cubic-bezier(.16,1,.3,1)}\",\n  '.hlp-pane[data-on=\"true\"]{opacity:1;transform:none}',\n  \"@media (prefers-reduced-motion:reduce){.hlp-title,.hlp-arrow,.hlp-pane,.hlp-row{transition:none}.hlp-row[data-on=\\\"true\\\"] .hlp-title{transform:none}.hlp-pop,.hlp-shell[data-open=\\\"true\\\"] .hlp-pop{transform:none;filter:none;transition:opacity .12s linear}.hlp-pane{transform:none}}\",\n].join(\"\\n\");\n\nconst nowMs = () => performance.now();\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nfunction safeHref(raw: string | undefined, fallback: string): string {\n  if (!raw) return fallback;\n  const s = raw.trim();\n  if (!s || /[\\\\\\u0000-\\u001f]/.test(s)) return fallback;\n  if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^(https?:|mailto:)/i.test(s) ? s : fallback;\n  return s;\n}\n\nfunction safeSrc(raw: string | undefined): string | undefined {\n  if (!raw) return undefined;\n  const s = raw.trim();\n  if (!s || /[\\\\\\u0000-\\u001f]/.test(s)) return undefined;\n  if (/^data:image\\/(png|jpe?g|webp|avif|gif);/i.test(s)) return s;\n  if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^https?:/i.test(s) ? s : undefined;\n  return s;\n}\n\nfunction accentFor(row: Row, i: number): string {\n  if (row.accent && /^#[0-9a-f]{3,8}$/i.test(row.accent)) return row.accent;\n  return PALETTE[i % PALETTE.length] ?? \"#6f8cff\";\n}\n\nfunction domainFor(row: Row): string {\n  if (row.domain) return row.domain;\n  const h = row.href && /^https?:\\/\\//i.test(row.href) ? row.href.replace(/^https?:\\/\\//i, \"\").split(/[/?#]/)[0] : \"\";\n  if (h) return h.replace(/^www\\./, \"\");\n  return row.title.toLowerCase().replace(/[^a-z0-9]+/g, \"\") + \".com\";\n}\n\nfunction Art({ variant, accent }: { variant: number; accent: string }) {\n  const hi = `color-mix(in srgb, ${accent} 88%, #ffffff)`;\n  const bar = (w: number, h: number, top: number, left: number, a: number): CSSProperties => ({\n    position: \"absolute\",\n    top,\n    left,\n    width: w,\n    height: h,\n    borderRadius: h / 2,\n    background: `rgba(255,255,255,${a})`,\n  });\n  if (variant === 0) {\n    return (\n      <>\n        <div style={{ position: \"absolute\", right: -26, top: -38, width: 150, height: 150, borderRadius: \"50%\", background: `radial-gradient(circle at 36% 36%, ${hi}, transparent 68%)`, opacity: 0.85 }} />\n        <div style={bar(118, 9, 18, 14, 0.9)} />\n        <div style={bar(84, 9, 33, 14, 0.9)} />\n        <div style={bar(132, 4, 52, 14, 0.32)} />\n        <div style={bar(46, 15, 63, 14, 0.95)} />\n      </>\n    );\n  }\n  if (variant === 1) {\n    return (\n      <>\n        <div style={bar(96, 8, 14, 14, 0.9)} />\n        <div style={bar(60, 4, 28, 14, 0.34)} />\n        {[0, 1, 2].map((n) => (\n          <div key={n} style={{ position: \"absolute\", left: 14 + n * 70, top: 40, width: 62, height: 38, borderRadius: 7, background: `rgba(255,255,255,${0.1 + n * 0.05})`, border: \"1px solid rgba(255,255,255,0.14)\" }}>\n            <div style={{ position: \"absolute\", left: 7, top: 7, width: 22, height: 4, borderRadius: 2, background: \"rgba(255,255,255,0.5)\" }} />\n            <div style={{ position: \"absolute\", left: 7, bottom: 7, width: 34 + n * 6, height: 3, borderRadius: 2, background: `color-mix(in srgb, ${accent} 70%, #ffffff)`, opacity: 0.9 }} />\n          </div>\n        ))}\n      </>\n    );\n  }\n  return (\n    <>\n      {[84, 58, 34].map((d, n) => (\n        <div key={d} style={{ position: \"absolute\", left: \"50%\", top: \"50%\", width: d, height: d, marginLeft: -d / 2, marginTop: -d / 2 - 2, borderRadius: \"50%\", border: `1px solid rgba(255,255,255,${0.2 + n * 0.14})` }} />\n      ))}\n      <div style={{ position: \"absolute\", left: \"50%\", top: \"50%\", width: 12, height: 12, marginLeft: -6, marginTop: -8, borderRadius: \"50%\", background: hi, boxShadow: `0 0 18px ${accent}` }} />\n      <div style={{ ...bar(54, 4, 72, 93, 0.4) }} />\n    </>\n  );\n}\n\nfunction PeekPane({ row, index, tone, on }: { row: Row; index: number; tone: \"dark\" | \"light\"; on: boolean }) {\n  const t = TONES[tone];\n  const accent = accentFor(row, index);\n  const domain = domainFor(row);\n  const src = safeSrc(row.image);\n  return (\n    <div className=\"hlp-pane\" data-on={on ? \"true\" : \"false\"}>\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 7,\n          height: 24,\n          padding: \"0 10px\",\n          background: t.bar,\n          borderBottom: `1px solid ${t.barLine}`,\n          color: t.sub,\n          fontFamily: \"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\",\n          fontSize: 9.5,\n          letterSpacing: \"0.02em\",\n        }}\n      >\n        <span\n          style={{\n            display: \"grid\",\n            placeItems: \"center\",\n            width: 13,\n            height: 13,\n            borderRadius: 4,\n            color: \"#fff\",\n            fontFamily: \"system-ui, sans-serif\",\n            fontSize: 8,\n            fontWeight: 700,\n            lineHeight: 1,\n            background: `linear-gradient(135deg, ${accent}, color-mix(in srgb, ${accent} 40%, #000000))`,\n          }}\n        >\n          {domain.charAt(0).toUpperCase()}\n        </span>\n        <span style={{ minWidth: 0, flex: 1, overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\" }}>{domain}</span>\n        <span style={{ opacity: 0.7 }}>{\"↗\"}</span>\n      </div>\n      <div\n        style={{\n          position: \"relative\",\n          height: 84,\n          overflow: \"hidden\",\n          background: `linear-gradient(155deg, color-mix(in srgb, ${accent} 62%, #0a0c10) 0%, color-mix(in srgb, ${accent} 14%, #0a0c10) 100%)`,\n        }}\n      >\n        {src ? (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img src={src} alt=\"\" draggable={false} style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", objectFit: \"cover\" }} />\n        ) : (\n          <Art variant={index % 3} accent={accent} />\n        )}\n        <div style={{ position: \"absolute\", inset: 0, background: \"linear-gradient(180deg, transparent 55%, rgba(0,0,0,0.28))\" }} />\n      </div>\n      <div style={{ padding: \"9px 12px 0\", color: t.text }}>\n        <div style={{ fontSize: 12.5, lineHeight: \"16px\", fontWeight: 600, letterSpacing: \"-0.01em\", overflow: \"hidden\", textOverflow: \"ellipsis\", whiteSpace: \"nowrap\" }}>{row.title}</div>\n        {row.description ? (\n          <div style={{ marginTop: 3, fontSize: 10, lineHeight: \"14px\", color: t.sub, display: \"-webkit-box\", WebkitLineClamp: 2, WebkitBoxOrient: \"vertical\", overflow: \"hidden\" }}>{row.description}</div>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nexport function HoverLinkPeek({\n  rows = DEFAULT_ROWS,\n  tilt = 18,\n  cardSize = 104,\n  showMeta = true,\n  tone = \"dark\",\n  restingIndex,\n  className,\n}: {\n  rows?: Row[];\n  /** Max lean of the card in degrees. */\n  tilt?: number;\n  /** Size of the peek card. 104 is the default scale (a 240px wide card); 64 to 200. */\n  cardSize?: number;\n  /** The right-hand meta column. Rows without a meta simply render blank. */\n  showMeta?: boolean;\n  /** Surface of the floating card. The list itself inherits the text colour of its page. */\n  tone?: \"dark\" | \"light\";\n  /** Keep the card resting on this row whenever nothing is hovered (a static demo state). */\n  restingIndex?: number;\n  className?: string;\n}) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const rest = restingIndex !== undefined && restingIndex >= 0 && restingIndex < rows.length ? restingIndex : null;\n  const scale = clamp(cardSize, 64, 400) / 104;\n  const cw = CARD_W * scale;\n  const ch = CARD_H * scale;\n\n  const boxRef = useRef<HTMLDivElement>(null);\n  const shellRef = useRef<HTMLDivElement>(null);\n  const tiltRef = useRef<HTMLDivElement>(null);\n  const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);\n  const anchorRef = useRef<(i: number) => void>(() => {});\n  const eng = useRef<Engine>({\n    rawX: 0, rawY: 0, rowH: 40, anchor: \"row\",\n    x: 0, y: 0, vx: 0, vy: 0,\n    open: false, shown: false, closedAt: -1e9,\n    scale: 1, tilt: 18, reduce: false,\n    last: 0, raf: 0, wake: () => {},\n    lp: false, suppress: false, startX: 0, startY: 0,\n    timer: 0, hideTimer: 0, suppressTimer: 0,\n  });\n  const [active, setActive] = useState<number | null>(rest);\n\n  useEffect(() => {\n    const e = eng.current;\n    e.scale = scale;\n    e.tilt = tilt;\n    e.wake();\n  }, [scale, tilt]);\n\n  useEffect(() => {\n    const e = eng.current;\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onMq = () => {\n      e.reduce = mq.matches;\n      e.wake();\n    };\n    e.reduce = mq.matches;\n    mq.addEventListener(\"change\", onMq);\n\n    const step = (t: number) => {\n      e.raf = 0;\n      const box = boxRef.current;\n      const shell = shellRef.current;\n      const tl = tiltRef.current;\n      if (!box || !shell || !tl) return;\n      const dt = e.last ? clamp((t - e.last) / 1000, 0, 0.05) : 1 / 60;\n      e.last = t;\n      const W = box.offsetWidth;\n      const w = CARD_W * e.scale;\n      const h = CARD_H * e.scale;\n      const maxL = Math.max(0, W - w);\n      let tx: number;\n      let ty: number;\n      if (e.anchor === \"touch\") {\n        tx = clamp(e.rawX - w / 2, 0, maxL);\n        ty = e.rawY - h - 30;\n        if (ty < -24) ty = e.rawY + 38;\n      } else {\n        const l = e.rawX + 22;\n        if (l + w <= W - 2) {\n          tx = l;\n          ty = e.rawY - h / 2;\n        } else if (e.anchor === \"row\" && e.rawX - 22 - w < 0) {\n          tx = clamp(e.rawX - w / 2, 0, maxL);\n          ty = e.rawY + e.rowH / 2 + 10;\n        } else {\n          tx = clamp(e.rawX - 22 - w, 0, maxL);\n          ty = e.rawY - h / 2;\n        }\n      }\n      if (e.open && !e.shown) {\n        e.x = tx;\n        e.y = ty;\n        e.vx = 0;\n        e.vy = 0;\n        e.shown = true;\n      }\n      if (e.reduce || !e.shown) {\n        e.x = tx;\n        e.y = ty;\n        e.vx = 0;\n        e.vy = 0;\n      } else {\n        const n = Math.max(1, Math.ceil(dt * 120));\n        const s = dt / n;\n        for (let k = 0; k < n; k++) {\n          e.vx += ((SPRING_K * (tx - e.x) - SPRING_C * e.vx) / SPRING_M) * s;\n          e.vy += ((SPRING_K * (ty - e.y) - SPRING_C * e.vy) / SPRING_M) * s;\n          e.x += e.vx * s;\n          e.y += e.vy * s;\n        }\n      }\n      const lx = tx - e.x;\n      const ly = ty - e.y;\n      const nx = e.reduce ? 0 : clamp(lx / LAG, -1, 1);\n      const ny = e.reduce ? 0 : clamp(ly / LAG, -1, 1);\n      shell.style.transform = \"translate3d(\" + e.x.toFixed(2) + \"px,\" + e.y.toFixed(2) + \"px,0)\";\n      tl.style.transform = e.reduce\n        ? \"none\"\n        : \"perspective(720px) rotateX(\" + (-ny * e.tilt * 0.5).toFixed(2) + \"deg) rotateY(\" + (nx * e.tilt * 0.5).toFixed(2) + \"deg) rotate(\" + (nx * e.tilt).toFixed(2) + \"deg)\";\n      tl.style.setProperty(\"--lx\", (50 + nx * 45).toFixed(1) + \"%\");\n      if (!shell.hasAttribute(\"data-ready\")) shell.setAttribute(\"data-ready\", \"1\");\n      const settled = Math.abs(lx) < 0.05 && Math.abs(ly) < 0.05 && Math.abs(e.vx) < 0.05 && Math.abs(e.vy) < 0.05;\n      if (settled) e.last = 0;\n      else e.raf = requestAnimationFrame(step);\n    };\n    e.wake = () => {\n      if (!e.raf) e.raf = requestAnimationFrame(step);\n    };\n\n    anchorRef.current = (i: number) => {\n      const box = boxRef.current;\n      const a = linkRefs.current[i];\n      if (!box || !a) return;\n      const br = box.getBoundingClientRect();\n      const ar = a.getBoundingClientRect();\n      const k = br.width ? box.offsetWidth / br.width : 1;\n      e.rawX = (ar.left - br.left) * k + ar.width * k * 0.5;\n      e.rawY = (ar.top - br.top) * k + (ar.height * k) / 2;\n      e.rowH = ar.height * k;\n      e.anchor = \"row\";\n      e.wake();\n    };\n\n    let ro: ResizeObserver | undefined;\n    if (typeof ResizeObserver !== \"undefined\" && boxRef.current) {\n      ro = new ResizeObserver(() => {\n        if (e.anchor === \"row\" && e.open) {\n          const i = linkRefs.current.findIndex((a) => a?.getAttribute(\"data-on\") === \"true\");\n          if (i >= 0) anchorRef.current(i);\n        }\n        e.wake();\n      });\n      ro.observe(boxRef.current);\n    }\n\n    return () => {\n      mq.removeEventListener(\"change\", onMq);\n      ro?.disconnect();\n      if (e.raf) cancelAnimationFrame(e.raf);\n      e.raf = 0;\n      e.wake = () => {};\n      window.clearTimeout(e.timer);\n      window.clearTimeout(e.hideTimer);\n      window.clearTimeout(e.suppressTimer);\n    };\n  }, []);\n\n  useEffect(() => {\n    const e = eng.current;\n    const open = active !== null;\n    if (open && !e.open && nowMs() - e.closedAt > 240) e.shown = false;\n    if (!open && e.open) e.closedAt = nowMs();\n    e.open = open;\n    if (open && e.anchor === \"row\") anchorRef.current(active);\n    e.wake();\n  }, [active]);\n\n  const setRaw = (clientX: number, clientY: number) => {\n    const box = boxRef.current;\n    if (!box) return;\n    const r = box.getBoundingClientRect();\n    const k = r.width ? box.offsetWidth / r.width : 1;\n    const e = eng.current;\n    e.rawX = (clientX - r.left) * k;\n    e.rawY = (clientY - r.top) * k;\n  };\n\n  const settle = () => {\n    if (rest !== null) eng.current.anchor = \"row\";\n    setActive(rest);\n  };\n\n  const onRowEnter = (i: number, ev: ReactPointerEvent<HTMLAnchorElement>) => {\n    if (ev.pointerType === \"touch\") return;\n    const e = eng.current;\n    setRaw(ev.clientX, ev.clientY);\n    e.anchor = \"pointer\";\n    setActive(i);\n    e.wake();\n  };\n\n  const onRowDown = (i: number, ev: ReactPointerEvent<HTMLAnchorElement>) => {\n    if (ev.pointerType !== \"touch\") return;\n    const e = eng.current;\n    window.clearTimeout(e.hideTimer);\n    window.clearTimeout(e.timer);\n    e.startX = ev.clientX;\n    e.startY = ev.clientY;\n    const cx = ev.clientX;\n    const cy = ev.clientY;\n    e.timer = window.setTimeout(() => {\n      e.lp = true;\n      e.suppress = true;\n      window.clearTimeout(e.suppressTimer);\n      e.suppressTimer = window.setTimeout(() => {\n        e.suppress = false;\n      }, 1500);\n      setRaw(cx, cy);\n      e.anchor = \"touch\";\n      setActive(i);\n      e.wake();\n      try {\n        navigator.vibrate?.(8);\n      } catch {\n        /* vibration is optional */\n      }\n    }, LONG_PRESS_MS);\n  };\n\n  const onBoxMove = (ev: ReactPointerEvent<HTMLDivElement>) => {\n    const e = eng.current;\n    if (ev.pointerType === \"touch\") {\n      if (!e.lp) {\n        if (Math.hypot(ev.clientX - e.startX, ev.clientY - e.startY) > MOVE_SLOP) window.clearTimeout(e.timer);\n        return;\n      }\n      setRaw(ev.clientX, ev.clientY);\n      e.wake();\n      return;\n    }\n    if (!e.open) return;\n    setRaw(ev.clientX, ev.clientY);\n    e.anchor = \"pointer\";\n    e.wake();\n  };\n\n  const onTouchEnd = (ev: ReactPointerEvent<HTMLDivElement>) => {\n    if (ev.pointerType !== \"touch\") return;\n    const e = eng.current;\n    window.clearTimeout(e.timer);\n    if (!e.lp) return;\n    e.lp = false;\n    window.clearTimeout(e.hideTimer);\n    e.hideTimer = window.setTimeout(settle, ev.type === \"pointercancel\" ? 0 : 900);\n  };\n\n  const onFocusRow = (i: number, ev: FocusEvent<HTMLAnchorElement>) => {\n    let visible = false;\n    try {\n      visible = ev.currentTarget.matches(\":focus-visible\");\n    } catch {\n      visible = true;\n    }\n    if (!visible) return;\n    anchorRef.current(i);\n    setActive(i);\n  };\n\n  const onBlurBox = (ev: FocusEvent<HTMLDivElement>) => {\n    const next = ev.relatedTarget as Node | null;\n    if (next && ev.currentTarget.contains(next)) return;\n    if (eng.current.anchor === \"row\") settle();\n  };\n\n  const onKeyDown = (ev: KeyboardEvent<HTMLDivElement>) => {\n    if (ev.key === \"Escape\" && active !== null) setActive(null);\n  };\n\n  return (\n    <div\n      ref={boxRef}\n      className={\"hlp relative \" + (className ?? \"\")}\n      data-open={active !== null ? \"true\" : \"false\"}\n      onPointerMove={onBoxMove}\n      onPointerLeave={(ev) => {\n        if (ev.pointerType !== \"touch\") settle();\n      }}\n      onPointerUp={onTouchEnd}\n      onPointerCancel={onTouchEnd}\n      onBlur={onBlurBox}\n      onKeyDown={onKeyDown}\n      onContextMenu={(ev) => {\n        if (eng.current.lp || eng.current.suppress) ev.preventDefault();\n      }}\n    >\n      <style>{CSS}</style>\n      <ul className=\"relative z-10 m-0 list-none p-0\">\n        {rows.map((row, i) => {\n          const descId = \"hlp\" + uid + \"d\" + i;\n          const on = active === i;\n          return (\n            <li key={row.id}>\n              <a\n                ref={(el) => {\n                  linkRefs.current[i] = el;\n                }}\n                href={safeHref(row.href, \"#\" + row.id)}\n                data-on={on ? \"true\" : \"false\"}\n                aria-describedby={row.description ? descId : undefined}\n                onPointerEnter={(ev) => onRowEnter(i, ev)}\n                onPointerDown={(ev) => onRowDown(i, ev)}\n                onFocus={(ev) => onFocusRow(i, ev)}\n                onClick={(ev) => {\n                  if (eng.current.suppress) {\n                    ev.preventDefault();\n                    eng.current.suppress = false;\n                  }\n                }}\n                className=\"hlp-row flex min-h-[56px] w-full items-baseline gap-3 !rounded-none py-3.5 text-inherit no-underline outline-none focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-current\"\n                style={{ color: \"inherit\", borderBottom: \"1px solid color-mix(in srgb, currentColor 15%, transparent)\" }}\n              >\n                <span className=\"w-6 shrink-0 font-mono text-[11px] tabular-nums tracking-[0.1em] opacity-55\">{row.id}</span>\n                <span className=\"hlp-title min-w-0 truncate text-xl font-semibold tracking-tight @xl:text-[26px]\">{row.title}</span>\n                {showMeta && (\n                  <span className=\"ml-auto max-w-[38%] shrink-0 truncate font-mono text-[10px] uppercase tracking-[0.14em] opacity-55\">{row.meta}</span>\n                )}\n                <svg aria-hidden viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" className={\"hlp-arrow shrink-0 self-center\" + (showMeta ? \"\" : \" ml-auto\")} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <path d=\"M4.5 11.5 11.5 4.5M5.5 4.5h6v6\" />\n                </svg>\n                {row.description ? (\n                  <span id={descId} className=\"sr-only\">\n                    {row.description}\n                  </span>\n                ) : null}\n              </a>\n            </li>\n          );\n        })}\n      </ul>\n\n      <div ref={shellRef} aria-hidden className=\"hlp-shell\" data-open={active !== null ? \"true\" : \"false\"} style={{ width: cw, height: ch }}>\n        <div ref={tiltRef} className=\"hlp-tilt\">\n          <div className=\"hlp-pop\">\n            <div\n              style={{\n                position: \"relative\",\n                width: cw,\n                height: ch,\n                borderRadius: 14 * scale,\n                overflow: \"hidden\",\n                background: TONES[tone].bg,\n                border: \"1px solid \" + TONES[tone].border,\n                boxShadow: TONES[tone].shadow,\n              }}\n            >\n              <div style={{ position: \"absolute\", left: 0, top: 0, width: CARD_W, height: CARD_H, transform: \"scale(\" + scale + \")\", transformOrigin: \"0 0\" }}>\n                {rows.map((row, i) => (\n                  <PeekPane key={row.id} row={row} index={i} tone={tone} on={active === i} />\n                ))}\n              </div>\n              <div\n                aria-hidden\n                style={{\n                  position: \"absolute\",\n                  inset: 0,\n                  background: \"radial-gradient(120% 90% at var(--lx) 0%, \" + TONES[tone].sheen + \", transparent 58%)\",\n                  mixBlendMode: tone === \"dark\" ? \"screen\" : \"normal\",\n                  opacity: tone === \"dark\" ? 0.55 : 0.5,\n                  pointerEvents: \"none\",\n                }}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/hover-link-peek.tsx"}],"meta":{"tier":"free","tags":["hover","preview","list","spring","links","touch","keyboard"],"href":"https://viberdy.dev/library/hover-link-peek","aiPrompt":"Create a React + Tailwind component named HoverLinkPeek (React only, no animation library): an index list of links where hovering a row floats a mini rendering of the destination beside the pointer. Single self-contained \"use client\" file.\n\nProps (all backward compatible):\n- rows?: { id, title, image?, meta?, href?, description?, domain?, accent? }[] (default: five invented demo rows). href defaults to \"#\" + id; hrefs and image sources are allow-listed (http/https/mailto, relative, data:image) and fall back otherwise.\n- tilt = 18 (max lean in degrees, 0 to 45), cardSize = 104 (scale knob: card width = 240 * cardSize / 104, range 64 to 200), showMeta = true (right meta column), tone = \"dark\" | \"light\" (card surface), restingIndex? (keeps the card resting anchored to that row when nothing is hovered), className.\n\nList: ul of anchors, min-height 56px, 14px vertical padding, hairline bottom border `color-mix(in srgb, currentColor 15%, transparent)`, colour inherited (works on light and dark pages). Each row: mono index (11px, 55% opacity), title (text-xl semibold, tracking-tight, 26px in a wide container), meta (mono 10px uppercase, 0.14em tracking, 55%), and a 14px arrow that fades in and slides from (-6, 6). Hovered/focused row: title shifts +8px (450ms cubic-bezier(.16,1,.3,1)); other rows dim to 36% opacity.\n\nCard (design size 240x176, scaled by cardSize/104): rounded 14px, 1px border, deep shadow. Dark: #0d0f13 surface, light: #fff. Inside, a 24px chrome bar (13px favicon monogram tile in the row accent, domain in 9.5px mono, a small arrow), an 84px art area, then title (12.5px semibold) and a two-line description (10px, 62% opacity). Art is the row's screenshot (object-cover) or generated from the accent: a dark accent gradient with one of three skeleton layouts (hero with orb and headline bars, three tiles, concentric rings) chosen by row index. All rows are stacked panes in the card and cross-fade (220ms) with an 8px rise, so image loads are warm. A soft sheen (radial gradient, screen blend on dark) slides with the lag.\n\nMotion: ONE rAF loop in a mount effect, sub-stepped at 120Hz, sleeping when settled. Position spring: stiffness 260, damping 26, mass 0.5. Lag = target minus spring position; nx,ny = clamp(lag/90, -1, 1); transform = perspective(720px) rotateX(-ny*tilt*0.5) rotateY(nx*tilt*0.5) rotate(nx*tilt). Position written to the DOM directly, never React state; only the active row index is state. Placement: pointer + 22px right, vertically centred; flip to the left of the pointer if it would leave the list; clamp horizontally. First show snaps to the target (no fly-in unless re-opened within 240ms). Pop: opacity 0, scale .86, translateY 8, blur 6px to rest in 340ms cubic-bezier(.16,1,.3,1); out in 140ms.\n\nInput: pointer events map coordinates in layout pixels (clientX - rect.left) * offsetWidth / rect.width. Mouse enter shows the card and follows; leaving the list hides it (or returns it to the resting row). Keyboard: onFocus with :focus-visible anchors the card to the row (centre-x of the row, or below the row when it does not fit), blur out of the list hides it, Escape dismisses. Touch: 380ms long-press (cancel after 10px of movement so scrolling works; touch-action pan-y, -webkit-touch-callout none, contextmenu suppressed during the press) shows the card above the finger and follows it, release hides it after 900ms and the following click is suppressed; a plain tap navigates.\n\nA11y: card aria-hidden, image alt empty; description is a visually hidden span referenced by aria-describedby; focus rings via important outline utilities using currentColor. prefers-reduced-motion: no spring, tilt, slide or title shift; the card snaps and fades in 120ms. Inject the static CSS with a plain <style> element, never interpolating props into it; accent colours are validated against /^#[0-9a-f]{3,8}$/i before use in inline styles.","license":"https://viberdy.dev/license"}}