{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","dependencies":["gsap"],"devDependencies":[],"files":[{"content":"// Formation — data model, tunables, math helpers, layout + pose functions.\n// Pure (no React) so the client component stays focused on wiring + the rAF loop.\n\nexport type FormationMode = \"flat\" | \"tilt\" | \"ring\" | \"gallery\";\n\nexport interface Work {\n  title: string;\n  image: string;\n}\n\nexport interface Pose {\n  x: number;\n  y: number;\n  z: number;\n  rx: number;\n  ry: number;\n  rz: number;\n  s: number;\n  o: number;\n}\n\nexport interface FmLayout {\n  W: number;\n  H: number;\n  /** Card count the ring was solved for. */\n  n: number;\n  mobile: boolean;\n  portrait: boolean;\n  cardW: number;\n  cardH: number;\n  flatScale: number;\n  // Flat ring geometry\n  flatAngles: number[];\n  Rx: number;\n  Ry: number;\n  flatCY: number;\n}\n\nexport const MODES: { id: FormationMode; label: string }[] = [\n  { id: \"flat\", label: \"Flat\" },\n  { id: \"tilt\", label: \"Tilt\" },\n  { id: \"ring\", label: \"Ring\" },\n  { id: \"gallery\", label: \"Gallery\" },\n];\n\n// ── Global tunables ────────────────────────────────────────────────────────\nexport const SPRING = 0.12;\nexport const HOVER_EASE = 0.055;\nexport const HOVER_ZOOM = 0.26;\nexport const PARALLAX_MAX = 5;\nexport const PERSP = 1700;\nexport const MORPH_DUR = 680;\nexport const MORPH_STAGGER = 220;\nexport const SWAP_BAND = 64;\nexport const SWAP_SPEED_REF = 1.1;\nexport const SWAP_FLOOR = 0.6;\n\nconst DEG = Math.PI / 180;\nconst TWO_PI = Math.PI * 2;\n\n// ── Helpers ────────────────────────────────────────────────────────────────\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nexport const lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst wrap = (x: number, p: number) => ((((x + p / 2) % p) + p) % p) - p / 2;\nexport const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);\n\n// Poses are mutated in place — the engine holds one `cur`/`from` per card for\n// the lifetime of the component, so nothing allocates inside the rAF loop.\nexport const copyPose = (dst: Pose, src: Pose): void => {\n  dst.x = src.x;\n  dst.y = src.y;\n  dst.z = src.z;\n  dst.rx = src.rx;\n  dst.ry = src.ry;\n  dst.rz = src.rz;\n  dst.s = src.s;\n  dst.o = src.o;\n};\n\n/** `dst = lerp(a, b, t)`, field by field. `dst` may alias `a` (that's the spring step). */\nexport const lerpPose = (dst: Pose, a: Pose, b: Pose, t: number): void => {\n  dst.x = lerp(a.x, b.x, t);\n  dst.y = lerp(a.y, b.y, t);\n  dst.z = lerp(a.z, b.z, t);\n  dst.rx = lerp(a.rx, b.rx, t);\n  dst.ry = lerp(a.ry, b.ry, t);\n  dst.rz = lerp(a.rz, b.rz, t);\n  dst.s = lerp(a.s, b.s, t);\n  dst.o = lerp(a.o, b.o, t);\n};\n\n/** Lower is \"more focused\": nearest the stage centre, biased toward the front. */\nexport const focusScore = (p: Pose): number => Math.abs(p.x) + Math.abs(p.y) - p.z;\n\nexport const poseTransform = (p: Pose): string =>\n  `translate3d(${p.x}px, ${p.y}px, ${p.z}px) rotateX(${p.rx}deg) rotateY(${p.ry}deg) rotateZ(${p.rz}deg) scale(${p.s})`;\n\n// ── Layout (recomputed on mount + resize) ───────────────────────────────────\n// The flat ring is not evenly angled: card spacing is solved so the *edge* gap\n// between neighbours is constant all the way round the ellipse. Sample the\n// ellipse finely, then bisect on a shared gap G until Σ ds/(fp+G) === n.\nconst buildFlatRing = (\n  W: number,\n  H: number,\n  n: number,\n  cardW: number,\n  cardH: number,\n  flatScale: number,\n  portrait: boolean,\n) => {\n  const flatCY = portrait ? H * 0.015 : H * 0.045;\n  const cw = cardW * flatScale;\n  const ch = cardH * flatScale;\n  const R0 = Math.min(W * 0.3, H * 0.38);\n\n  const Rc = Math.min(W / 2 - cw * 0.5 - ch * 0.12 - 22, H / 2 - ch * 0.5 - flatCY - 22);\n  const Rx = portrait ? Rc : Math.min(R0, W / 2 - cardW * 0.6);\n  const Ry = portrait ? Rc : Math.min(R0, H / 2 - cardH * 0.6);\n\n  const M = 3000;\n  const dphi = TWO_PI / M;\n  const samples = Array.from({ length: M }, (_, k) => {\n    const phi = k * dphi;\n    const sp = Math.sin(phi);\n    const cp = Math.cos(phi);\n    return {\n      ds: Math.sqrt(Rx * Rx * sp * sp + Ry * Ry * cp * cp) * dphi,\n      fp: cw * Math.abs(cp) + ch * Math.abs(sp),\n      phi,\n    };\n  });\n\n  // count(G) falls monotonically as G grows, so plain bisection converges.\n  const count = (G: number) => {\n    let s = 0;\n    for (const sm of samples) {\n      s += sm.ds / (sm.fp + G);\n    }\n    return s;\n  };\n  let lo = -0.85 * Math.min(cw, ch);\n  let hi = Math.max(W, H);\n  for (let it = 0; it < 60; it += 1) {\n    const mid = (lo + hi) / 2;\n    if (count(mid) > n) {\n      lo = mid;\n    } else {\n      hi = mid;\n    }\n  }\n  const G = (lo + hi) / 2;\n\n  // Place card i where the cumulative count first reaches i.\n  const flatAngles: number[] = [];\n  let cum = 0;\n  for (const sm of samples) {\n    if (flatAngles.length >= n) {\n      break;\n    }\n    while (flatAngles.length < n && cum >= flatAngles.length) {\n      flatAngles.push(sm.phi);\n    }\n    cum += sm.ds / (sm.fp + G);\n  }\n  while (flatAngles.length < n) {\n    flatAngles.push((TWO_PI * flatAngles.length) / n);\n  }\n\n  return { Rx, Ry, flatAngles, flatCY };\n};\n\nexport const getLayout = (W: number, H: number, n: number): FmLayout => {\n  const mobile = Math.min(W, H) < 640;\n  const portrait = H > W;\n  const cardW = mobile\n    ? clamp(Math.min(W, H) * 0.27, 80, 118)\n    : clamp(Math.min(W, H) * 0.155, 128, 196);\n  const cardH = Math.round(cardW * 1.34);\n  const flatScale = mobile ? 0.42 : 0.62;\n  const ring = buildFlatRing(W, H, n, cardW, cardH, flatScale, portrait);\n  return { H, W, cardH, cardW, flatScale, mobile, n, portrait, ...ring };\n};\n\n// ── Formations — each a pure function f(i, L, browse) → Pose ─────────────────\nconst flatPose = (i: number, L: FmLayout, browse: number): Pose => {\n  const { n } = L;\n  const slot = (((i + browse * 0.004) % n) + n) % n;\n  const i0 = Math.floor(slot);\n  const i1 = (i0 + 1) % n;\n  const fr = slot - i0;\n  const a0 = L.flatAngles.at(i0) ?? 0;\n  let a1 = L.flatAngles.at(i1) ?? 0;\n  // bridge the 2π seam\n  if (a1 < a0) {\n    a1 += TWO_PI;\n  }\n  const ang = lerp(a0, a1, fr);\n  return {\n    o: 1,\n    rx: 0,\n    ry: 0,\n    rz: Math.sin(i * 3.1 + 1.2) * 7,\n    s: L.flatScale,\n    x: Math.cos(ang) * L.Rx,\n    y: Math.sin(ang) * L.Ry + L.flatCY,\n    z: 0,\n  };\n};\n\nconst tiltPose = (i: number, L: FmLayout, browse: number): Pose => {\n  const { n } = L;\n  const unit = L.cardW * (L.mobile ? 1.12 : 1.5);\n  const x = wrap((i - Math.floor(n / 2)) * unit + browse, n * unit);\n  const Rarc = L.W * (L.mobile ? 1.5 : 1.2);\n  const ax = Math.min(Math.abs(x), Rarc * 0.98);\n  const y = -L.H * 0.05 + (Rarc - Math.sqrt(Rarc * Rarc - ax * ax));\n  const rz = (Math.asin(clamp(x / Rarc, -1, 1)) / DEG) * 0.65;\n  return {\n    o: clamp((0.5 - Math.abs(x) / L.W) / 0.13, 0, 1),\n    rx: 0,\n    ry: 0,\n    rz,\n    s: L.mobile ? 0.92 : 1.18,\n    x,\n    y,\n    z: 0,\n  };\n};\n\nconst ringPose = (i: number, L: FmLayout, browse: number): Pose => {\n  const t = (i / L.n) * TWO_PI + browse * 0.0016;\n  const R = Math.min(L.W, L.H) * 0.38;\n  const lx = Math.sin(t) * R * (L.portrait ? 1.04 : 1.46);\n  const lz = Math.cos(t) * R;\n  const A = 63 * DEG;\n  const y0 = lz * Math.sin(A);\n  const depth = lz * Math.cos(A);\n  const B = -21 * DEG;\n  const x = lx * Math.cos(B) - y0 * Math.sin(B);\n  const y = lx * Math.sin(B) + y0 * Math.cos(B);\n  const k = (lz / R + 1) / 2;\n  return {\n    o: 1,\n    rx: 0,\n    ry: 0,\n    rz: (lx / R) * (L.mobile ? 3 : 6),\n    s: (L.mobile ? 0.36 : 0.6) + k * (L.mobile ? 0.19 : 0.42),\n    x,\n    y,\n    z: depth,\n  };\n};\n\nconst galleryPose = (i: number, L: FmLayout, browse: number): Pose => {\n  const theta = (i / L.n) * TWO_PI + browse * 0.0042;\n  const c = Math.cos(theta);\n  const front = (c + 1) / 2;\n  const baseS = L.mobile ? 0.72 : 1.42;\n  return {\n    o: 1,\n    rx: 0,\n    ry: 0,\n    rz: 0,\n    s: baseS * ((L.mobile ? 0.5 : 0.66) + front * (L.mobile ? 0.5 : 0.34)),\n    x: Math.sin(theta) * L.W * (L.mobile ? 0.46 : 0.43),\n    y: c * L.H * (L.mobile ? 0.16 : 0.14),\n    z: c * (L.mobile ? 95 : 150),\n  };\n};\n\nexport const poseFor = (mode: FormationMode, i: number, L: FmLayout, browse: number): Pose => {\n  if (mode === \"flat\") {\n    return flatPose(i, L, browse);\n  }\n  if (mode === \"tilt\") {\n    return tiltPose(i, L, browse);\n  }\n  if (mode === \"ring\") {\n    return ringPose(i, L, browse);\n  }\n  return galleryPose(i, L, browse);\n};\n","path":"/formation-poses.ts","target":"uicapsule/formation/formation-poses.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\nimport type { FmLayout, FormationMode, Pose, Work } from \"./formation-poses\";\nimport {\n  clamp,\n  copyPose,\n  easeInOut,\n  focusScore,\n  getLayout,\n  HOVER_EASE,\n  HOVER_ZOOM,\n  lerpPose,\n  MODES,\n  MORPH_DUR,\n  MORPH_STAGGER,\n  PARALLAX_MAX,\n  PERSP,\n  poseFor,\n  poseTransform,\n  SPRING,\n  SWAP_BAND,\n  SWAP_FLOOR,\n  SWAP_SPEED_REF,\n} from \"./formation-poses\";\n\n// Name real font stacks rather than leaning on `font-sans` / `font-mono`\n// utilities, which resolve to undefined vars (and therefore serif) in a bare\n// preview frame.\nconst SANS =\n  'ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif';\nconst MONO = 'ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace';\n\ninterface CustomCSS extends CSSProperties {\n  [key: `--${string}`]: string | number | undefined;\n}\n\n/** Per-card mutable engine state. One object replaces six parallel arrays. */\ninterface CardState {\n  index: number;\n  work: Work;\n  /** Pose written to the DOM this frame. */\n  cur: Pose;\n  /** Pose snapshot captured when a morph begins. */\n  from: Pose;\n  /** Eased hover amount, 0..1, published as `--hv`. */\n  hov: number;\n  /** Eased depth-swap cross-fade amount, 0..1. */\n  swap: number;\n  prevZ: number;\n  outer: HTMLDivElement | null;\n  inner: HTMLDivElement | null;\n}\n\ninterface LoopState {\n  raf: number;\n  lastTime: number;\n  onScreen: boolean;\n  visible: boolean;\n  reduced: boolean;\n  browse: number;\n  vel: number;\n  morphing: boolean;\n  morphMs: number;\n  seeded: boolean;\n  hoverCard: CardState | null;\n  lastFocused: CardState | null;\n  curTX: number;\n  curTY: number;\n  /** Pointer position, root-relative. */\n  cursor: { x: number; y: number; inside: boolean };\n  /**\n   * The pointer currently being tracked, if any. `committed` means it has moved\n   * past the tap slop and is now scrubbing — i.e. it *is* the drag state, so\n   * there is no separate `dragging` flag to fall out of sync with it.\n   */\n  press: { x: number; y: number; id: number; committed: boolean } | null;\n  /** Previous pointer x, root-relative — the scrub delta is measured against it. */\n  lastX: number;\n}\n\nconst zeroPose = (): Pose => ({\n  o: 0,\n  rx: 0,\n  ry: 0,\n  rz: 0,\n  s: 1,\n  x: 0,\n  y: 0,\n  z: 0,\n});\n\nconst createState = (): LoopState => ({\n  browse: 0,\n  curTX: 0,\n  curTY: 0,\n  cursor: { inside: false, x: 0, y: 0 },\n  hoverCard: null,\n  lastFocused: null,\n  lastTime: 0,\n  lastX: 0,\n  morphMs: 0,\n  morphing: false,\n  onScreen: true,\n  press: null,\n  raf: 0,\n  reduced: false,\n  seeded: false,\n  vel: 0,\n  visible: true,\n});\n\nconst makeCards = (works: Work[]): CardState[] =>\n  works.map((work, index) => ({\n    cur: zeroPose(),\n    from: zeroPose(),\n    hov: 0,\n    index,\n    inner: null,\n    outer: null,\n    prevZ: 0,\n    swap: 0,\n    work,\n  }));\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n\n/** Scrubbing is exactly \"a tracked pointer that has passed the tap slop\". */\nconst isDragging = (s: LoopState) => s.press?.committed === true;\n\nconst isUI = (target: EventTarget | null) =>\n  target instanceof Element && target.closest(\"[data-fm-ui]\") !== null;\n\ninterface FormationProps {\n  works: Work[];\n}\n\nexport const Formation = ({ works }: FormationProps): ReactNode => {\n  const [mode, setMode] = useState<FormationMode>(\"flat\");\n\n  const rootRef = useRef<HTMLElement | null>(null);\n  const parallaxRef = useRef<HTMLDivElement | null>(null);\n  const counterRef = useRef<HTMLSpanElement | null>(null);\n\n  // Mutable engine state (never triggers a re-render)\n  const sRef = useRef<LoopState | null>(null);\n  if (!sRef.current) {\n    sRef.current = createState();\n  }\n  const S = sRef.current;\n\n  // Per-card state, rebuilt only when the `works` array itself changes.\n  const worksRef = useRef<Work[] | null>(null);\n  const cardsRef = useRef<CardState[]>([]);\n  if (worksRef.current !== works) {\n    worksRef.current = works;\n    cardsRef.current = makeCards(works);\n  }\n  const cards = cardsRef.current;\n  const n = cards.length;\n\n  const layoutRef = useRef<FmLayout | null>(null);\n  /** Root's live client box — pointer coords are converted against it. */\n  const boxRef = useRef({ h: 0, left: 0, top: 0, w: 0 });\n  const modeRef = useRef<FormationMode>(\"flat\");\n  const firstMode = useRef(true);\n  const renderStaticRef = useRef<() => void>(() => {\n    /* empty */\n  });\n\n  // ── Geometry helpers (read refs only — safe to capture once) ─────────────\n  const applyCardSizes = () => {\n    const L = layoutRef.current;\n    if (!L) {\n      return;\n    }\n    for (const card of cards) {\n      const { outer } = card;\n      if (!outer) {\n        continue;\n      }\n      outer.style.width = `${L.cardW}px`;\n      outer.style.height = `${L.cardH}px`;\n      outer.style.marginLeft = `${-L.cardW / 2}px`;\n      outer.style.marginTop = `${-L.cardH / 2}px`;\n    }\n  };\n\n  /** Painter's-algorithm hit test in root-relative space; highest z wins. */\n  const hoverHit = (px: number, py: number) => {\n    const box = boxRef.current;\n    const inRect = (el: HTMLDivElement) => {\n      const r = el.getBoundingClientRect();\n      const l = r.left - box.left;\n      const t = r.top - box.top;\n      return px >= l && px <= l + r.width && py >= t && py <= t + r.height;\n    };\n    const stickyCard = S.hoverCard;\n    // Bias toward whatever is already hovered so a hairline overlap can't flicker.\n    if (stickyCard && stickyCard.cur.o >= 0.5 && stickyCard.outer && inRect(stickyCard.outer)) {\n      return stickyCard;\n    }\n    let best: CardState | null = null;\n    let bestZ = -Infinity;\n    for (const card of cards) {\n      if (card.cur.o < 0.5) {\n        continue;\n      }\n      const el = card.outer;\n      if (!el) {\n        continue;\n      }\n      if (inRect(el) && card.cur.z > bestZ) {\n        bestZ = card.cur.z;\n        best = card;\n      }\n    }\n    return best;\n  };\n\n  const renderStatic = () => {\n    const L = layoutRef.current;\n    if (!L) {\n      return;\n    }\n    const m = modeRef.current;\n    let focused: CardState | null = null;\n    let best = Infinity;\n    for (const card of cards) {\n      const p = poseFor(m, card.index, L, 0);\n      copyPose(card.cur, p);\n      if (card.outer) {\n        card.outer.style.transform = poseTransform(p);\n        card.outer.style.opacity = String(p.o);\n      }\n      card.inner?.style.setProperty(\"--hv\", \"0\");\n      const score = focusScore(p);\n      if (score < best) {\n        best = score;\n        focused = card;\n      }\n    }\n    if (parallaxRef.current) {\n      parallaxRef.current.style.transform = \"\";\n    }\n    if (counterRef.current && focused) {\n      counterRef.current.textContent = `${pad(focused.index + 1)} — ${pad(n)}`;\n    }\n  };\n\n  // ── Pointer input (React handlers → latest closures) ─────────────────────\n  // Every handler is gated on the tracked `pointerId`: on touch, a second\n  // contact landing mid-swipe must not hijack the drag.\n  const onPointerDown = (e: ReactPointerEvent<HTMLElement>) => {\n    if (isUI(e.target)) {\n      return;\n    }\n    if (S.press) {\n      return;\n    }\n    const box = boxRef.current;\n    const lx = e.clientX - box.left;\n    const ly = e.clientY - box.top;\n    S.cursor.x = lx;\n    S.cursor.y = ly;\n    S.cursor.inside = true;\n    S.lastX = lx;\n    S.press = { committed: false, id: e.pointerId, x: lx, y: ly };\n  };\n\n  const onPointerMove = (e: ReactPointerEvent<HTMLElement>) => {\n    const { press } = S;\n    if (press && press.id !== e.pointerId) {\n      return;\n    }\n    const box = boxRef.current;\n    const lx = e.clientX - box.left;\n    const ly = e.clientY - box.top;\n    S.cursor.x = lx;\n    S.cursor.y = ly;\n    S.cursor.inside = true;\n    if (press && !S.morphing) {\n      if (!press.committed) {\n        const dist = Math.hypot(lx - press.x, ly - press.y);\n        // 8px of slop so a jittery tap on touch doesn't nudge the carousel.\n        if (dist > 8) {\n          press.committed = true;\n          try {\n            rootRef.current?.setPointerCapture(press.id);\n          } catch {\n            /* noop */\n          }\n        }\n      }\n      if (press.committed) {\n        const gain = modeRef.current === \"flat\" || modeRef.current === \"ring\" ? 1.4 : 1;\n        const d = (lx - S.lastX) * gain;\n        S.browse += d;\n        S.vel = d;\n      }\n    }\n    S.lastX = lx;\n  };\n\n  const endPress = (e: ReactPointerEvent<HTMLElement>) => {\n    const { press } = S;\n    if (!press || press.id !== e.pointerId) {\n      return;\n    }\n    const root = rootRef.current;\n    if (root?.hasPointerCapture(press.id)) {\n      root.releasePointerCapture(press.id);\n    }\n    S.press = null;\n  };\n\n  const onPointerLeave = () => {\n    // An uncommitted press can be released outside the root — no capture has\n    // been taken yet, so its `pointerup` lands elsewhere and never reaches us.\n    // Dropping it here is what stops the next re-entry from resuming a drag\n    // with no button held.\n    if (S.press && !S.press.committed) {\n      S.press = null;\n    }\n    if (isDragging(S)) {\n      return;\n    }\n    S.cursor.inside = false;\n    S.hoverCard = null;\n  };\n\n  // Keep the mode effect's reduced-motion path on the latest closure.\n  useEffect(() => {\n    renderStaticRef.current = renderStatic;\n  });\n\n  // ── Mount: layout, engine loop, lifecycle ────────────────────────────────\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) {\n      return;\n    }\n    const st = S;\n\n    st.reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const box = boxRef.current;\n\n    const measure = () => {\n      const r = root.getBoundingClientRect();\n      box.left = r.left;\n      box.top = r.top;\n      // A freshly mounted iframe reports 0x0 on the first ResizeObserver tick.\n      if (r.width < 1 || r.height < 1) {\n        return false;\n      }\n      const w = Math.round(r.width);\n      const h = Math.round(r.height);\n      const changed = w !== box.w || h !== box.h;\n      box.w = w;\n      box.h = h;\n      return changed;\n    };\n\n    // buildFlatRing is ~180k iterations; only pay it when the box really resizes.\n    const relayout = () => {\n      if (!measure()) {\n        return;\n      }\n      layoutRef.current = getLayout(box.w, box.h, n);\n      // Seed (or re-seed) the virtual cursor at centre so the parallax rests\n      // neutral. The first measure inside a fresh iframe can be 0x0, so this has\n      // to live here rather than after a single relayout() call.\n      if (!st.cursor.inside) {\n        st.cursor.x = box.w / 2;\n        st.cursor.y = box.h / 2;\n      }\n      applyCardSizes();\n      if (st.reduced) {\n        renderStatic();\n      }\n    };\n\n    // Seeds the layout and, under reduced motion, paints the one static frame.\n    relayout();\n\n    const updateCounter = () => {\n      let focused = st.hoverCard;\n      if (!focused) {\n        let best = Infinity;\n        for (const card of cards) {\n          const score = focusScore(card.cur);\n          if (score < best) {\n            best = score;\n            focused = card;\n          }\n        }\n      }\n      if (focused && focused !== st.lastFocused) {\n        st.lastFocused = focused;\n        if (counterRef.current) {\n          counterRef.current.textContent = `${pad(focused.index + 1)} — ${pad(n)}`;\n        }\n      }\n    };\n\n    const staggerDenom = Math.max(1, n - 1);\n\n    const advancePoses = (L: FmLayout, mode2: FormationMode, dt: number) => {\n      if (st.morphing) {\n        st.morphMs += dt;\n        let allDone = true;\n        for (const card of cards) {\n          const p = clamp(\n            (st.morphMs - (MORPH_STAGGER * card.index) / staggerDenom) / MORPH_DUR,\n            0,\n            1,\n          );\n          if (p < 1) {\n            allDone = false;\n          }\n          lerpPose(card.cur, card.from, poseFor(mode2, card.index, L, 0), easeInOut(p));\n        }\n        if (allDone) {\n          st.morphing = false;\n        }\n        return;\n      }\n      for (const card of cards) {\n        const t2 = poseFor(mode2, card.index, L, st.browse);\n        const { cur } = card;\n        // Snap on the first frame, and across tilt mode's wrap seam.\n        if (!st.seeded || (mode2 === \"tilt\" && Math.abs(t2.x - cur.x) > L.W)) {\n          copyPose(cur, t2);\n        } else {\n          lerpPose(cur, cur, t2, SPRING);\n        }\n      }\n      st.seeded = true;\n    };\n\n    // Depth-swap cross-fade target: how hard this card is crossing another in z.\n    const swapTarget = (card: CardState, L: FmLayout) => {\n      let tgt = 0;\n      const a = card.cur;\n      for (const other of cards) {\n        if (other === card) {\n          continue;\n        }\n        const b = other.cur;\n        if (\n          Math.abs(a.x - b.x) < (L.cardW * a.s + L.cardW * b.s) / 2 &&\n          Math.abs(a.y - b.y) < (L.cardH * a.s + L.cardH * b.s) / 2\n        ) {\n          const gapNow = a.z - b.z;\n          const prox = Math.max(0, 1 - Math.abs(gapNow) / SWAP_BAND);\n          const gapPrev = card.prevZ - other.prevZ;\n          const cross = Math.min(1, Math.abs(gapNow - gapPrev) / SWAP_SPEED_REF);\n          const v = prox * cross;\n          if (v > tgt) {\n            tgt = v;\n          }\n        }\n      }\n      return tgt;\n    };\n\n    const frame = (now: number) => {\n      const L = layoutRef.current;\n      if (!L) {\n        st.raf = requestAnimationFrame(frame);\n        return;\n      }\n      const dt = Math.min(50, now - (st.lastTime || now));\n      st.lastTime = now;\n      const mode2 = modeRef.current;\n      const dragging = isDragging(st);\n\n      // Measure first, then write. `hoverHit` reads every card's client rect, so\n      // any style write before it forces a synchronous layout every frame.\n      // Refresh the root's origin so hit-testing survives the page moving.\n      const rr = root.getBoundingClientRect();\n      box.left = rr.left;\n      box.top = rr.top;\n\n      if (dragging || st.morphing) {\n        st.hoverCard = null;\n      } else if (st.cursor.inside) {\n        st.hoverCard = hoverHit(st.cursor.x, st.cursor.y);\n      }\n\n      root.style.cursor = dragging ? \"grabbing\" : \"grab\";\n\n      // Scrub momentum\n      if (!dragging && !st.morphing) {\n        st.browse += st.vel;\n        st.vel *= 0.92;\n        if (Math.abs(st.vel) < 0.02) {\n          st.vel = 0;\n        }\n      }\n\n      // Parallax lean\n      const ty = (st.cursor.x / L.W - 0.5) * PARALLAX_MAX;\n      const tx = (0.5 - st.cursor.y / L.H) * PARALLAX_MAX;\n      st.curTX += (tx - st.curTX) * 0.06;\n      st.curTY += (ty - st.curTY) * 0.06;\n      if (parallaxRef.current) {\n        parallaxRef.current.style.transform = `rotateX(${st.curTX}deg) rotateY(${st.curTY}deg)`;\n      }\n\n      advancePoses(L, mode2, dt);\n\n      // Hover ease → --hv\n      for (const card of cards) {\n        card.hov += ((card === st.hoverCard ? 1 : 0) - card.hov) * HOVER_EASE;\n      }\n\n      // Depth-swap cross-fade (second pass — all poses final)\n      for (const card of cards) {\n        card.swap += (swapTarget(card, L) - card.swap) * 0.3;\n      }\n\n      // Write\n      for (const card of cards) {\n        const { cur } = card;\n        if (card.outer) {\n          card.outer.style.transform = poseTransform(cur);\n          card.outer.style.opacity = String(cur.o * (1 - card.swap * (1 - SWAP_FLOOR)));\n        }\n        card.inner?.style.setProperty(\"--hv\", String(card.hov));\n        card.prevZ = cur.z;\n      }\n\n      updateCounter();\n      st.raf = requestAnimationFrame(frame);\n    };\n\n    const start = () => {\n      if (!st.raf && !st.reduced) {\n        st.lastTime = 0;\n        st.raf = requestAnimationFrame(frame);\n      }\n    };\n    const stop = () => {\n      if (st.raf) {\n        cancelAnimationFrame(st.raf);\n        st.raf = 0;\n      }\n    };\n    const evalRun = () => {\n      if (st.onScreen && st.visible) {\n        start();\n      } else {\n        stop();\n      }\n    };\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const [entry] = entries;\n        if (!entry) {\n          return;\n        }\n        st.onScreen = entry.isIntersecting;\n        evalRun();\n      },\n      { threshold: 0 },\n    );\n    io.observe(root);\n\n    const onVis = () => {\n      st.visible = document.visibilityState === \"visible\";\n      evalRun();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const ro = new ResizeObserver(() => relayout());\n    ro.observe(root);\n    window.addEventListener(\"resize\", relayout);\n\n    const onWheel = (e: WheelEvent) => {\n      if (isUI(e.target)) {\n        return;\n      }\n      if (st.reduced) {\n        return;\n      }\n      e.preventDefault();\n      if (st.morphing) {\n        return;\n      }\n      const gain = modeRef.current === \"flat\" || modeRef.current === \"ring\" ? 0.6 : 0.8;\n      const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;\n      const impulse = -delta * gain;\n      st.browse += impulse;\n      st.vel = impulse * 0.25;\n    };\n    root.addEventListener(\"wheel\", onWheel, { passive: false });\n\n    if (!st.reduced) {\n      start();\n    }\n\n    return () => {\n      stop();\n      io.disconnect();\n      ro.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      window.removeEventListener(\"resize\", relayout);\n      root.removeEventListener(\"wheel\", onWheel);\n      // Let a StrictMode remount re-seed poses from scratch, and re-arm the\n      // \"first mode\" short-circuit so the remount doesn't morph from a zero pose.\n      st.seeded = false;\n      firstMode.current = true;\n      box.w = 0;\n      box.h = 0;\n    };\n    // applyCardSizes / hoverHit / renderStatic close over refs and `cards` only,\n    // so re-running the engine for them would tear down the loop for nothing.\n    // oxlint-disable-next-line react/rule-suppression -- the component reads its engine refs during render by design; letting the compiler in surfaces ~20 refs/immutability errors that need a state-model rewrite, not a deps fix\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [cards, n, S]);\n\n  // Entrance bloom (the card inners), kicked off on fonts.ready.\n  useEffect(() => {\n    const inners = cards.map((c) => c.inner).filter((el): el is HTMLDivElement => el !== null);\n    if (!inners.length) {\n      return;\n    }\n    if (S.reduced) {\n      gsap.set(inners, { filter: \"none\", opacity: 1, scale: 1, yPercent: 0 });\n      return;\n    }\n    gsap.set(inners, {\n      filter: \"blur(10px)\",\n      opacity: 0,\n      scale: 0.7,\n      yPercent: 8,\n    });\n    let cancelled = false;\n    let tween: gsap.core.Tween | null = null;\n    const play = () => {\n      if (cancelled) {\n        return;\n      }\n      tween = gsap.to(inners, {\n        delay: 0.1,\n        duration: 1,\n        ease: \"power4.out\",\n        filter: \"blur(0px)\",\n        opacity: 1,\n        scale: 1,\n        stagger: { each: 0.035, from: \"edges\" },\n        yPercent: 0,\n      });\n    };\n    const playWhenReady = async () => {\n      await document.fonts.ready;\n      play();\n    };\n    void playWhenReady();\n    return () => {\n      cancelled = true;\n      tween?.kill();\n    };\n  }, [cards, S]);\n\n  // Mode change → begin the morph (or static re-render under reduced motion).\n  useEffect(() => {\n    modeRef.current = mode;\n    if (firstMode.current) {\n      firstMode.current = false;\n      return;\n    }\n    if (S.reduced) {\n      renderStaticRef.current();\n      return;\n    }\n    for (const card of cards) {\n      copyPose(card.from, card.cur);\n    }\n    S.browse = 0;\n    S.vel = 0;\n    S.morphing = true;\n    S.morphMs = 0;\n    // No cleanup here: this effect re-runs on every mode change, so resetting\n    // `firstMode` from a cleanup would swallow every other morph. The reset\n    // lives in the engine effect's cleanup, which only runs on unmount.\n  }, [mode, cards, S]);\n\n  const stageStyle: CustomCSS = {\n    \"--fm-bg\": \"#0a0a0a\",\n    \"--fm-fg\": \"#fafafa\",\n    background: \"linear-gradient(180deg, #121215 0%, #09090b 100%)\",\n    color: \"rgba(255,255,255,0.92)\",\n    fontFamily: SANS,\n    touchAction: \"pan-y\",\n  };\n\n  return (\n    <section\n      ref={rootRef}\n      className=\"relative h-full w-full select-none overflow-hidden\"\n      style={stageStyle}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endPress}\n      onPointerCancel={endPress}\n      onPointerLeave={onPointerLeave}\n    >\n      <div\n        className=\"absolute inset-0\"\n        style={{ perspective: `${PERSP}px`, perspectiveOrigin: \"50% 50%\" }}\n      >\n        <div\n          ref={parallaxRef}\n          className=\"absolute inset-0\"\n          style={{ transformStyle: \"preserve-3d\" }}\n        >\n          {cards.map((card) => (\n            <div\n              key={card.index}\n              ref={(el) => {\n                card.outer = el;\n              }}\n              // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the card is a composite of DOM children that <img> cannot hold; role=\"img\" deliberately presents it as one labelled picture\n              role=\"img\"\n              aria-label={card.work.title}\n              className=\"absolute left-1/2 top-1/2\"\n              style={{ opacity: 0, transformStyle: \"preserve-3d\" }}\n            >\n              {/* Outer is driven by the rAF loop, inner by GSAP. Keeping them\n                  separate is what stops the two systems fighting. */}\n              <div\n                ref={(el) => {\n                  card.inner = el;\n                }}\n                className=\"absolute inset-0 overflow-hidden\"\n                style={{\n                  borderRadius: 12,\n                  boxShadow: \"0 16px 40px -16px rgba(0,0,0,0.55)\",\n                  opacity: 0,\n                }}\n              >\n                <div\n                  className=\"absolute inset-0 overflow-hidden\"\n                  style={{\n                    borderRadius: 12,\n                    transform: `scale(calc(1 + ${HOVER_ZOOM} * var(--hv, 0)))`,\n                  }}\n                >\n                  {/* A background image, not <img>: no native drag ghost to fight. */}\n                  <div\n                    className=\"absolute inset-0\"\n                    style={{\n                      backgroundImage: `url(${card.work.image})`,\n                      backgroundPosition: \"center\",\n                      backgroundSize: \"cover\",\n                      borderRadius: 12,\n                      filter: \"saturate(0.98) contrast(1.03)\",\n                    }}\n                  />\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      {/* Focus counter */}\n      <footer\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 z-40 flex items-end justify-end p-5 sm:px-8\"\n        style={{ color: \"var(--fm-fg)\" }}\n      >\n        <span\n          ref={counterRef}\n          className=\"hidden uppercase sm:block\"\n          style={{\n            fontFamily: MONO,\n            fontSize: \"0.64rem\",\n            fontVariantNumeric: \"tabular-nums\",\n            letterSpacing: \"0.2em\",\n            opacity: 0.5,\n          }}\n        >\n          {`01 — ${pad(n)}`}\n        </span>\n      </footer>\n\n      {/* Formation dock */}\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-6 sm:inset-x-auto sm:bottom-auto sm:right-0 sm:top-5 sm:justify-end sm:px-0 sm:pr-6\">\n        <div\n          role=\"tablist\"\n          data-fm-ui\n          className=\"pointer-events-auto flex gap-1 rounded-full p-1\"\n          style={{\n            WebkitBackdropFilter: \"blur(12px)\",\n            backdropFilter: \"blur(12px)\",\n            background: \"color-mix(in srgb, var(--fm-bg) 72%, transparent)\",\n            border: \"1px solid color-mix(in srgb, var(--fm-fg) 12%, transparent)\",\n            boxShadow: \"0 14px 40px -20px rgba(0,0,0,0.5)\",\n          }}\n        >\n          {MODES.map((m) => {\n            const active = mode === m.id;\n            return (\n              <button\n                key={m.id}\n                role=\"tab\"\n                type=\"button\"\n                aria-selected={active}\n                onClick={() => setMode(m.id)}\n                className=\"rounded-full transition-colors\"\n                style={{\n                  background: active ? \"var(--fm-fg)\" : \"transparent\",\n                  border: \"1px solid transparent\",\n                  color: active ? \"var(--fm-bg)\" : \"var(--fm-fg)\",\n                  fontFamily: SANS,\n                  fontSize: \"0.8rem\",\n                  fontWeight: 500,\n                  padding: \"6px 14px\",\n                }}\n              >\n                {m.label}\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    </section>\n  );\n};\n","path":"/formation.tsx","target":"uicapsule/formation/formation.tsx","type":"registry:file"},{"content":"{\n  \"name\": \"@uicapsule/formation\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"clean\": \"git clean -xdf .cache .turbo dist node_modules\"\n  },\n  \"dependencies\": {\n    \"gsap\": \"^3.15.0\",\n    \"react\": \"catalog:\",\n    \"react-dom\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"catalog:\",\n    \"@types/react-dom\": \"catalog:\"\n  }\n}\n","path":"/package.json","target":"uicapsule/formation/package.json","type":"registry:file"},{"content":"\"use client\";\n\nimport type { Work } from \"./formation-poses\";\nimport { Formation } from \"./formation\";\n\nconst rootUrl = \"https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/formation\";\n\nconst works: Work[] = [\n  {\n    image: `${rootUrl}/vista.jpg`,\n    title: \"Vantage\",\n  },\n  {\n    image: `${rootUrl}/mirror.jpg`,\n    title: \"Mirror\",\n  },\n  {\n    image: `${rootUrl}/cosmos.jpg`,\n    title: \"Cosmos\",\n  },\n  {\n    image: `${rootUrl}/current.jpg`,\n    title: \"Current\",\n  },\n  {\n    image: `${rootUrl}/portal.jpg`,\n    title: \"Threshold\",\n  },\n  {\n    image: `${rootUrl}/valley.jpg`,\n    title: \"Hollow\",\n  },\n  {\n    image: `${rootUrl}/ascent.jpg`,\n    title: \"Ascent\",\n  },\n  {\n    image: `${rootUrl}/array.jpg`,\n    title: \"Array\",\n  },\n  {\n    image: `${rootUrl}/giza.jpg`,\n    title: \"Meridian\",\n  },\n  {\n    image: `${rootUrl}/rift.jpg`,\n    title: \"Rift\",\n  },\n  {\n    image: `${rootUrl}/overlook.jpg`,\n    title: \"Overlook\",\n  },\n  {\n    image: `${rootUrl}/horizon.jpg`,\n    title: \"Event Horizon\",\n  },\n  {\n    image: `${rootUrl}/archipelago.jpg`,\n    title: \"Archipelago\",\n  },\n  {\n    image: `${rootUrl}/crest.jpg`,\n    title: \"Crest\",\n  },\n  {\n    image: `${rootUrl}/ridge.jpg`,\n    title: \"Ridge\",\n  },\n  {\n    image: `${rootUrl}/fathom.jpg`,\n    title: \"Fathom\",\n  },\n];\n\nconst Preview = () => <Formation works={works} />;\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/formation/preview.tsx","type":"registry:file"}],"homepage":"https://uicapsule.com/ui/formation","name":"formation","registryDependencies":[],"type":"registry:block"}