{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"folding-panel","title":"Folding Panel","description":"A panel that folds along real crease lines. Facets are placed by walking the fold outward so the paper stays connected, the panel foreshortens by cos θ as it closes, and each facet shades by Lambert's cosine law from its own normal.","dependencies":[],"files":[{"path":"src/components/FoldingPanel/index.js","content":"export { default } from './FoldingPanel.jsx'\n","type":"registry:component","target":"components/folding-panel/index.js"},{"path":"src/components/FoldingPanel/FoldingPanel.jsx","content":"'use client'\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'\nimport './FoldingPanel.css'\n\n/* =========================================================\n   FoldingPanel\n   A panel that folds along real crease lines, each facet\n   catching light correctly as its angle changes.\n\n   The usual accordion scales its panels, or turns each one\n   about its own edge, and it cannot close: the facets come\n   apart and pass through one another, because nothing\n   tracks where the previous crease ended up. A folded sheet\n   is a zigzag, and its creases lie in two planes. Every\n   other one stays where the flat sheet was; the ones between\n   fall back from it by w·sin θ. So each facet here is a\n   single turn about a crease that stays on the panel's own\n   plane — an even facet about its leading crease, an odd one\n   about its trailing crease — and the crease that falls back\n   between two neighbours is reached from both sides at once.\n   The paper stays connected however far it closes, and there\n   is nothing to measure: the whole fold is written in\n   percentages of a facet, so a resize costs nothing.\n\n   Two things make it read as paper rather than as rotated\n   divs:\n\n   · the accordion foreshortens. A facet of width w tilted by\n     θ occupies w·cos θ across the panel, so the whole thing\n     narrows as it folds — exactly as a folded map does —\n     about `anchor`, the one point of it that stays put.\n   · each facet shades by Lambert's cosine law, I = N·L, with\n     the normal taken from the facet's own angle. Alternate\n     facets tilt opposite ways, so they alternate light and\n     dark on their own rather than from a hardcoded stripe.\n\n   perspective sits on the container, not the facets, so all\n   of them share one vanishing point. Per-element perspective\n   gives each facet its own, and the fold visibly comes apart.\n   The fold falls back from the panel's plane, so it closes\n   in on that point and never outgrows its box. (A negative\n   maxAngle folds it toward the eye instead, where it can.)\n\n   A new fold does not arrive in one frame. The crease angle\n   rides a spring,\n\n     θ″ = −k·(θ − θ₀) − c·θ′\n\n   critically damped at the defaults: it sets off at once,\n   is there in about a third of a second and does not swing\n   past, because heavy paper has no bounce in it. Opening\n   reads quicker than closing, and nothing was tuned to make\n   it so: width goes as cos θ, which is all but flat near\n   θ = 0, so the panel is at full width well before the angle\n   has finished arriving. A change that lands mid-move only\n   moves θ₀ — the angle and its rate carry on — so a fold\n   told to close and then to open turns round in one motion\n   instead of starting again. The spring is solved rather\n   than stepped: each frame takes the exact solution over the\n   time that really passed, so it is the same curve at 60 Hz\n   and at 120 Hz and it holds at any stiffness. The loop\n   stops the moment the fold has settled. Out of view, or\n   under reduced motion, there is no move to watch and the\n   fold is simply at its new angle. The light, the ambient\n   floor and the anchor ease over ~80 ms rather than snap.\n\n   With `drag`, a hand folds it, and the foreshortening law\n   is the whole mapping. A point of the sheet m from the\n   anchor sits at m·cos θ, so for it to stay under the finger\n   cos θ has to move by the finger's travel over m: pull out\n   and the fold opens, push in and it closes, and the paper\n   you took hold of stays in your hand — as nearly as the\n   vanishing point allows: the law is the flat one, and a\n   crease that has fallen back is drawn a little nearer the\n   middle than it lies. Paper does not stretch, so the pull\n   stops dead at flat. Taken mid-move, the fold stops where\n   it was caught; a press that never travels takes nothing\n   else, so a click cannot crease it, and a pull is not a\n   click: the one the browser makes of it is swallowed, so a\n   panel inside a link can be folded. Let go and it carries\n   on the way the hand was going — the hand's speed fitted\n   over the last 70 ms of the stroke, not one event — with\n   the spring picking up from that angle and that rate.\n   Under reduced motion the fold still follows the hand —\n   that motion is yours — and stays where it is let go.\n\n   The panel never keeps a fold of its own. A hand, or a key,\n   asks for one through onFoldChange, and `fold` stays the\n   truth: answer and the paper stays where it was left, don't\n   and it springs back. The drag takes one axis and leaves\n   the page the other and the pinch. Its keyboard twin is a\n   real range input laid unseen over the panel — arrows,\n   Page Up and Down, Home and End, the name and value a\n   screen reader expects — there only when there is an\n   onFoldChange for it to work.\n\n   The content is painted once per facet but exists once. The\n   first facet holds the real copy; the others are aria-hidden\n   and inert, so a screen reader reads it once and Tab stops\n   in it once. That makes the children artwork: a control in\n   them works only where the first facet shows it.\n   ========================================================= */\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\nconst finite = (n, d) => (Number.isFinite(n) ? n : d)\n\n/* The fold is posed in a layout effect, so a change of axis or facet count is\n   never painted half-applied. Plain useEffect on the server, where there is\n   nothing to lay out and older Reacts warn about it. */\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect\n\nconst RAD = Math.PI / 180\nconst HALF = Math.PI / 2\nconst MOST = 64       // facets, at most\nconst EASE = 0.08     // s — how quickly the light, the ambient floor and the anchor follow a change\nconst REST = 3e-4     // rad — closer to its target than this, and slow, the fold is there\nconst RIGID = 1e5     // 1/s² — a spring this stiff is there within a frame, and is taken to be rigid\nconst TRAIL = 70      // ms of stroke the release speed is fitted over\nconst FAST = 4000     // px/s — the fastest a hand is taken to be moving\nconst REACH = 0.12    // the nearest to the anchor a hand is taken to hold the sheet, as a fraction of the panel\nconst SLOP = 3        // px a press may wander before it is a pull\nconst STEP = 0.05     // fold per arrow key\nconst LEAP = 0.2      // fold per Page Up or Page Down\n\n// Something in the artwork that wants the press for itself.\nconst CONTROLS = 'a, button, input, select, textarea, label, summary, [contenteditable]'\n\n// The props as the fold uses them: in range, and a number wherever one is needed.\nfunction read(p) {\n  const top = clamp(finite(p.maxAngle, 78), -90, 90) * RAD\n  const k = p.stiffness === Infinity ? Infinity : Math.max(1, finite(p.stiffness, 400))\n  return {\n    top,\n    to: clamp(finite(p.fold, 0), 0, 1) * top,\n    light: clamp(finite(p.light, 0.55), 0, 1),\n    ambient: clamp(finite(p.ambient, 0.42), 0, 1),\n    anchor: clamp(finite(p.anchor, 0.5), 0, 1),\n    k,\n    c: clamp(finite(p.damping, 40), 1, 1e4), // a fold always loses something on the way\n    rigid: k >= RIGID,\n    horizontal: p.axis !== 'y',\n  }\n}\n\n/* Facet i of n at crease angle t: its transform and its shade.\n\n   Crease j of the flat sheet sits j facets along. Folded, it sits j·cos t\n   along, plus whatever keeps the anchor where it was — so the facet's hinge,\n   crease i for an even facet and i + 1 for an odd one, has moved by\n   (anchor·n − j)·(1 − cos t) facets, which as a percentage is in the facet's\n   own width and needs no measuring. Flat, there is no transform at all, and\n   the facets are plain boxes that meet on whole pixels. */\nfunction pose(i, n, t, anchor, light, ambient, horizontal) {\n  const odd = i & 1\n  const a = odd ? -t : t\n  const shift = ((anchor * n - (i + odd)) * (1 - Math.cos(t)) * 100).toFixed(3)\n  const deg = (a / RAD).toFixed(3)\n  const transform = !t\n    ? 'none'\n    : horizontal\n      ? `translateX(${shift}%) rotateY(${deg}deg)`\n      : `translateY(${shift}%) rotateX(${-deg}deg)`\n\n  /* Lambert: the facet normal turns with the facet, so the dot product with a\n     fixed light gives alternating bright and dark automatically. */\n  const lx = (light - 0.5) * 2\n  const lz = Math.sqrt(Math.max(0, 1 - lx * lx))\n  const lambert = clamp(Math.sin(a) * lx + Math.cos(a) * lz, 0, 1)\n  return [transform, (ambient + (1 - ambient) * lambert).toFixed(3)]\n}\n\n/* A damped spring's offset x and rate v after h seconds, for stiffness k and\n   damping c: the exact solution, in whichever of its three forms the damping\n   calls for — it rings below critical, creeps above it. */\nfunction spring(x, v, k, c, h) {\n  const s = c / 2\n  const d = s * s - k\n  if (Math.abs(d) < 1e-6 * k) {\n    const b = v + s * x\n    const e = Math.exp(-s * h)\n    return [(x + b * h) * e, (v - s * b * h) * e]\n  }\n  if (d < 0) {\n    const w = Math.sqrt(-d)\n    const b = (v + s * x) / w\n    const e = Math.exp(-s * h)\n    const cs = Math.cos(w * h), sn = Math.sin(w * h)\n    return [e * (x * cs + b * sn), e * (v * cs - (s * b + w * x) * sn)]\n  }\n  const q = Math.sqrt(d)\n  const r1 = -s + q, r2 = -s - q\n  const a = (v - r2 * x) / (2 * q), b = x - a\n  const e1 = Math.exp(r1 * h), e2 = Math.exp(r2 * h)\n  return [a * e1 + b * e2, a * r1 * e1 + b * r2 * e2]\n}\n\nexport default function FoldingPanel({\n  facets = 6,          // how many facets the panel is creased into, 1–64\n  fold = 0,            // 0 flat, 1 fully closed\n  maxAngle = 78,       // degrees each facet tilts at fold = 1, up to 90; negative folds toward the eye\n  perspective = 1200,  // px\n  light = 0.55,        // direction of the key light, 0..1 across the panel\n  ambient = 0.42,      // floor brightness, so a dark facet is not black\n  axis = 'x',          // fold across the width, or down the height\n\n  anchor = 0.5,        // the point of the panel that stays put as it narrows, 0..1 along the fold\n  stiffness = 400,     // spring carrying the crease angle to a new fold, 1/s² — Infinity follows the prop exactly\n  damping = 40,        // its damping, 1/s — at the defaults critical: there in a third of a second, never past\n\n  drag = false,        // let a hand fold it: the paper it takes hold of stays under it\n  onFoldChange,        // called with the fold, 0..1, that a hand or a key is asking for\n  dragLabel = 'Fold',  // accessible name of the slider that stands in for the drag on a keyboard\n\n  children,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const api = useRef(null)\n\n  const n = clamp(Math.floor(finite(facets, 6)), 1, MOST)\n  const strips = useMemo(() => Array.from({ length: n }, (_, i) => i), [n])\n  const upright = axis === 'y'\n  const at = clamp(finite(fold, 0), 0, 1)\n\n  // The fold as first rendered, so the server's markup is already folded. After\n  // that the loop owns the facets' transforms and React leaves them alone.\n  const [first] = useState(() => read({ fold, maxAngle, light, ambient, anchor, axis }))\n\n  const live = useRef({})\n  // Synced after every render and before the effects below, so the loop reads\n  // current props without being torn down and restarted.\n  useIsomorphicLayoutEffect(() => {\n    live.current = { fold, maxAngle, light, ambient, axis, anchor, stiffness, damping, drag, onFoldChange }\n  })\n\n  useIsomorphicLayoutEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n\n    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = motion.matches\n    let seen = true               // false once the panel is known to be out of view\n\n    let cells = []\n    const p0 = read(live.current)\n    let x = p0.to, v = 0          // the crease angle, rad, and its rate\n    let li = p0.light, am = p0.ambient, an = p0.anchor   // as displayed, eased toward the props\n    let raf = 0, last = 0\n    let tick = 1 / 60             // the display's frame interval, as last measured\n\n    // The hand. Held, the crease angle is whatever keeps the paper under it.\n    let held = false, pid = -1\n    let box = null, scale = 1     // root rect, cached for the length of a drag\n    let wide = true               // the axis it was taken along\n    let taken = false             // the press has travelled, and is a pull\n    let h0 = 0, c0 = 1            // where along the panel it took hold, and cos θ then\n    let lever = 1                 // how far along the flat sheet from the anchor, signed, px\n    let floor = 0, sign = 1       // cos θ at the far end of the pull, and which way the fold tilts\n    let trail = []                // [t, px along the panel] of the stroke\n    let told = NaN                // the fold last asked for\n    let pulled = -1               // when a pull was last let go, ms — the click that follows it is not one\n\n    const paint = (horizontal) => {\n      const count = cells.length\n      for (let i = 0; i < count; i++) {\n        const [transform, shade] = pose(i, count, x, an, li, am, horizontal)\n        cells[i].style.transform = transform\n        cells[i].style.setProperty('--fp-shade', shade)\n      }\n    }\n\n    const frame = (now) => {\n      raf = 0\n      // Back-to-back frames measure the display, 60 Hz or 120 Hz alike. The\n      // first frame after the loop has been idle advances one frame of that,\n      // not the idle time; a stall mid-move, like a return to the tab,\n      // advances at most 1/30 s.\n      const gap = last ? (now - last) / 1000 : 0\n      const dt = gap > 0 ? Math.min(gap, 1 / 30) : tick\n      if (gap > 0 && gap < 1 / 40) tick += (gap - tick) * 0.5\n      last = now\n      const p = read(live.current)\n      const quick = still || !seen\n      let busy = false\n\n      if (!held) {\n        if (!quick && !p.rigid) {\n          ;[x, v] = spring(x - p.to, v, p.k, p.c, dt)\n          x += p.to\n          // Edge-on, the facets have met: a swing cannot carry them through\n          // one another, and stops there.\n          if (Math.abs(x) > HALF) { x = clamp(x, -HALF, HALF); v = 0 }\n        }\n        if (quick || p.rigid || (Math.abs(x - p.to) < REST && Math.abs(v) < REST * 20)) { x = p.to; v = 0 }\n        else busy = true\n      }\n\n      const e = quick ? 1 : 1 - Math.exp(-dt / EASE)\n      li += (p.light - li) * e\n      am += (p.ambient - am) * e\n      an += (p.anchor - an) * e\n      if (Math.abs(p.light - li) < 1e-3) li = p.light; else busy = true\n      if (Math.abs(p.ambient - am) < 1e-3) am = p.ambient; else busy = true\n      if (Math.abs(p.anchor - an) < 1e-4) an = p.anchor; else busy = true\n\n      paint(p.horizontal)\n      if (busy) raf = requestAnimationFrame(frame)\n    }\n\n    const kick = () => {\n      if (!raf) { last = 0; raf = requestAnimationFrame(frame) }\n    }\n\n    // A change with no move to make of it — reduced motion, out of view, a\n    // rigid spring — is posed before the browser paints, not a frame later.\n    const follow = () => {\n      if (raf) return\n      if (still || !seen || read(live.current).rigid) { last = 0; frame(performance.now()) }\n      else kick()\n    }\n\n    const rebuild = () => {\n      cells = Array.from(root.querySelectorAll(':scope > .folding-panel__facet'))\n      // Every copy but the first is paint. Set here, not in the markup: React\n      // 18 and 19 disagree about how `inert` is written.\n      for (let i = 1; i < cells.length; i++) cells[i].inert = true\n      paint(read(live.current).horizontal)\n    }\n\n    const locate = () => {\n      box = root.getBoundingClientRect()\n      scale = root.offsetWidth ? box.width / root.offsetWidth || 1 : 1\n    }\n\n    const along = (e) => (wide ? e.clientX - box.left : e.clientY - box.top) / scale\n\n    // The crease angle that keeps the paper under a hand at h.\n    const grasp = (h) => sign * Math.acos(clamp(c0 + (h - h0) / lever, floor, 1))\n\n    const ask = (t) => {\n      const tell = live.current.onFoldChange\n      const { top } = read(live.current)\n      if (typeof tell !== 'function' || !top) return\n      const f = clamp(t / top, 0, 1)\n      if (f !== told) { told = f; tell(f) }\n    }\n\n    // Least-squares speed of the hand over the end of the stroke, px/s.\n    const fling = (t) => {\n      const win = trail.filter((q) => q[0] >= t - TRAIL)\n      if (win.length < 2) return 0\n      let mt = 0, mh = 0\n      for (const q of win) { mt += q[0] / win.length; mh += q[1] / win.length }\n      let tt = 0, th = 0\n      for (const q of win) { tt += (q[0] - mt) ** 2; th += (q[0] - mt) * (q[1] - mh) }\n      return tt < 1e-6 ? 0 : clamp((th / tt) * 1000, -FAST, FAST)\n    }\n\n    const onDown = (e) => {\n      const p = read(live.current)\n      if (!live.current.drag || held || (e.pointerType === 'mouse' && e.button !== 0)) return\n      if (e.target.closest?.(CONTROLS)) return\n      wide = p.horizontal\n      const size = wide ? root.offsetWidth : root.offsetHeight\n      if (!(size > 0)) return\n      e.preventDefault()          // the press is the panel's: no selection started from it\n      locate()\n\n      // The fold stops where it is caught, and the hand takes the sheet where\n      // it touched it: that far from the anchor once the fold is undone. Too\n      // near the anchor and a hair's travel would close the panel, so the\n      // hold is never taken to be nearer than REACH, nor past the sheet's end.\n      const pin = an * size\n      h0 = along(e)\n      c0 = Math.cos(x)\n      sign = (x || p.top) < 0 ? -1 : 1\n      floor = Math.cos(Math.max(Math.abs(p.top), Math.abs(x)))\n      lever = (h0 < pin ? -1 : 1)\n        * clamp(Math.abs(h0 - pin) / Math.max(c0, 0.05), REACH * size, Math.max(pin, size - pin))\n\n      held = true\n      taken = false\n      pid = e.pointerId\n      v = 0\n      told = NaN\n      trail = [[e.timeStamp, h0]]\n      try { root.setPointerCapture(pid) } catch { /* capture is a nicety */ }\n      grip(true)\n    }\n\n    const onMove = (e) => {\n      if (!held || e.pointerId !== pid) return\n      const list = e.getCoalescedEvents?.()\n      for (const c of list && list.length ? list : [e]) trail.push([c.timeStamp, along(c)])\n      while (trail.length > 2 && trail[0][0] < e.timeStamp - 200) trail.shift()\n      const h = along(e)\n      if (!taken) {\n        // The pull starts from the edge of the slop, so the paper does not\n        // jump it when it does, and trails the hand by no more than that.\n        if (Math.abs(h - h0) < SLOP) return\n        taken = true\n        h0 += Math.sign(h - h0) * SLOP\n      }\n      const t = grasp(h)\n      if (t === x) return\n      x = t\n      ask(x)\n      kick()\n    }\n\n    const onUp = (e) => {\n      if (!held || (e.pointerId != null && e.pointerId !== pid)) return\n      const up = e.type === 'pointerup'\n      if (up) trail.push([e.timeStamp, along(e)])\n      held = false\n      pid = -1\n      grip(false)\n      try { root.releasePointerCapture(e.pointerId) } catch { /* already gone */ }\n\n      // The paper carries on the way the hand was going. A critically damped\n      // spring let go at speed u, toward a point u·c/k ahead, runs onto it and\n      // stops without turning back, so that is where the hand is taken to\n      // land, and the fold that puts the paper there is the one asked for. Up\n      // against flat or closed there is less room than that, and the rate is\n      // cut to what the room allows — the fold slows into the stop rather\n      // than swinging through it.\n      const p = read(live.current)\n      if (taken) {\n        const h = trail[trail.length - 1][1]\n        const u = up && !still && !p.rigid ? fling(e.timeStamp) : 0\n        const land = grasp(h + (u * p.c) / p.k)\n        const most = Math.sqrt(p.k) * Math.abs(land - x)\n        v = clamp((grasp(h + u * tick) - x) / tick, -most, most)\n        ask(land)\n        if (up) pulled = e.timeStamp\n      } else if (x !== p.to) {\n        ask(x)                    // caught mid-move and let go: it stays where it was caught\n      }\n      kick()\n    }\n\n    // A pull ends in a click, on the panel or on whatever link or card it sits\n    // in. It was not one, and goes no further.\n    const onClick = (e) => {\n      if (e.detail && e.timeStamp - pulled < 60) { e.preventDefault(); e.stopPropagation() }\n      pulled = -1\n    }\n\n    // The browser's own drag, of an image or a link in the artwork, would\n    // take the stroke away mid-pull.\n    const onDragStart = (e) => { if (live.current.drag) e.preventDefault() }\n\n    const onScroll = () => { locate() }\n\n    // The window hears about a hold only while there is one: the scroll that\n    // moves the page under a still hand, and the release, should the capture\n    // have been refused.\n    const grip = (on) => {\n      const act = on ? 'addEventListener' : 'removeEventListener'\n      window[act]('scroll', onScroll, { capture: true, passive: true })\n      window[act]('pointerup', onUp)\n      window[act]('pointercancel', onUp)\n      root.classList.toggle('is-held', on)\n    }\n\n    const onMotion = () => { still = motion.matches; follow() }\n\n    const io = typeof IntersectionObserver === 'undefined'\n      ? null\n      : new IntersectionObserver((entries) => { seen = entries[entries.length - 1].isIntersecting })\n    io?.observe(root)\n\n    root.addEventListener('pointerdown', onDown)\n    root.addEventListener('pointermove', onMove, { passive: true })\n    root.addEventListener('pointerup', onUp)\n    root.addEventListener('pointercancel', onUp)\n    root.addEventListener('lostpointercapture', onUp)\n    root.addEventListener('click', onClick, true)\n    root.addEventListener('dragstart', onDragStart)\n    motion.addEventListener?.('change', onMotion)\n\n    rebuild()\n    api.current = { follow, rebuild }\n\n    return () => {\n      api.current = null\n      io?.disconnect()\n      grip(false)\n      root.removeEventListener('pointerdown', onDown)\n      root.removeEventListener('pointermove', onMove)\n      root.removeEventListener('pointerup', onUp)\n      root.removeEventListener('pointercancel', onUp)\n      root.removeEventListener('lostpointercapture', onUp)\n      root.removeEventListener('click', onClick, true)\n      root.removeEventListener('dragstart', onDragStart)\n      motion.removeEventListener?.('change', onMotion)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [])\n\n  // New facets, or the other axis, are posed where the fold is now, before\n  // they are painted flat; the fold keeps its angle and its rate.\n  useIsomorphicLayoutEffect(() => {\n    api.current?.rebuild()\n  }, [strips, upright])\n\n  // A prop change moves the spring's target; the fold goes there from where\n  // it is, at the rate it has.\n  useIsomorphicLayoutEffect(() => {\n    api.current?.follow()\n  }, [fold, maxAngle, light, ambient, anchor, stiffness, damping])\n\n  const keyed = drag && typeof onFoldChange === 'function'\n\n  const onKeyDown = (e) => {\n    const by = { ArrowRight: STEP, ArrowUp: STEP, ArrowLeft: -STEP, ArrowDown: -STEP, PageUp: LEAP, PageDown: -LEAP }[e.key]\n    const next = e.key === 'Home' ? 0 : e.key === 'End' ? 1 : by ? clamp(Math.round((at + by) * 1e4) / 1e4, 0, 1) : null\n    if (next === null || e.altKey || e.ctrlKey || e.metaKey) return\n    e.preventDefault()\n    if (next !== at) onFoldChange(next)\n  }\n\n  return (\n    <div\n      ref={rootRef}\n      className={`folding-panel folding-panel--${upright ? 'y' : 'x'}${drag ? ' folding-panel--drag' : ''} ${className}`.trim()}\n      style={{ perspective: `${Math.max(1, finite(perspective, 1200))}px`, ...style }}\n      {...rest}\n    >\n      {strips.map((i) => {\n        const [transform, shade] = pose(i, n, first.to, first.anchor, first.light, first.ambient, first.horizontal)\n        return (\n          <div\n            className={`folding-panel__facet${i % 2 ? ' is-odd' : ''}`}\n            key={i}\n            aria-hidden={i ? 'true' : undefined}\n            style={{ '--fp-i': i, '--fp-n': n, '--fp-shade': shade, transform }}\n          >\n            <div className=\"folding-panel__inner\">{children}</div>\n          </div>\n        )\n      })}\n      {keyed ? (\n        <input\n          className=\"folding-panel__range\"\n          type=\"range\"\n          min={0}\n          max={100}\n          step={1}\n          value={Math.round(at * 100)}\n          aria-label={dragLabel}\n          aria-valuetext={`${Math.round(at * 100)}%`}\n          onChange={(e) => onFoldChange(clamp(e.target.valueAsNumber / 100, 0, 1))}\n          onKeyDown={onKeyDown}\n        />\n      ) : null}\n    </div>\n  )\n}\n","type":"registry:component","target":"components/folding-panel/FoldingPanel.jsx"},{"path":"src/components/FoldingPanel/FoldingPanel.css","content":"/* FoldingPanel — perspective lives on the container so every facet shares one\n   vanishing point. Each facet is a window onto the same content, offset by its\n   own index, so the artwork stays continuous across the creases. */\n\n.folding-panel {\n  position: relative;\n  display: block;\n  overflow: hidden;\n  overflow: clip;   /* nothing in here scrolls, so nothing — a focus, a find-in-page — can scroll it */\n  transform-style: preserve-3d;\n}\n\n/* Placed by offset rather than laid out in a row, so the slices keep their\n   order in a right-to-left page: the picture is not text, and does not turn\n   round with it. */\n.folding-panel__facet {\n  --fp-shade: 1;\n  position: absolute;\n  overflow: hidden;\n  overflow: clip;   /* a facet scrolled by a focus inside it would slide its slice out of the picture */\n  /* Two antialiased edges meeting on a crease leave a hairline of the page\n     showing through. Each facet paints a pixel of its own slice past its\n     creases instead, so neighbours overlap in the picture they share. */\n  overflow-clip-margin: 1px;\n  backface-visibility: hidden;\n  will-change: transform;\n}\n\n/* Each facet turns about the crease that stays on the panel's plane: an even\n   facet about its leading edge, an odd one about its trailing edge. The crease\n   between them is the one that falls back. */\n.folding-panel--x .folding-panel__facet {\n  top: 0;\n  left: calc(100% / var(--fp-n) * var(--fp-i));\n  width: calc(100% / var(--fp-n));\n  height: 100%;\n  transform-origin: left center;\n}\n\n.folding-panel--x .folding-panel__facet.is-odd { transform-origin: right center; }\n\n.folding-panel--y .folding-panel__facet {\n  left: 0;\n  top: calc(100% / var(--fp-n) * var(--fp-i));\n  width: 100%;\n  height: calc(100% / var(--fp-n));\n  transform-origin: center top;\n}\n\n.folding-panel--y .folding-panel__facet.is-odd { transform-origin: center bottom; }\n\n/* The content is drawn at full size inside every facet and slid so that each\n   one shows its own slice — that is what keeps the picture continuous. */\n.folding-panel__inner {\n  position: absolute;\n  inset: 0;\n  filter: brightness(var(--fp-shade));\n}\n\n.folding-panel--x .folding-panel__inner {\n  width: calc(100% * var(--fp-n));\n  left: calc(-100% * var(--fp-i));\n}\n\n.folding-panel--y .folding-panel__inner {\n  height: calc(100% * var(--fp-n));\n  top: calc(-100% * var(--fp-i));\n}\n\n/* With `drag` the whole panel is the handle, so the cursor says grab over all\n   of it. The drag takes the fold's own axis and leaves the page the other one\n   and the pinch: a finger moving across the fold scrolls the page as it would\n   anywhere. */\n.folding-panel--drag {\n  cursor: grab;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.folding-panel--drag.folding-panel--x { touch-action: pan-y pinch-zoom; }\n.folding-panel--drag.folding-panel--y { touch-action: pan-x pinch-zoom; }\n\n.folding-panel--drag img { -webkit-user-drag: none; }\n\n.folding-panel.is-held,\n.folding-panel.is-held * { cursor: grabbing; }\n\n/* The drag's keyboard twin: a real slider the size of the panel, unseen and\n   out of the pointer's way. Focused from the keyboard, it rings the panel it\n   folds — the ring is the panel's own outline, since one drawn on the input\n   would be clipped with everything else in here. */\n.folding-panel__range {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  opacity: 0;\n  pointer-events: none;\n}\n\n.folding-panel:has(> .folding-panel__range:focus-visible) {\n  outline: 2px solid currentColor;\n  outline-offset: 4px;\n}\n\n@media (forced-colors: active) {\n  .folding-panel:has(> .folding-panel__range:focus-visible) { outline-color: CanvasText; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .folding-panel__facet { will-change: auto; }\n}\n","type":"registry:file","target":"components/folding-panel/FoldingPanel.css"},{"path":"src/components/FoldingPanel/FoldingPanel.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport interface FoldingPanelProps extends ComponentPropsWithoutRef<'div'> {\n  /** How many facets the panel is creased into, 1–64. */\n  facets?: number\n  /** 0 is flat, 1 is fully closed. A change springs there from where the fold is, at the rate it has; under reduced motion, or out of view, it is simply there. */\n  fold?: number\n  /** Degrees each facet tilts at fold = 1, −90 to 90. The fold falls back from the panel's plane; a negative angle folds it toward the viewer instead. A change springs like a change of fold. */\n  maxAngle?: number\n  /** Shared vanishing point, px. */\n  perspective?: number\n  /** Key light position across the panel, 0..1. A change eases in over ~80 ms. */\n  light?: number\n  /** Floor brightness, 0..1, so a facet turned away is not black. A change eases in over ~80 ms. */\n  ambient?: number\n  /** Fold across the width or down the height. */\n  axis?: 'x' | 'y'\n  /** The point of the panel that stays put as the fold narrows it, 0..1 along the fold: 0 hinges it at the left or top edge, 0.5 closes it on its middle, 1 on the right or bottom. A change eases in over ~80 ms. */\n  anchor?: number\n  /** Spring carrying the crease angle to a new fold or maxAngle, 1/s², at least 1. Infinity — or anything from 100000 up — follows the prop exactly, for a fold that is already animated or scrubbed by its owner. */\n  stiffness?: number\n  /** That spring's damping, 1/s, 1 to 10000. With stiffness k the damping ratio is damping / 2√k: at the defaults exactly 1, so the fold is there in about a third of a second and does not swing past. Less and it rings. */\n  damping?: number\n  /** Let a hand fold it. The paper it takes hold of stays under it, the pull stops at flat and at maxAngle, and on release the fold carries on with the hand's speed. The drag takes the fold's axis and leaves the page the other one and the pinch; text in the panel is no longer selectable. */\n  drag?: boolean\n  /** Called with the fold, 0..1, that a hand or a key is asking for: through a drag, where a released drag will land, and on each key of the keyboard slider. The panel keeps no fold of its own — set `fold` from this, or the paper springs back to `fold` when it is let go. Without it a drag is a pull-and-release and the panel takes no Tab stop. */\n  onFoldChange?: (fold: number) => void\n  /** Accessible name of the slider that stands in for the drag on a keyboard and for a screen reader. It is rendered only with `drag` and `onFoldChange`: arrows move the fold by 0.05, Page Up and Page Down by 0.2, Home and End take it flat and closed. */\n  dragLabel?: string\n  /** Painted once per facet, but real once: the first facet's copy is the one a screen reader reads and Tab reaches, and the rest are aria-hidden and inert. Treat it as artwork. */\n  children?: ReactNode\n}\n\ndeclare const FoldingPanel: (props: FoldingPanelProps) => ReactElement\nexport default FoldingPanel\n","type":"registry:lib","target":"components/folding-panel/FoldingPanel.d.ts"}],"type":"registry:component"}