{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-drape",
  "title": "Dropdown Drape",
  "description": "Dropdown whose panel is a live verlet cloth pinned to the trigger: it falls and drapes like an awning, labels ride the weave, the cursor billows the fabric, and close yanks it back up.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/dropdown-drape/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\n\n// DrapeMenu — a dropdown whose panel is a live verlet cloth pinned along the\n// trigger's bottom edge. On open the cloth falls under gravity and drapes like\n// an awning; the menu items are real DOM nodes (role=menuitem) riding\n// designated vertex rows, so they sway and settle with the fabric. Cursor\n// proximity billows the weave upward; close yanks every vertex back to the\n// trigger on a critically damped spring and unmounts at sub-pixel rest.\n// Canvas 2D underneath draws per-quad fold shading from token-derived grays.\n// Direct-DOM rAF loop — no React state on the hot path, sleeps when settled.\n\nexport type DrapeMenuItem = {\n  id: string;\n  label: string;\n  /** rendered right-aligned in font-mono, e.g. \"⌘N\" */\n  shortcut?: string;\n  icon?: ReactNode;\n  /** draws a separator row above this item */\n  separatorBefore?: boolean;\n};\n\nconst COLS = 10;\nconst ROWS = 14;\nconst GRAVITY = 2400; // px/s²\nconst VERLET_DAMP = 0.99; // velocity retention per step\nconst RELAX_ITERS = 3; // structural-constraint relaxation passes per frame\nconst BILLOW_F = 900; // px/s² peak upward force at zero distance\nconst BILLOW_SIGMA = 60; // gaussian falloff radius, px\nconst BILLOW_RANGE2 = 150 * 150; // px² — beyond this the force is skipped\nconst SPRING_K = 120; // s⁻² — close retract stiffness\nconst SPRING_C = 2 * Math.sqrt(SPRING_K); // ζ = 1.0, critically damped\nconst SLEEP_EPS = 0.02; // px/frame — loop sleeps below this displacement\nconst DONE_EPS = 0.5; // px — retract unmounts below this displacement\nconst ARM_V = 24; // px/s — row must first exceed this to arm its entrance\nconst REVEAL_V = 8; // px/s — armed row reveals its label below this\nconst ITEM_H = 38;\nconst SEP_H = 13;\nconst PAD_TOP = 10;\nconst PAD_BOT = 12;\nconst INSET = 10; // px between cloth edge and item edge\nconst MARGIN_X = 80; // canvas bleed for sway/billow\nconst MARGIN_TOP = 16;\nconst MARGIN_BOT = 64;\nconst MAX_TILT = (6 * Math.PI) / 180; // item rotation clamp, ±6°\nconst DT_MAX = 0.032; // s — clamp tab-switch jumps\n\ntype Entry =\n  | { kind: \"item\"; item: DrapeMenuItem; y0: number; h: number }\n  | { kind: \"sep\"; y0: number; h: number };\n\ntype Sim = {\n  posX: Float32Array;\n  posY: Float32Array;\n  prevX: Float32Array;\n  prevY: Float32Array;\n  velX: Float32Array;\n  velY: Float32Array;\n};\n\ntype Geom = { tw: number; clothW: number; dx0: number; dy0: number; dpr: number };\n\ntype RGB = [number, number, number];\n\nfunction Glyph({ d }: { d: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"h-4 w-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d={d} />\n    </svg>\n  );\n}\n\nconst DEFAULT_ITEMS: DrapeMenuItem[] = [\n  { id: \"new-project\", label: \"New project\", shortcut: \"⌘N\", icon: <Glyph d=\"M8 3v10M3 8h10\" /> },\n  {\n    id: \"invite-member\",\n    label: \"Invite member\",\n    shortcut: \"⌘I\",\n    icon: <Glyph d=\"M10 13.5v-1a3 3 0 0 0-3-3H4.5a3 3 0 0 0-3 3v1M5.75 7a2.25 2.25 0 1 0 0-4.5 2.25 2.25 0 0 0 0 4.5M12.5 5v4M14.5 7h-4\" />,\n  },\n  {\n    id: \"switch-workspace\",\n    label: \"Switch workspace\",\n    shortcut: \"⌘K\",\n    icon: <Glyph d=\"M5 3 2 6l3 3M2 6h9M11 13l3-3-3-3M14 10H5\" />,\n  },\n  {\n    id: \"settings\",\n    label: \"Workspace settings\",\n    shortcut: \"⌘,\",\n    separatorBefore: true,\n    icon: <Glyph d=\"M2 5h12M10 3v4M2 11h12M6 9v4\" />,\n  },\n  {\n    id: \"usage\",\n    label: \"Usage & billing\",\n    icon: <Glyph d=\"M2 13.5h12M4 13.5v-3M8 13.5v-8M12 13.5v-5.5\" />,\n  },\n  {\n    id: \"sign-out\",\n    label: \"Sign out\",\n    shortcut: \"⇧⌘Q\",\n    icon: <Glyph d=\"M6.5 2H3v12h3.5M10.5 11l3-3-3-3M13.5 8h-7\" />,\n  },\n];\n\nexport function DrapeMenu({\n  label = \"Workspace\",\n  items = DEFAULT_ITEMS,\n  onSelect,\n  minWidth = 264,\n  className = \"\",\n}: {\n  /** trigger label; also the menu's aria-label */\n  label?: string;\n  /** the menu's rows */\n  items?: DrapeMenuItem[];\n  /** called with the chosen row's id when one is selected */\n  onSelect?: (id: string) => void;\n  /** cloth/panel width floor in px (widens to match a wider trigger) */\n  minWidth?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const menuId = useId();\n  const [open, setOpen] = useState(false);\n  const [mounted, setMounted] = useState(false);\n  const [reduced, setReduced] = useState(false);\n\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const elsRef = useRef<(HTMLElement | null)[]>([]);\n\n  const simRef = useRef<Sim | null>(null);\n  const geomRef = useRef<Geom | null>(null);\n  const phaseRef = useRef<\"fall\" | \"retract\">(\"fall\");\n  const lastDtRef = useRef(1 / 60);\n  const openStartRef = useRef(0);\n  const revealRef = useRef<{ armed: boolean[]; revealed: boolean[] }>({ armed: [], revealed: [] });\n  const wakeRef = useRef<(() => void) | null>(null);\n  const closeRef = useRef<(focusTrigger: boolean) => void>(() => {});\n  const onSelectRef = useRef(onSelect);\n  onSelectRef.current = onSelect;\n  const reducedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Keyed on a content signature rather than the `items` reference itself:\n  // consumers commonly pass an inline array literal (`<DrapeMenu items={[...]} />`),\n  // which gets a fresh identity on every parent re-render. Keying on `items`\n  // directly would tear down and reinit the live cloth sim (see the physics\n  // effect below, which depends on `entries`) on every unrelated re-render.\n  const itemsSig = items\n    .map((it) => `${it.id}|${it.label}|${it.shortcut ?? \"\"}|${it.separatorBefore ? 1 : 0}`)\n    .join(\"\u0001\");\n  const { list: entries, clothH } = useMemo(() => {\n    const list: Entry[] = [];\n    let y = PAD_TOP;\n    for (const item of items) {\n      if (item.separatorBefore && list.length > 0) {\n        list.push({ kind: \"sep\", y0: y, h: SEP_H });\n        y += SEP_H;\n      }\n      list.push({ kind: \"item\", item, y0: y, h: ITEM_H });\n      y += ITEM_H;\n    }\n    return { list, clothH: y + PAD_BOT };\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- itemsSig is the intended dependency\n  }, [itemsSig]);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // clear the reduced-mode close timer on unmount\n  useEffect(\n    () => () => {\n      if (reducedTimerRef.current) clearTimeout(reducedTimerRef.current);\n    },\n    []\n  );\n\n  const openMenu = () => {\n    if (reducedTimerRef.current) {\n      clearTimeout(reducedTimerRef.current);\n      reducedTimerRef.current = null;\n    }\n    openStartRef.current = performance.now();\n    if (!reduced && mounted && phaseRef.current === \"retract\") {\n      // reopen mid-retract: hand spring velocities back to the verlet solver\n      const s = simRef.current;\n      if (s) {\n        const dt = 1 / 60;\n        for (let i = 0; i < s.posX.length; i++) {\n          s.prevX[i] = s.posX[i] - s.velX[i] * dt;\n          s.prevY[i] = s.posY[i] - s.velY[i] * dt;\n        }\n      }\n      phaseRef.current = \"fall\";\n      const rev = revealRef.current;\n      rev.armed.fill(false);\n      rev.revealed.fill(false);\n      for (const el of elsRef.current) if (el) el.style.opacity = \"0\";\n      wakeRef.current?.();\n    }\n    setMounted(true);\n    setOpen(true);\n  };\n\n  const closeMenu = (focusTrigger: boolean) => {\n    if (!open) return;\n    if (reduced) {\n      setOpen(false);\n      reducedTimerRef.current = setTimeout(() => setMounted(false), 170);\n    } else {\n      const s = simRef.current;\n      if (s) {\n        const dt = lastDtRef.current || 1 / 60;\n        for (let i = 0; i < s.posX.length; i++) {\n          s.velX[i] = (s.posX[i] - s.prevX[i]) / dt;\n          s.velY[i] = (s.posY[i] - s.prevY[i]) / dt;\n        }\n      }\n      phaseRef.current = \"retract\";\n      setOpen(false);\n      for (const el of elsRef.current) if (el) el.style.opacity = \"0\";\n      wakeRef.current?.();\n    }\n    if (focusTrigger) triggerRef.current?.focus();\n  };\n  closeRef.current = closeMenu;\n\n  // close on outside pointerdown while mounted\n  useEffect(() => {\n    if (!mounted) return;\n    const onDown = (e: PointerEvent) => {\n      const w = wrapperRef.current;\n      if (w && e.target instanceof Node && !w.contains(e.target)) closeRef.current(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [mounted]);\n\n  // move focus to the first item once the menu opens\n  useEffect(() => {\n    if (!open || !mounted) return;\n    const id = requestAnimationFrame(() => {\n      for (let i = 0; i < entries.length; i++) {\n        if (entries[i].kind === \"item\") {\n          elsRef.current[i]?.focus({ preventScroll: true });\n          break;\n        }\n      }\n    });\n    return () => cancelAnimationFrame(id);\n  }, [open, mounted, entries]);\n\n  // -------------------------------------------------------------------------\n  // cloth simulation + canvas fold shading (skipped under reduced motion)\n  // -------------------------------------------------------------------------\n  useEffect(() => {\n    if (!mounted || reduced) return;\n    const wrapper = wrapperRef.current;\n    const trigger = triggerRef.current;\n    const overlay = overlayRef.current;\n    const canvas = canvasRef.current;\n    if (!wrapper || !trigger || !overlay || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const N = COLS * ROWS;\n    let raf = 0;\n    let last = 0;\n    let pActive = false;\n    let pxr = 0;\n    let pyr = 0;\n\n    // token-derived draw colors — re-derived live on documentElement class flips\n    let col = {\n      surface: [23, 23, 23] as RGB,\n      border: [46, 46, 46] as RGB,\n      lighter: [237, 237, 237] as RGB,\n      darker: [10, 10, 10] as RGB,\n    };\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const read = (name: string, fb: string): RGB => {\n        const raw = cs.getPropertyValue(name).trim();\n        ctx.fillStyle = fb;\n        if (raw) ctx.fillStyle = raw; // canvas normalizes any CSS color format\n        const v = String(ctx.fillStyle);\n        if (v.charAt(0) === \"#\" && v.length >= 7) {\n          return [\n            parseInt(v.slice(1, 3), 16),\n            parseInt(v.slice(3, 5), 16),\n            parseInt(v.slice(5, 7), 16),\n          ];\n        }\n        const m = v.match(/[\\d.]+/g);\n        if (m && m.length >= 3) return [Number(m[0]), Number(m[1]), Number(m[2])];\n        return [23, 23, 23];\n      };\n      const surface = read(\"--surface\", \"#171717\");\n      const border = read(\"--border\", \"#2e2e2e\");\n      const fg = read(\"--foreground\", \"#ededed\");\n      const bg = read(\"--background\", \"#0a0a0a\");\n      const lum = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];\n      // sheen goes toward whichever ink is lighter, shadow toward the darker —\n      // fold shading stays visible on both themes without hardcoded hexes\n      const fgLighter = lum(fg) >= lum(bg);\n      col = {\n        surface,\n        border,\n        lighter: fgLighter ? fg : bg,\n        darker: fgLighter ? bg : fg,\n      };\n    };\n    const mix = (a: RGB, b: RGB, t: number) =>\n      `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(\n        a[2] + (b[2] - a[2]) * t\n      )})`;\n    const rgba = (c: RGB, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;\n\n    const measure = (): boolean => {\n      const tRect = trigger.getBoundingClientRect();\n      if (tRect.width < 2 || tRect.height < 2) return false; // zero-size guard\n      const clothW = Math.max(minWidth, Math.round(tRect.width));\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      const cw = clothW + MARGIN_X * 2;\n      const ch = clothH + MARGIN_TOP + MARGIN_BOT;\n      canvas.width = Math.max(1, Math.round(cw * dpr));\n      canvas.height = Math.max(1, Math.round(ch * dpr));\n      canvas.style.width = `${cw}px`;\n      canvas.style.height = `${ch}px`;\n      geomRef.current = {\n        tw: tRect.width,\n        clothW,\n        dx0: clothW / (COLS - 1),\n        dy0: clothH / (ROWS - 1),\n        dpr,\n      };\n      const itemW = clothW - INSET * 2;\n      for (const el of elsRef.current) if (el) el.style.width = `${itemW}px`;\n      return true;\n    };\n    if (!measure()) return;\n\n    // vertices start compressed at the trigger's bottom edge, then fall free\n    const initCloth = () => {\n      const g = geomRef.current;\n      if (!g) return;\n      const sim: Sim = {\n        posX: new Float32Array(N),\n        posY: new Float32Array(N),\n        prevX: new Float32Array(N),\n        prevY: new Float32Array(N),\n        velX: new Float32Array(N),\n        velY: new Float32Array(N),\n      };\n      for (let r = 0; r < ROWS; r++) {\n        for (let c = 0; c < COLS; c++) {\n          const i = r * COLS + c;\n          sim.posX[i] = c * g.dx0;\n          // fraction of rest length, not an absolute px start: the prior fixed\n          // `r * 1.5` compressed rows to ~1/13th of dy0, which this solver's\n          // 3-iteration relax can't recover from and folds into a stuck ~14px\n          // clump instead of draping (reproduced with a plain click). 0.5 keeps\n          // the compressed-then-falls look inside the solver's convergence basin.\n          sim.posY[i] = r * g.dy0 * 0.5;\n          sim.prevX[i] = sim.posX[i];\n          sim.prevY[i] = sim.posY[i];\n        }\n      }\n      simRef.current = sim;\n    };\n    initCloth();\n    phaseRef.current = \"fall\";\n    revealRef.current = {\n      armed: new Array<boolean>(entries.length).fill(false),\n      revealed: new Array<boolean>(entries.length).fill(false),\n    };\n    derive();\n\n    const A = { x: 0, y: 0 };\n    const B = { x: 0, y: 0 };\n    const C = { x: 0, y: 0 };\n    const D = { x: 0, y: 0 };\n\n    const sampleInto = (\n      X: Float32Array,\n      Y: Float32Array,\n      u: number,\n      v: number,\n      out: { x: number; y: number }\n    ) => {\n      const cf = u * (COLS - 1);\n      const rf = v * (ROWS - 1);\n      let c0 = Math.floor(cf);\n      let r0 = Math.floor(rf);\n      if (c0 < 0) c0 = 0;\n      else if (c0 > COLS - 2) c0 = COLS - 2;\n      if (r0 < 0) r0 = 0;\n      else if (r0 > ROWS - 2) r0 = ROWS - 2;\n      let fx = cf - c0;\n      let fy = rf - r0;\n      if (fx < 0) fx = 0;\n      else if (fx > 1) fx = 1;\n      if (fy < 0) fy = 0;\n      else if (fy > 1) fy = 1;\n      const i00 = r0 * COLS + c0;\n      const i01 = i00 + COLS;\n      const x0 = X[i00] + (X[i00 + 1] - X[i00]) * fx;\n      const x1 = X[i01] + (X[i01 + 1] - X[i01]) * fx;\n      const y0 = Y[i00] + (Y[i00 + 1] - Y[i00]) * fx;\n      const y1 = Y[i01] + (Y[i01 + 1] - Y[i01]) * fx;\n      out.x = x0 + (x1 - x0) * fy;\n      out.y = y0 + (y1 - y0) * fy;\n    };\n\n    const updateEntries = (dt: number, now: number) => {\n      const g = geomRef.current;\n      const s = simRef.current;\n      if (!g || !s) return;\n      const uL = INSET / g.clothW;\n      const uR = 1 - uL;\n      const rev = revealRef.current;\n      const falling = phaseRef.current === \"fall\";\n      for (let i = 0; i < entries.length; i++) {\n        const el = elsRef.current[i];\n        if (!el) continue;\n        const en = entries[i];\n        const v = (en.y0 + en.h / 2) / clothH;\n        sampleInto(s.posX, s.posY, uL, v, A);\n        sampleInto(s.posX, s.posY, uR, v, B);\n        let ang = Math.atan2(B.y - A.y, B.x - A.x);\n        if (ang > MAX_TILT) ang = MAX_TILT;\n        else if (ang < -MAX_TILT) ang = -MAX_TILT;\n        el.style.transform = `translate3d(${A.x.toFixed(2)}px, ${(A.y - en.h / 2).toFixed(\n          2\n        )}px, 0) rotate(${ang.toFixed(4)}rad)`;\n        if (falling && !rev.revealed[i]) {\n          // label fades in once its anchor row's vertical velocity settles\n          sampleInto(s.posX, s.posY, 0.5, v, C);\n          sampleInto(s.prevX, s.prevY, 0.5, v, D);\n          const vy = Math.abs(C.y - D.y) / dt;\n          if (vy > ARM_V) rev.armed[i] = true;\n          if ((rev.armed[i] && vy < REVEAL_V) || now - openStartRef.current > 900) {\n            rev.revealed[i] = true;\n            el.style.opacity = \"1\";\n          }\n        }\n      }\n    };\n\n    const draw = () => {\n      const g = geomRef.current;\n      const s = simRef.current;\n      if (!g || !s) return;\n      const { posX, posY } = s;\n      ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);\n      ctx.clearRect(0, 0, canvas.width / g.dpr, canvas.height / g.dpr);\n      ctx.translate(MARGIN_X, MARGIN_TOP);\n      ctx.lineJoin = \"round\";\n      // per-quad fold shading: brightness from horizontal stretch (taut =\n      // sheen toward the lighter ink) and lateral lean (fold shadow toward\n      // the darker ink) — a cheap vertex-normal approximation\n      for (let r = 0; r < ROWS - 1; r++) {\n        for (let c = 0; c < COLS - 1; c++) {\n          const i00 = r * COLS + c;\n          const i10 = i00 + 1;\n          const i01 = i00 + COLS;\n          const i11 = i01 + 1;\n          const ex =\n            (Math.hypot(posX[i10] - posX[i00], posY[i10] - posY[i00]) +\n              Math.hypot(posX[i11] - posX[i01], posY[i11] - posY[i01])) /\n            (2 * g.dx0);\n          const lean = (posX[i01] - posX[i00] + (posX[i11] - posX[i10])) / (2 * g.dy0);\n          let sh = 2.4 * (ex - 1) + 1.1 * lean;\n          if (sh > 1) sh = 1;\n          else if (sh < -1) sh = -1;\n          const fill =\n            sh >= 0\n              ? mix(col.surface, col.lighter, 0.06 + 0.3 * sh)\n              : mix(col.surface, col.darker, 0.05 + 0.32 * -sh);\n          ctx.beginPath();\n          ctx.moveTo(posX[i00], posY[i00]);\n          ctx.lineTo(posX[i10], posY[i10]);\n          ctx.lineTo(posX[i11], posY[i11]);\n          ctx.lineTo(posX[i01], posY[i01]);\n          ctx.closePath();\n          ctx.fillStyle = fill;\n          ctx.fill();\n          ctx.strokeStyle = fill; // 1px self-stroke kills seams between quads\n          ctx.lineWidth = 1;\n          ctx.stroke();\n        }\n      }\n      // weave ribs\n      ctx.strokeStyle = rgba(col.border, 0.45);\n      ctx.lineWidth = 1;\n      for (let r = 1; r < ROWS - 1; r++) {\n        ctx.beginPath();\n        ctx.moveTo(posX[r * COLS], posY[r * COLS]);\n        for (let c = 1; c < COLS; c++) ctx.lineTo(posX[r * COLS + c], posY[r * COLS + c]);\n        ctx.stroke();\n      }\n      // hem\n      ctx.strokeStyle = rgba(col.border, 0.9);\n      ctx.beginPath();\n      ctx.moveTo(posX[0], posY[0]);\n      for (let c = 1; c < COLS; c++) ctx.lineTo(posX[c], posY[c]);\n      for (let r = 1; r < ROWS; r++) ctx.lineTo(posX[r * COLS + COLS - 1], posY[r * COLS + COLS - 1]);\n      for (let c = COLS - 2; c >= 0; c--)\n        ctx.lineTo(posX[(ROWS - 1) * COLS + c], posY[(ROWS - 1) * COLS + c]);\n      for (let r = ROWS - 2; r > 0; r--) ctx.lineTo(posX[r * COLS], posY[r * COLS]);\n      ctx.closePath();\n      ctx.stroke();\n    };\n\n    const relax = (s: Sim, i: number, j: number, rest: number) => {\n      const dx = s.posX[j] - s.posX[i];\n      const dy = s.posY[j] - s.posY[i];\n      const dist = Math.hypot(dx, dy);\n      if (dist < 1e-6) return; // degenerate-pair guard\n      const f = ((dist - rest) / dist) * 0.5;\n      const ox = dx * f;\n      const oy = dy * f;\n      s.posX[i] += ox;\n      s.posY[i] += oy;\n      s.posX[j] -= ox;\n      s.posY[j] -= oy;\n    };\n\n    const finishClose = () => {\n      simRef.current = null;\n      phaseRef.current = \"fall\";\n      setMounted(false);\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const g = geomRef.current;\n      const s = simRef.current;\n      if (!g || !s) return;\n      const dt = last === 0 ? 1 / 60 : Math.min(DT_MAX, Math.max(0.001, (now - last) / 1000));\n      last = now;\n      lastDtRef.current = dt;\n      let sleep = false;\n\n      if (phaseRef.current === \"fall\") {\n        // verlet step: gravity + gaussian cursor billow, 0.99 damping/step\n        const dt2 = dt * dt;\n        for (let i = COLS; i < N; i++) {\n          const x = s.posX[i];\n          const y = s.posY[i];\n          let ay = GRAVITY;\n          if (pActive) {\n            const ddx = x - pxr;\n            const ddy = y - pyr;\n            const d2 = ddx * ddx + ddy * ddy;\n            if (d2 < BILLOW_RANGE2)\n              ay -= BILLOW_F * Math.exp(-d2 / (2 * BILLOW_SIGMA * BILLOW_SIGMA));\n          }\n          s.posX[i] = x + (x - s.prevX[i]) * VERLET_DAMP;\n          s.posY[i] = y + (y - s.prevY[i]) * VERLET_DAMP + ay * dt2;\n          s.prevX[i] = x;\n          s.prevY[i] = y;\n        }\n        for (let it = 0; it < RELAX_ITERS; it++) {\n          for (let r = 0; r < ROWS; r++) {\n            const row = r * COLS;\n            for (let c = 0; c < COLS - 1; c++) relax(s, row + c, row + c + 1, g.dx0);\n          }\n          for (let r = 0; r < ROWS - 1; r++) {\n            for (let c = 0; c < COLS; c++) relax(s, r * COLS + c, (r + 1) * COLS + c, g.dy0);\n          }\n          // re-pin the top row along the trigger's bottom edge\n          for (let c = 0; c < COLS; c++) {\n            s.posX[c] = c * g.dx0;\n            s.posY[c] = 0;\n            s.prevX[c] = s.posX[c];\n            s.prevY[c] = 0;\n          }\n        }\n        let maxDp = 0;\n        for (let i = COLS; i < N; i++) {\n          const dpx = Math.abs(s.posX[i] - s.prevX[i]);\n          const dpy = Math.abs(s.posY[i] - s.prevY[i]);\n          if (dpx > maxDp) maxDp = dpx;\n          if (dpy > maxDp) maxDp = dpy;\n        }\n        updateEntries(dt, now);\n        draw();\n        // Hard elapsed-time cap: the drape is visually at rest well before the\n        // velocity epsilon trips (documented drape time is 380-500ms), but\n        // sub-visual verlet jitter can keep the loop at 60fps for several extra\n        // seconds. Force-sleep with margin past that so we're not burning\n        // frames on imperceptible motion.\n        if (maxDp < SLEEP_EPS || now - openStartRef.current > 1200) sleep = true;\n      } else {\n        // retract: every vertex on a critically damped spring to the trigger\n        const tx = g.tw / 2;\n        let maxDisp = 0;\n        for (let i = 0; i < N; i++) {\n          let vx = s.velX[i];\n          let vy = s.velY[i];\n          vx += (-SPRING_K * (s.posX[i] - tx) - SPRING_C * vx) * dt;\n          vy += (-SPRING_K * s.posY[i] - SPRING_C * vy) * dt;\n          s.velX[i] = vx;\n          s.velY[i] = vy;\n          s.posX[i] += vx * dt;\n          s.posY[i] += vy * dt;\n          s.prevX[i] = s.posX[i];\n          s.prevY[i] = s.posY[i];\n          const dx = Math.abs(s.posX[i] - tx);\n          const dy = Math.abs(s.posY[i]);\n          if (dx > maxDisp) maxDisp = dx;\n          if (dy > maxDisp) maxDisp = dy;\n        }\n        updateEntries(dt, now);\n        draw();\n        if (maxDisp < DONE_EPS) {\n          finishClose();\n          return;\n        }\n      }\n\n      if (sleep) last = 0;\n      else raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    wakeRef.current = wake;\n    wake();\n\n    const onMove = (e: PointerEvent) => {\n      const oRect = overlay.getBoundingClientRect();\n      pxr = e.clientX - oRect.left;\n      pyr = e.clientY - oRect.top;\n      pActive = true;\n      wake();\n    };\n    const onLeave = () => {\n      pActive = false;\n      wake();\n    };\n    wrapper.addEventListener(\"pointermove\", onMove);\n    wrapper.addEventListener(\"pointerleave\", onLeave);\n\n    const ro = new ResizeObserver(() => {\n      if (measure()) wake();\n    });\n    ro.observe(trigger);\n\n    const mo = new MutationObserver(() => {\n      derive();\n      wake();\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      wakeRef.current = null;\n      ro.disconnect();\n      mo.disconnect();\n      wrapper.removeEventListener(\"pointermove\", onMove);\n      wrapper.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [mounted, reduced, entries, clothH, minWidth]);\n\n  // -------------------------------------------------------------------------\n  // keyboard menu semantics\n  // -------------------------------------------------------------------------\n  const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const btns: HTMLElement[] = [];\n    for (let i = 0; i < entries.length; i++) {\n      if (entries[i].kind === \"item\") {\n        const el = elsRef.current[i];\n        if (el) btns.push(el);\n      }\n    }\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      closeMenu(true);\n      return;\n    }\n    if (e.key === \"Tab\") {\n      closeMenu(false);\n      return;\n    }\n    if (btns.length === 0) return;\n    const idx = btns.indexOf(document.activeElement as HTMLElement);\n    let next = -1;\n    if (e.key === \"ArrowDown\") next = (idx + 1) % btns.length;\n    else if (e.key === \"ArrowUp\") next = idx < 0 ? btns.length - 1 : (idx - 1 + btns.length) % btns.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = btns.length - 1;\n    if (next >= 0) {\n      e.preventDefault();\n      btns[next].focus({ preventScroll: true });\n    }\n  };\n\n  const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {\n    if (!open && (e.key === \"ArrowDown\" || e.key === \"ArrowUp\")) {\n      e.preventDefault();\n      openMenu();\n    } else if (open && e.key === \"Escape\") {\n      e.preventDefault();\n      closeMenu(true);\n    }\n  };\n\n  const select = (id: string) => {\n    onSelectRef.current?.(id);\n    closeMenu(true);\n  };\n\n  const setEl = (i: number) => (el: HTMLElement | null) => {\n    elsRef.current[i] = el;\n  };\n\n  const itemClass =\n    \"group flex items-center gap-2.5 rounded-sm px-3 text-left text-sm text-foreground outline-none \" +\n    \"hover:bg-surface focus-visible:bg-surface focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent\";\n  const rail = (\n    <span\n      aria-hidden\n      className=\"absolute bottom-[7px] left-0 top-[7px] w-0.5 rounded-full bg-ns-accent opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100\"\n    />\n  );\n\n  const itemInner = (item: DrapeMenuItem) => (\n    <>\n      {rail}\n      {item.icon && (\n        <span className=\"shrink-0 text-ns-muted transition-colors duration-150 group-hover:text-foreground group-focus-visible:text-foreground\">\n          {item.icon}\n        </span>\n      )}\n      <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n      {item.shortcut && <span className=\"shrink-0 font-mono text-xs text-ns-muted\">{item.shortcut}</span>}\n    </>\n  );\n\n  return (\n    <div ref={wrapperRef} className={`relative inline-block ${className}`}>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={mounted ? menuId : undefined}\n        onClick={() => (open ? closeMenu(true) : openMenu())}\n        onKeyDown={onTriggerKeyDown}\n        className=\"inline-flex items-center gap-2 rounded-sm border border-border bg-surface px-3.5 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n      >\n        {label}\n        <svg\n          viewBox=\"0 0 16 16\"\n          className={`h-3.5 w-3.5 text-ns-muted transition-transform duration-200 ${open ? \"rotate-180\" : \"\"}`}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n        >\n          <path d=\"m4 6 4 4 4-4\" />\n        </svg>\n      </button>\n\n      {mounted && !reduced && (\n        <div ref={overlayRef} className=\"absolute left-0 top-full z-50\">\n          <canvas\n            ref={canvasRef}\n            aria-hidden\n            className=\"pointer-events-none absolute\"\n            style={{ left: -MARGIN_X, top: -MARGIN_TOP }}\n          />\n          <div\n            id={menuId}\n            role=\"menu\"\n            aria-label={label}\n            onKeyDown={onMenuKeyDown}\n            className=\"relative\"\n          >\n            {entries.map((en, i) =>\n              en.kind === \"sep\" ? (\n                <div\n                  key={`sep-${i}`}\n                  ref={setEl(i)}\n                  role=\"separator\"\n                  aria-orientation=\"horizontal\"\n                  className=\"absolute left-0 top-0 flex items-center px-2.5 will-change-transform\"\n                  style={{\n                    height: SEP_H,\n                    opacity: 0,\n                    transition: \"opacity 120ms linear\",\n                    transformOrigin: \"0 50%\",\n                  }}\n                >\n                  <span className=\"h-px w-full bg-border\" />\n                </div>\n              ) : (\n                <button\n                  key={en.item.id}\n                  ref={setEl(i)}\n                  type=\"button\"\n                  role=\"menuitem\"\n                  tabIndex={-1}\n                  onClick={() => select(en.item.id)}\n                  className={`absolute left-0 top-0 will-change-transform ${itemClass}`}\n                  style={{\n                    height: ITEM_H,\n                    opacity: 0,\n                    transition: \"opacity 120ms linear\",\n                    transformOrigin: \"0 50%\",\n                  }}\n                >\n                  {itemInner(en.item)}\n                </button>\n              )\n            )}\n          </div>\n        </div>\n      )}\n\n      {mounted && reduced && (\n        <div\n          id={menuId}\n          role=\"menu\"\n          aria-label={label}\n          onKeyDown={onMenuKeyDown}\n          data-state={open ? \"open\" : \"closed\"}\n          className=\"absolute left-0 top-full z-50 mt-2 origin-top rounded-md border border-border bg-surface p-1.5 shadow-lg transition-[opacity,transform] duration-150 data-[state=closed]:pointer-events-none data-[state=closed]:scale-[0.98] data-[state=closed]:opacity-0\"\n          style={{ width: minWidth, animation: \"ns-drape-in 150ms ease-out\" }}\n        >\n          {entries.map((en, i) =>\n            en.kind === \"sep\" ? (\n              <div\n                key={`sep-${i}`}\n                role=\"separator\"\n                aria-orientation=\"horizontal\"\n                className=\"mx-1 my-1.5 h-px bg-border\"\n              />\n            ) : (\n              <button\n                key={en.item.id}\n                ref={setEl(i)}\n                type=\"button\"\n                role=\"menuitem\"\n                tabIndex={-1}\n                onClick={() => select(en.item.id)}\n                className={`relative w-full py-2 ${itemClass}`}\n              >\n                {itemInner(en.item)}\n              </button>\n            )\n          )}\n        </div>\n      )}\n\n      <style>{`@keyframes ns-drape-in{from{opacity:0;transform:scale(.98)}}`}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/dropdown-drape.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "menu",
      "dropdown",
      "cloth",
      "physics",
      "verlet",
      "cursor",
      "nav"
    ],
    "instruction": "Build a dropdown menu whose panel is a live 2D verlet cloth pinned along its top edge to the trigger's bottom edge. Simulate a 10x14 point grid with structural constraints only and 3 relaxation iterations per frame; gravity 2400 px/s^2, velocity damping 0.99 per step. On open the vertices start compressed at the trigger's bottom edge and fall freely, draping taut in roughly 380-500 ms — emergent from the physics, not tweened. A Canvas 2D layer underneath draws per-quad fold shading from a vertex-normal approximation (horizontal stretch = sheen toward the lighter ink, lateral lean = shadow toward the darker ink), with every drawn color derived from getComputedStyle CSS tokens (--surface/--border/--foreground/--background) at mount and re-derived live via a MutationObserver on the documentElement class so both themes shade correctly. Menu items are real DOM nodes (role=menu/menuitem) anchored to designated vertex rows: each item's transform is its anchor vertex translate plus a rotation from the local row slope clamped to +/-6 degrees, and each label fades in over 120 ms once its anchor row's vertical velocity drops below 8 px/s. Cursor proximity billows the weave: an upward force F = 900 * exp(-d^2 / (2*60^2)) px/s^2 on vertices within ~150 px of the pointer. Close yanks every vertex back to the trigger point on a per-vertex critically damped spring (k = 120 s^-2, zeta = 1.0) and unmounts once max displacement is under 0.5 px. Hot path is refs-only direct-DOM in a single rAF loop that sleeps when max per-frame displacement is under 0.02 px, or unconditionally 1200 ms after open (a backstop for imperceptible verlet jitter that can otherwise keep the epsilon from tripping), and wakes on pointermove/open/close; ResizeObserver re-derives cloth width from the trigger rect with a zero-size guard, and all listeners, observers, and the canvas are torn down on close/unmount. Full menu semantics: aria-expanded trigger, arrow-key navigation, Home/End, Enter selects, Esc closes and returns focus to the trigger, outside click closes; hovered/focused items get a token-relative bg-surface highlight plus an accent left rail and accent focus-visible ring. Under prefers-reduced-motion render a plain dropdown with a 150 ms opacity/scale-98% fade and no cloth or canvas."
  },
  "type": "registry:ui"
}