{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "InfiniteMenu-TS-TW",
	"title": "InfiniteMenu",
	"description": "A draggable sphere of image tiles that snaps the nearest one forward and shows its title.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "InfiniteMenu/InfiniteMenu.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useRef, useState } from 'react';\n\nexport interface InfiniteMenuItem {\n  image: string;\n  title?: string;\n  description?: string;\n  link?: string;\n}\n\nexport interface InfiniteMenuProps {\n  items?: InfiniteMenuItem[];\n  count?: number;\n  tileSize?: number;\n  roundness?: number;\n  zoom?: number;\n  pullBack?: number;\n  stretch?: number;\n  inertia?: number;\n  autoplay?: number;\n  grayscale?: boolean;\n  dim?: number;\n  intro?: boolean;\n  showInfo?: boolean;\n  renderInfo?: (item: InfiniteMenuItem, index: number, moving: boolean) => React.ReactNode;\n  theme?: 'dark' | 'light';\n  accentColor?: string;\n  backgroundColor?: string;\n  onActiveChange?: (item: InfiniteMenuItem, index: number) => void;\n  onItemClick?: (item: InfiniteMenuItem, index: number) => void;\n  className?: string;\n  style?: React.CSSProperties;\n}\n\ntype Engine = { loadItems: (list: InfiniteMenuItem[]) => void; wake: () => void };\n\nconst VERTEX = `#version 300 es\nin vec2 aCorner;\nin vec4 aTile;\nuniform vec4 uOrientation;\nuniform mat4 uProjection;\nuniform float uCamera;\nuniform float uRadius;\nuniform float uTileRadius;\nuniform vec4 uSpin;\nuniform float uStretch;\nuniform float uShift;\nout vec2 vUv;\nout float vFront;\nout float vItem;\n\nvec3 rotate(vec4 q, vec3 v) {\n  vec3 t = 2.0 * cross(q.xyz, v);\n  return v + q.w * t + cross(q.xyz, t);\n}\n\nvoid main() {\n  vec3 center = normalize(rotate(uOrientation, aTile.xyz));\n  vec3 reference = abs(center.y) > 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);\n  vec3 tangent = normalize(cross(reference, center));\n  vec3 bitangent = cross(center, tangent);\n  float front = max(center.z, 0.0);\n  vec3 offset = (tangent * aCorner.x + bitangent * aCorner.y) * uTileRadius * mix(0.5, 1.0, front);\n  vec3 motion = cross(uSpin.xyz, center);\n  float sweep = length(motion);\n  if (sweep > 0.0001) {\n    vec3 direction = motion / sweep;\n    float amount = min(0.9, uSpin.w * uStretch * 0.12) * sweep;\n    offset += direction * dot(offset, direction) * amount;\n  }\n  vec3 world = normalize(center * uRadius + offset) * uRadius * (1.0 + 0.003 * center.z);\n  gl_Position = uProjection * vec4(world.xy, world.z - uCamera, 1.0);\n  gl_Position.y += uShift * gl_Position.w;\n  vUv = aCorner;\n  vFront = center.z;\n  vItem = aTile.w;\n}`;\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nuniform sampler2D uAtlas;\nuniform float uCells;\nuniform float uInset;\nuniform float uRoundness;\nuniform float uGrayscale;\nuniform float uDim;\nuniform float uReveal;\nuniform vec3 uPlaceholder;\nuniform vec3 uTone;\nin vec2 vUv;\nin float vFront;\nin float vItem;\nout vec4 outColor;\n\nvoid main() {\n  float corner = clamp(uRoundness, 0.0, 1.0);\n  vec2 q = abs(vUv) - (1.0 - corner);\n  float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - corner;\n  float aa = max(fwidth(d), 0.0001);\n  float mask = 1.0 - smoothstep(-aa, aa, d);\n  float alpha = mask * smoothstep(0.02, 0.4, vFront) * uReveal;\n  if (alpha < 0.002) discard;\n  vec2 cell = vec2(mod(vItem, uCells), floor(vItem / uCells));\n  vec2 local = vec2(vUv.x, -vUv.y) * 0.5 + 0.5;\n  vec4 sampled = texture(uAtlas, (cell + uInset + local * (1.0 - 2.0 * uInset)) / uCells);\n  vec3 color = mix(uPlaceholder, sampled.rgb, sampled.a);\n  float focus = smoothstep(0.93, 0.997, vFront);\n  float gray = dot(color, vec3(0.2126, 0.7152, 0.0722));\n  color = mix(color, vec3(gray), uGrayscale * (1.0 - focus));\n  color = mix(color, uTone, uDim * (1.0 - focus));\n  outColor = vec4(color * alpha, alpha);\n}`;\n\nconst ROOT =\n  'relative h-full w-full overflow-hidden outline-none @container text-[var(--infinite-menu-text)] [-webkit-tap-highlight-color:transparent] focus-visible:shadow-[inset_0_0_0_2px_var(--infinite-menu-text)]';\nconst CANVAS = 'absolute inset-0 block h-full w-full cursor-grab touch-none active:cursor-grabbing';\nconst LAYER = 'group pointer-events-none absolute inset-0';\nconst CUSTOM = 'pointer-events-none absolute inset-0';\nconst MOTION =\n  'transition-[opacity,translate,scale] duration-[450ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[moving=true]:opacity-0 group-data-[moving=true]:duration-[180ms]';\nconst TITLE =\n  MOTION +\n  ' absolute top-1/2 right-[calc(50%_+_var(--infinite-menu-tile,160px)_+_28px)] m-0 max-w-[calc(50%_-_var(--infinite-menu-tile,160px)_-_48px)] -translate-y-1/2 text-right text-[clamp(1.2rem,calc((50cqw_-_var(--infinite-menu-tile,160px))_*_0.2),3.4rem)] leading-[1.05] font-semibold tracking-[-0.03em] text-balance [text-shadow:var(--infinite-menu-shadow)] group-data-[moving=true]:translate-x-4 group-data-[layout=stacked]:top-auto group-data-[layout=stacked]:right-[88px] group-data-[layout=stacked]:bottom-[52px] group-data-[layout=stacked]:left-5 group-data-[layout=stacked]:max-w-none group-data-[layout=stacked]:translate-y-0 group-data-[layout=stacked]:text-left group-data-[layout=stacked]:text-[clamp(1.3rem,6cqw,1.8rem)] group-data-[layout=stacked]:group-data-[moving=true]:translate-x-0 group-data-[layout=stacked]:group-data-[moving=true]:translate-y-3';\nconst DESCRIPTION =\n  MOTION +\n  ' absolute top-1/2 left-[calc(50%_+_var(--infinite-menu-tile,160px)_+_28px)] m-0 max-w-[min(22ch,calc(50%_-_var(--infinite-menu-tile,160px)_-_48px))] -translate-y-1/2 text-[clamp(0.85rem,1.5cqw,1.05rem)] leading-[1.55] opacity-[0.72] [text-shadow:var(--infinite-menu-shadow)] group-data-[moving=true]:-translate-x-4 group-data-[layout=stacked]:top-auto group-data-[layout=stacked]:right-[88px] group-data-[layout=stacked]:bottom-[22px] group-data-[layout=stacked]:left-5 group-data-[layout=stacked]:max-w-[36ch] group-data-[layout=stacked]:translate-y-0 group-data-[layout=stacked]:truncate group-data-[layout=stacked]:group-data-[moving=true]:translate-x-0 group-data-[layout=stacked]:group-data-[moving=true]:translate-y-3';\nconst ACTION =\n  'absolute bottom-[7%] left-1/2 grid h-[52px] w-[52px] -translate-x-1/2 cursor-pointer place-items-center rounded-full border-0 bg-[var(--infinite-menu-accent)] p-0 text-[var(--infinite-menu-on-accent)] pointer-events-auto transition-[opacity,translate,scale] duration-[450ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:scale-[1.06] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[var(--infinite-menu-accent)] group-data-[moving=true]:pointer-events-none group-data-[moving=true]:translate-y-4 group-data-[moving=true]:scale-[0.6] group-data-[moving=true]:opacity-0 group-data-[moving=true]:duration-[180ms] group-data-[layout=stacked]:right-5 group-data-[layout=stacked]:bottom-[22px] group-data-[layout=stacked]:left-auto group-data-[layout=stacked]:translate-x-0 group-data-[layout=stacked]:group-data-[moving=true]:translate-y-3';\n\nconst GRID = 12;\nconst RADIUS = 2;\nconst FOV = (30 * Math.PI) / 180;\nconst FRONT = [0, 0, 1];\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];\nconst cross = (a: number[], b: number[]) => [\n  a[1] * b[2] - a[2] * b[1],\n  a[2] * b[0] - a[0] * b[2],\n  a[0] * b[1] - a[1] * b[0]\n];\nconst normalize = (v: number[]) => {\n  const length = Math.hypot(v[0], v[1], v[2]) || 1;\n  return [v[0] / length, v[1] / length, v[2] / length];\n};\nconst quatMultiply = (a: number[], b: number[]) => [\n  a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],\n  a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],\n  a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],\n  a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]\n];\nconst quatAxisAngle = (axis: number[], angle: number) => {\n  const s = Math.sin(angle / 2);\n  return [axis[0] * s, axis[1] * s, axis[2] * s, Math.cos(angle / 2)];\n};\nconst quatNormalize = (q: number[]) => {\n  const length = Math.hypot(q[0], q[1], q[2], q[3]) || 1;\n  return [q[0] / length, q[1] / length, q[2] / length, q[3] / length];\n};\nconst rotate = (q: number[], v: number[]) => {\n  const t = cross(q, v).map(value => value * 2);\n  const u = cross(q, t);\n  return [v[0] + q[3] * t[0] + u[0], v[1] + q[3] * t[1] + u[1], v[2] + q[3] * t[2] + u[2]];\n};\n\nconst sphere = (count: number) => {\n  const golden = Math.PI * (3 - Math.sqrt(5));\n  return Array.from({ length: count }, (_, i) => {\n    const y = 1 - (2 * (i + 0.5)) / count;\n    const ring = Math.sqrt(1 - y * y);\n    return [Math.cos(golden * i) * ring, y, Math.sin(golden * i) * ring];\n  });\n};\n\nconst luminance = (hex: string) => {\n  const value = hex.replace('#', '');\n  const full = value.length === 3 ? value.replace(/./g, c => c + c) : value.slice(0, 6);\n  const n = parseInt(full, 16);\n  if (Number.isNaN(n)) return 0;\n  return (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;\n};\n\nconst compile = (gl: WebGL2RenderingContext, type: number, source: string) => {\n  const shader = gl.createShader(type);\n  if (!shader) return null;\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;\n  gl.deleteShader(shader);\n  return null;\n};\n\nconst ArrowIcon = () => (\n  <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n    <path\n      d=\"M7 17L17 7M17 7H9M17 7V15\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst InfiniteMenu = ({\n  items = [],\n  count = 160,\n  tileSize = 0.63,\n  roundness = 0.2,\n  zoom = 1.65,\n  pullBack = 1,\n  stretch = 0.6,\n  inertia = 0.6,\n  autoplay = 3,\n  grayscale = true,\n  dim = 0.5,\n  intro = true,\n  showInfo = true,\n  renderInfo,\n  theme = 'dark',\n  accentColor,\n  backgroundColor,\n  onActiveChange,\n  onItemClick,\n  className = '',\n  style\n}: InfiniteMenuProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const engineRef = useRef<Engine | null>(null);\n  const [active, setActive] = useState(0);\n  const [moving, setMoving] = useState(true);\n  const [layout, setLayout] = useState('side');\n  const settings = {\n    count: clamp(Math.round(count), 6, 400),\n    tileSize: clamp(tileSize, 0.2, 1.2),\n    roundness: clamp(roundness, 0, 1),\n    zoom: clamp(zoom, 0.2, 4),\n    pullBack: clamp(pullBack, 0, 3),\n    stretch: clamp(stretch, 0, 3),\n    inertia: clamp(inertia, 0, 1),\n    autoplay: Math.max(0, autoplay),\n    grayscale,\n    dim: clamp(dim, 0, 1),\n    intro,\n    theme: theme === 'light' ? 'light' : 'dark'\n  };\n  const settingsRef = useRef(settings);\n  settingsRef.current = settings;\n  const itemsRef = useRef(items);\n  itemsRef.current = items;\n  const callbacksRef = useRef({ onActiveChange, onItemClick });\n  callbacksRef.current = { onActiveChange, onItemClick };\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const gl = canvas?.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: true });\n    if (!root || !canvas || !gl) return undefined;\n\n    const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);\n    const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);\n    const program = gl.createProgram();\n    if (!vertex || !fragment || !program) return undefined;\n    gl.attachShader(program, vertex);\n    gl.attachShader(program, fragment);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return undefined;\n    gl.useProgram(program);\n\n    const uniforms: Record<string, WebGLUniformLocation | null> = {};\n    for (const name of [\n      'uOrientation',\n      'uProjection',\n      'uCamera',\n      'uRadius',\n      'uTileRadius',\n      'uSpin',\n      'uStretch',\n      'uShift',\n      'uAtlas',\n      'uCells',\n      'uInset',\n      'uRoundness',\n      'uGrayscale',\n      'uDim',\n      'uReveal',\n      'uPlaceholder',\n      'uTone'\n    ]) {\n      uniforms[name] = gl.getUniformLocation(program, name);\n    }\n\n    const corners: number[] = [];\n    const indices: number[] = [];\n    for (let y = 0; y <= GRID; y++) {\n      for (let x = 0; x <= GRID; x++) corners.push((x / GRID) * 2 - 1, (y / GRID) * 2 - 1);\n    }\n    for (let y = 0; y < GRID; y++) {\n      for (let x = 0; x < GRID; x++) {\n        const i = y * (GRID + 1) + x;\n        indices.push(i, i + 1, i + GRID + 1, i + 1, i + GRID + 2, i + GRID + 1);\n      }\n    }\n\n    const vao = gl.createVertexArray();\n    gl.bindVertexArray(vao);\n    const cornerBuffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer);\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(corners), gl.STATIC_DRAW);\n    const cornerLocation = gl.getAttribLocation(program, 'aCorner');\n    gl.enableVertexAttribArray(cornerLocation);\n    gl.vertexAttribPointer(cornerLocation, 2, gl.FLOAT, false, 0, 0);\n    const indexBuffer = gl.createBuffer();\n    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);\n    gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);\n    const tileBuffer = gl.createBuffer();\n    gl.bindBuffer(gl.ARRAY_BUFFER, tileBuffer);\n    const tileLocation = gl.getAttribLocation(program, 'aTile');\n    gl.enableVertexAttribArray(tileLocation);\n    gl.vertexAttribPointer(tileLocation, 4, gl.FLOAT, false, 0, 0);\n    gl.vertexAttribDivisor(tileLocation, 1);\n    gl.bindVertexArray(null);\n\n    const atlasTexture = gl.createTexture();\n    gl.activeTexture(gl.TEXTURE0);\n    gl.bindTexture(gl.TEXTURE_2D, atlasTexture);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0]));\n    gl.uniform1i(uniforms.uAtlas, 0);\n\n    const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n    const atlas = { canvas: document.createElement('canvas'), cells: 1, cell: 512, request: 0, dirty: false };\n    const drag = { active: false, id: -1, x: 0, y: 0, dx: 0, dy: 0, moved: 0, start: 0 };\n    const state = {\n      width: 1,\n      height: 1,\n      tiles: [] as number[][],\n      assign: [] as number[],\n      count: 0,\n      orientation: quatNormalize([0.08, -0.3, 0, 1]),\n      axis: [0, 1, 0],\n      speed: settingsRef.current.intro && !reduce ? 3.2 : 0,\n      camera: 0,\n      target: -1,\n      front: 0,\n      shown: -1,\n      moving: true,\n      reveal: 0,\n      hovered: false,\n      focused: false,\n      idle: 0,\n      settle: 0,\n      tilePx: 0,\n      layout: 'side',\n      shift: 0,\n      started: false\n    };\n    let raf = 0;\n    let timer = 0;\n    let last = 0;\n    let visible = true;\n    let alive = true;\n\n    const layout = () => {\n      const s = settingsRef.current;\n      if (s.count === state.count) return;\n      state.count = s.count;\n      state.tiles = sphere(s.count);\n      const items = Math.max(1, itemsRef.current.length);\n      const usage = new Array(items).fill(0);\n      state.assign = state.tiles.map(() => -1);\n      state.tiles.forEach((tile, i) => {\n        const near = state.tiles\n          .map((other, j) => [j === i ? -2 : dot(tile, other), j])\n          .sort((a, b) => b[0] - a[0])\n          .slice(0, 6)\n          .map(([, j]) => state.assign[j]);\n        let pick = -1;\n        for (let k = 0; k < items; k++) {\n          const candidate = (i + k) % items;\n          if (near.includes(candidate)) continue;\n          if (pick < 0 || usage[candidate] < usage[pick]) pick = candidate;\n        }\n        if (pick < 0) pick = i % items;\n        state.assign[i] = pick;\n        usage[pick] += 1;\n      });\n      const data = new Float32Array(s.count * 4);\n      state.tiles.forEach((tile, i) => data.set([tile[0], tile[1], tile[2], state.assign[i]], i * 4));\n      gl.bindBuffer(gl.ARRAY_BUFFER, tileBuffer);\n      gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);\n      let first = -1;\n      let firstZ = -2;\n      state.tiles.forEach((tile, i) => {\n        const z = rotate(state.orientation, tile)[2];\n        if (state.assign[i] === 0 && z > firstZ) {\n          firstZ = z;\n          first = i;\n        }\n      });\n      state.target = first;\n    };\n\n    const uploadAtlas = () => {\n      atlas.dirty = false;\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, atlasTexture);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.canvas);\n      gl.generateMipmap(gl.TEXTURE_2D);\n    };\n\n    const loadItems = (list: InfiniteMenuItem[]) => {\n      const request = ++atlas.request;\n      const total = Math.max(1, list.length);\n      atlas.cells = Math.ceil(Math.sqrt(total));\n      atlas.cell = clamp(Math.floor(4096 / atlas.cells), 64, 512);\n      atlas.canvas.width = atlas.cells * atlas.cell;\n      atlas.canvas.height = atlas.cells * atlas.cell;\n      const ctx = atlas.canvas.getContext('2d');\n      ctx?.clearRect(0, 0, atlas.canvas.width, atlas.canvas.height);\n      uploadAtlas();\n      state.count = 0;\n      layout();\n      let pending = list.length;\n      if (!pending) state.started = true;\n      list.forEach((item, index) => {\n        const image = new Image();\n        image.crossOrigin = 'anonymous';\n        image.decoding = 'async';\n        const done = () => {\n          pending -= 1;\n          if (pending <= 0) state.started = true;\n          wake();\n        };\n        image.onload = () => {\n          if (!alive || request !== atlas.request || !ctx) return;\n          const size = atlas.cell;\n          const scale = Math.max(size / image.naturalWidth, size / image.naturalHeight);\n          const width = image.naturalWidth * scale;\n          const height = image.naturalHeight * scale;\n          const x = (index % atlas.cells) * size;\n          const y = Math.floor(index / atlas.cells) * size;\n          ctx.save();\n          ctx.beginPath();\n          ctx.rect(x, y, size, size);\n          ctx.clip();\n          ctx.drawImage(image, x + (size - width) / 2, y + (size - height) / 2, width, height);\n          ctx.restore();\n          atlas.dirty = true;\n          done();\n        };\n        image.onerror = () => {\n          if (!alive || request !== atlas.request) return;\n          done();\n        };\n        image.src = item?.image ?? '';\n      });\n    };\n\n    const tileRadius = () =>\n      0.5 * RADIUS * Math.sqrt((4 * Math.PI) / Math.max(1, state.count)) * settingsRef.current.tileSize;\n\n    const restDistance = () => {\n      const s = settingsRef.current;\n      const aspect = state.width / state.height;\n      const span = Math.tan(FOV / 2) * Math.min(1, aspect);\n      return RADIUS + tileRadius() / (0.46 * s.zoom * span);\n    };\n\n    const measure = () => {\n      const rest = restDistance();\n      const radius = (tileRadius() / ((rest - RADIUS) * Math.tan(FOV / 2))) * (state.height / 2);\n      const rounded = Math.round(radius);\n      if (rounded !== state.tilePx) {\n        state.tilePx = rounded;\n        root.style.setProperty('--infinite-menu-tile', `${rounded}px`);\n      }\n      const mode = state.width / 2 - radius < 120 ? 'stacked' : 'side';\n      if (mode !== state.layout) {\n        state.layout = mode;\n        setLayout(mode);\n      }\n    };\n\n    const nearest = () => {\n      let best = 0;\n      let bestZ = -2;\n      for (let i = 0; i < state.tiles.length; i++) {\n        const z = rotate(state.orientation, state.tiles[i])[2];\n        if (z > bestZ) {\n          bestZ = z;\n          best = i;\n        }\n      }\n      return best;\n    };\n\n    const neighbor = (from: number, direction: number[]) => {\n      const origin = rotate(state.orientation, state.tiles[from]);\n      let best = -1;\n      let bestScore = Infinity;\n      for (let i = 0; i < state.tiles.length; i++) {\n        if (i === from) continue;\n        const c = rotate(state.orientation, state.tiles[i]);\n        if (c[2] < 0) continue;\n        const dx = c[0] - origin[0];\n        const dy = c[1] - origin[1];\n        const length = Math.hypot(dx, dy);\n        if (length < 0.0001) continue;\n        const align = (dx * direction[0] + dy * direction[1]) / length;\n        if (align < 0.55) continue;\n        const score = length * (2.2 - align);\n        if (score < bestScore) {\n          bestScore = score;\n          best = i;\n        }\n      }\n      return best;\n    };\n\n    const goTo = (index: number) => {\n      if (index < 0) return;\n      state.target = index;\n      state.speed = 0;\n      state.idle = 0;\n      wake();\n    };\n\n    const pick = (clientX: number, clientY: number) => {\n      const rect = canvas.getBoundingClientRect();\n      const aspect = rect.width / rect.height;\n      const span = Math.tan(FOV / 2);\n      const x = (((clientX - rect.left) / rect.width) * 2 - 1) * aspect;\n      const y = 1 - ((clientY - rect.top) / rect.height) * 2 - state.shift;\n      const radius = tileRadius();\n      let best = -1;\n      let bestDistance = Infinity;\n      for (let i = 0; i < state.tiles.length; i++) {\n        const c = rotate(state.orientation, state.tiles[i]);\n        if (c[2] < 0.2) continue;\n        const depth = state.camera - c[2] * RADIUS;\n        const sx = (c[0] * RADIUS) / (depth * span);\n        const sy = (c[1] * RADIUS) / (depth * span);\n        const reach = (radius * (0.5 + 0.5 * c[2])) / (depth * span);\n        const distance = Math.hypot(sx - x, sy - y);\n        if (distance < reach && distance < bestDistance) {\n          bestDistance = distance;\n          best = i;\n        }\n      }\n      return best;\n    };\n\n    const activate = (index: number) => {\n      const list = itemsRef.current;\n      if (!list.length) return;\n      const itemIndex = (state.assign[index] ?? 0) % list.length;\n      const item = list[itemIndex];\n      const handler = callbacksRef.current.onItemClick;\n      if (handler) handler(item, itemIndex);\n      else if (item?.link) window.open(item.link, item.link.startsWith('http') ? '_blank' : '_self', 'noopener');\n    };\n\n    const report = () => {\n      const list = itemsRef.current;\n      const index = list.length ? (state.assign[state.front] ?? 0) % list.length : 0;\n      if (!state.moving && index !== state.shown) {\n        state.shown = index;\n        setActive(index);\n        callbacksRef.current.onActiveChange?.(list[index], index);\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!alive || !visible) return;\n      const s = settingsRef.current;\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      layout();\n      if (atlas.dirty) uploadAtlas();\n      if (state.started) state.reveal = Math.min(1, state.reveal + dt / (reduce ? 0.01 : 0.7));\n      const minSide = Math.min(state.width, state.height);\n\n      if (drag.active) {\n        if (drag.dx || drag.dy) {\n          const length = Math.hypot(drag.dx, drag.dy);\n          const angle = (length / minSide) * 2.6;\n          const axis = normalize([drag.dy, drag.dx, 0]);\n          state.orientation = quatNormalize(quatMultiply(quatAxisAngle(axis, angle), state.orientation));\n          const blend = 1 - Math.exp(-dt * 30);\n          state.axis = normalize(state.axis.map((value, i) => value + (axis[i] - value) * blend));\n          state.speed += (angle / dt - state.speed) * blend;\n          drag.dx = 0;\n          drag.dy = 0;\n        } else {\n          state.speed *= Math.exp(-dt * 14);\n        }\n      } else {\n        if (state.speed > 0.0005) {\n          state.orientation = quatNormalize(\n            quatMultiply(quatAxisAngle(state.axis, state.speed * dt), state.orientation)\n          );\n          state.speed *= Math.exp(-dt * (6 - 5 * s.inertia));\n        } else {\n          state.speed = 0;\n        }\n        const goal = state.target >= 0 ? state.target : nearest();\n        const current = rotate(state.orientation, state.tiles[goal]);\n        const angle = Math.acos(clamp(dot(current, FRONT), -1, 1));\n        const pull = clamp(1 - state.speed / 1.4, 0, 1);\n        if (angle > 0.0002 && pull > 0) {\n          const axis = cross(current, FRONT);\n          const step = angle * (1 - Math.exp(-dt * (state.target >= 0 ? 6 : 8))) * pull;\n          if (Math.hypot(axis[0], axis[1], axis[2]) > 1e-6) {\n            state.orientation = quatNormalize(quatMultiply(quatAxisAngle(normalize(axis), step), state.orientation));\n          }\n        } else if (state.target >= 0 && state.speed === 0) {\n          state.target = -1;\n        }\n        state.front = goal;\n      }\n\n      const settledAngle = Math.acos(clamp(rotate(state.orientation, state.tiles[state.front])[2], -1, 1));\n      const moving = drag.active || state.speed > 0.08 || settledAngle > 0.01;\n      if (moving !== state.moving) {\n        state.moving = moving;\n        setMoving(moving);\n      }\n      state.settle += ((moving ? 0 : 1) - state.settle) * (1 - Math.exp(-dt * (moving ? 10 : 3)));\n      if (!moving) report();\n\n      const rest = restDistance();\n      const pullAmount = clamp(drag.active ? 1 : state.speed / 2.2, 0, 1);\n      const target = rest + s.pullBack * pullAmount * RADIUS * 1.25 + (1 - state.reveal) * RADIUS * 1.5;\n      if (!state.camera) state.camera = target + RADIUS;\n      state.camera += (target - state.camera) * (1 - Math.exp(-dt * 4.5));\n\n      if (s.autoplay > 0 && !moving && !state.hovered && !state.focused && !reduce) {\n        state.idle += dt;\n        if (state.idle >= s.autoplay) {\n          state.idle = 0;\n          goTo(neighbor(state.front, [1, 0]));\n        }\n      } else {\n        state.idle = 0;\n      }\n\n      measure();\n      const shiftTarget = state.layout === 'stacked' ? Math.min(0.3, 72 / state.height) : 0;\n      state.shift += (shiftTarget - state.shift) * (1 - Math.exp(-dt * 6));\n      render();\n\n      const busy =\n        moving ||\n        Math.abs(target - state.camera) > 0.001 ||\n        Math.abs((state.layout === 'stacked' ? Math.min(0.3, 72 / state.height) : 0) - state.shift) > 0.0005 ||\n        Math.abs((moving ? 0 : 1) - state.settle) > 0.002 ||\n        state.reveal < 1 ||\n        atlas.dirty ||\n        !state.started;\n      if (busy) raf = requestAnimationFrame(frame);\n      else {\n        last = 0;\n        if (s.autoplay > 0 && !state.hovered && !state.focused && !reduce) {\n          clearTimeout(timer);\n          timer = window.setTimeout(wake, Math.max(16, (s.autoplay - state.idle) * 1000));\n        }\n      }\n    };\n\n    const render = () => {\n      const s = settingsRef.current;\n      const aspect = state.width / state.height;\n      const f = 1 / Math.tan(FOV / 2);\n      const near = 0.05;\n      const far = 50;\n      const projection = new Float32Array([\n        f / aspect,\n        0,\n        0,\n        0,\n        0,\n        f,\n        0,\n        0,\n        0,\n        0,\n        (far + near) / (near - far),\n        -1,\n        0,\n        0,\n        (2 * far * near) / (near - far),\n        0\n      ]);\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      gl.clearColor(0, 0, 0, 0);\n      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);\n      gl.enable(gl.DEPTH_TEST);\n      gl.enable(gl.BLEND);\n      gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);\n      gl.useProgram(program);\n      const [qx, qy, qz, qw] = state.orientation;\n      gl.uniform4f(uniforms.uOrientation, qx, qy, qz, qw);\n      gl.uniformMatrix4fv(uniforms.uProjection, false, projection);\n      gl.uniform1f(uniforms.uCamera, state.camera);\n      gl.uniform1f(uniforms.uRadius, RADIUS);\n      gl.uniform1f(uniforms.uTileRadius, tileRadius());\n      gl.uniform4f(uniforms.uSpin, state.axis[0], state.axis[1], state.axis[2], reduce ? 0 : state.speed);\n      gl.uniform1f(uniforms.uStretch, s.stretch);\n      gl.uniform1f(uniforms.uShift, state.shift);\n      gl.uniform1f(uniforms.uCells, atlas.cells);\n      gl.uniform1f(uniforms.uInset, 2 / atlas.cell);\n      gl.uniform1f(uniforms.uRoundness, s.roundness);\n      gl.uniform1f(uniforms.uGrayscale, s.grayscale ? 1 : 0);\n      gl.uniform1f(uniforms.uDim, s.dim * (0.45 + 0.55 * state.settle));\n      gl.uniform1f(uniforms.uReveal, state.reveal);\n      if (s.theme === 'light') {\n        gl.uniform3f(uniforms.uPlaceholder, 0.9, 0.9, 0.91);\n        gl.uniform3f(uniforms.uTone, 1, 1, 1);\n      } else {\n        gl.uniform3f(uniforms.uPlaceholder, 0.13, 0.12, 0.15);\n        gl.uniform3f(uniforms.uTone, 0.04, 0.035, 0.05);\n      }\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, atlasTexture);\n      gl.bindVertexArray(vao);\n      gl.drawElementsInstanced(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0, state.tiles.length);\n      gl.bindVertexArray(null);\n    };\n\n    const wake = () => {\n      clearTimeout(timer);\n      if (raf || !alive || !visible) return;\n      raf = requestAnimationFrame(frame);\n    };\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.pointerType === 'mouse' && event.button !== 0) return;\n      canvas.setPointerCapture?.(event.pointerId);\n      drag.active = true;\n      drag.id = event.pointerId;\n      drag.x = event.clientX;\n      drag.y = event.clientY;\n      drag.dx = 0;\n      drag.dy = 0;\n      drag.moved = 0;\n      drag.start = performance.now();\n      state.target = -1;\n      wake();\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (!drag.active || event.pointerId !== drag.id) return;\n      const dx = event.clientX - drag.x;\n      const dy = event.clientY - drag.y;\n      drag.x = event.clientX;\n      drag.y = event.clientY;\n      drag.dx += dx;\n      drag.dy += dy;\n      drag.moved += Math.hypot(dx, dy);\n      wake();\n    };\n\n    const onPointerUp = (event: PointerEvent) => {\n      if (!drag.active || event.pointerId !== drag.id) return;\n      drag.active = false;\n      canvas.releasePointerCapture?.(event.pointerId);\n      if (drag.moved < 6 && performance.now() - drag.start < 450) {\n        const index = pick(event.clientX, event.clientY);\n        if (index >= 0 && index === state.front) activate(index);\n        else if (index >= 0) goTo(index);\n      }\n      wake();\n    };\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      const directions: Record<string, number[]> = {\n        ArrowRight: [1, 0],\n        ArrowLeft: [-1, 0],\n        ArrowUp: [0, 1],\n        ArrowDown: [0, -1]\n      };\n      if (directions[event.key]) {\n        event.preventDefault();\n        goTo(neighbor(state.target >= 0 ? state.target : state.front, directions[event.key]));\n      } else if (event.key === 'Enter' && event.target === root) {\n        event.preventDefault();\n        activate(state.front);\n      }\n    };\n\n    const onEnter = () => {\n      state.hovered = true;\n    };\n    const onLeave = () => {\n      state.hovered = false;\n      wake();\n    };\n    const onFocusIn = () => {\n      state.focused = true;\n    };\n    const onFocusOut = (event: FocusEvent) => {\n      if (root.contains(event.relatedTarget as Node | null)) return;\n      state.focused = false;\n      wake();\n    };\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      state.width = Math.max(1, root.clientWidth);\n      state.height = Math.max(1, root.clientHeight);\n      canvas.width = Math.round(state.width * dpr);\n      canvas.height = Math.round(state.height * dpr);\n      render();\n      wake();\n    };\n\n    engineRef.current = { loadItems, wake };\n\n    canvas.addEventListener('pointerdown', onPointerDown);\n    canvas.addEventListener('pointermove', onPointerMove);\n    canvas.addEventListener('pointerup', onPointerUp);\n    canvas.addEventListener('pointercancel', onPointerUp);\n    root.addEventListener('keydown', onKeyDown);\n    root.addEventListener('pointerenter', onEnter);\n    root.addEventListener('pointerleave', onLeave);\n    root.addEventListener('focusin', onFocusIn);\n    root.addEventListener('focusout', onFocusOut);\n    const resizeObserver = new ResizeObserver(resize);\n    resizeObserver.observe(root);\n    const visibility = new IntersectionObserver(entries => {\n      visible = entries.some(entry => entry.isIntersecting);\n      if (visible) {\n        last = 0;\n        wake();\n      }\n    });\n    visibility.observe(root);\n    layout();\n    resize();\n\n    return () => {\n      alive = false;\n      cancelAnimationFrame(raf);\n      clearTimeout(timer);\n      engineRef.current = null;\n      canvas.removeEventListener('pointerdown', onPointerDown);\n      canvas.removeEventListener('pointermove', onPointerMove);\n      canvas.removeEventListener('pointerup', onPointerUp);\n      canvas.removeEventListener('pointercancel', onPointerUp);\n      root.removeEventListener('keydown', onKeyDown);\n      root.removeEventListener('pointerenter', onEnter);\n      root.removeEventListener('pointerleave', onLeave);\n      root.removeEventListener('focusin', onFocusIn);\n      root.removeEventListener('focusout', onFocusOut);\n      resizeObserver.disconnect();\n      visibility.disconnect();\n      gl.deleteTexture(atlasTexture);\n      gl.deleteBuffer(cornerBuffer);\n      gl.deleteBuffer(indexBuffer);\n      gl.deleteBuffer(tileBuffer);\n      gl.deleteVertexArray(vao);\n      gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteShader(fragment);\n    };\n  }, []);\n\n  useEffect(() => {\n    engineRef.current?.loadItems(items);\n  }, [items]);\n\n  useEffect(() => {\n    engineRef.current?.wake();\n  });\n\n  const current = items.length ? items[active % items.length] : null;\n  const ink = settings.theme === 'light' ? '#120f17' : '#ffffff';\n  const accent = accentColor || ink;\n  const onAccent = luminance(accent) > 0.5 ? '#120f17' : '#ffffff';\n  const hasAction = Boolean(current?.link || onItemClick);\n\n  const handleAction = (event: React.MouseEvent) => {\n    if (!current) return;\n    if (onItemClick) {\n      onItemClick(current, active % items.length);\n      if (!current.link) event.preventDefault();\n    }\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`${ROOT}${className ? ` ${className}` : ''}`}\n      tabIndex={0}\n      role=\"group\"\n      aria-roledescription=\"carousel\"\n      aria-label={current?.title || 'Infinite menu'}\n      style={\n        {\n          backgroundColor,\n          '--infinite-menu-text': ink,\n          '--infinite-menu-accent': accent,\n          '--infinite-menu-on-accent': onAccent,\n          '--infinite-menu-shadow':\n            settings.theme === 'light' ? '0 2px 18px rgba(255, 255, 255, 0.7)' : '0 2px 18px rgba(0, 0, 0, 0.45)',\n          ...style\n        } as React.CSSProperties\n      }\n    >\n      <canvas ref={canvasRef} className={CANVAS} />\n      {showInfo &&\n        current &&\n        (renderInfo ? (\n          <div className={CUSTOM}>{renderInfo(current, active % items.length, moving)}</div>\n        ) : (\n          <div className={LAYER} data-moving={moving} data-layout={layout}>\n            {current.title && <h2 className={TITLE}>{current.title}</h2>}\n            {current.description && <p className={DESCRIPTION}>{current.description}</p>}\n            {hasAction &&\n              (current.link ? (\n                <a\n                  className={ACTION}\n                  href={current.link}\n                  target={current.link.startsWith('http') ? '_blank' : undefined}\n                  rel={current.link.startsWith('http') ? 'noreferrer' : undefined}\n                  aria-label={current.title ? `Open ${current.title}` : 'Open'}\n                  onClick={handleAction}\n                >\n                  <ArrowIcon />\n                </a>\n              ) : (\n                <button\n                  type=\"button\"\n                  className={ACTION}\n                  aria-label={current.title ? `Open ${current.title}` : 'Open'}\n                  onClick={handleAction}\n                >\n                  <ArrowIcon />\n                </button>\n              ))}\n          </div>\n        ))}\n    </div>\n  );\n};\n\nexport default InfiniteMenu;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}