{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids"],"dependencies":["gsap","lucide-react"],"description":"A camera drifts forever across an edgeless field of fashion figures — scroll to zoom at the cursor, drag to fling, click one and it flies in while every other card dims and a glass panel slides out.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport type { CSSProperties, RefObject } from \"react\";\nimport { Check, ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nimport type { Look } from \"./lookbook-data\";\nimport { LookFigure } from \"./look-figure\";\nimport { fmtPrice, lookTotal } from \"./lookbook-data\";\n\n/**\n * Below this container width the panel always drops underneath the focused\n * card instead of sitting beside it. It is a floor, not the whole rule —\n * `usesSideLayout` is what both this panel and the host actually branch on.\n */\nexport const NARROW_BREAKPOINT = 920;\n\n/** Gap the beside-card layout wants between the card and the panel. */\nconst PANEL_GAP = 40;\n/** Smallest gap the beside-card layout will accept before giving up on it. */\nconst PANEL_MIN_GAP = 16;\n/** Space kept between the panel and the right edge of the frame. */\nconst PANEL_EDGE = 16;\n\nconst panelWidth = (containerW: number): number => Math.min(420, containerW * 0.36);\n\n/**\n * Right edge of the focused card in the beside-card layout: it lands centred\n * on 0.34w and renders 0.75h tall, so it is 0.25h wide either side.\n */\nconst focusedCardRight = (containerW: number, containerH: number): number =>\n  0.34 * containerW + 0.25 * containerH;\n\n/**\n * Whether the panel sits beside the focused card rather than beneath it.\n *\n * Width alone is not enough. The focused card is sized off the container\n * HEIGHT, so a tall-narrow desktop frame keeps growing it until the panel no\n * longer fits alongside — at which point the right-edge clamp below drags the\n * panel back on top of the card it describes (measured: 15px of overlap at\n * 920x1100). Falling back to the sheet layout is only safe because the host\n * reads this same predicate to choose where to fly the camera; the two must\n * never disagree about which layout is in play.\n */\nexport const usesSideLayout = (containerW: number, containerH: number): boolean => {\n  if (containerW < NARROW_BREAKPOINT) {\n    return false;\n  }\n  const rightEdge =\n    focusedCardRight(containerW, containerH) + PANEL_MIN_GAP + panelWidth(containerW) + PANEL_EDGE;\n  return rightEdge <= containerW;\n};\n\nconst SLOT_TAG = { lower: \"B\", shoes: \"C\", top: \"A\" } as const;\n\n/** Utilities-layer scrollbar suppression — the app's base layer styles one in. */\nconst NO_SCROLLBAR =\n  \"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden\";\n\nexport interface DetailPanelProps {\n  look: Look;\n  /** Measured container box — never `window`, so the panel matches the frame. */\n  containerW: number;\n  containerH: number;\n  /** The glass card itself — the host choreographs its entrance and exit. */\n  ref: RefObject<HTMLDivElement | null>;\n}\n\nexport const DetailPanel = ({ look, containerW, containerH, ref }: DetailPanelProps) => {\n  const [activeIdx, setActiveIdx] = useState(0);\n  const [bag, setBag] = useState<ReadonlySet<string>>(new Set<string>());\n\n  const narrow = !usesSideLayout(containerW, containerH);\n\n  const firstPaint = useRef(true);\n  // Hide before first paint so the open animation never flashes, and so\n  // re-renders (thumbnail switches) can't reset opacity mid-view.\n  useLayoutEffect(() => {\n    if (firstPaint.current && ref.current) {\n      ref.current.style.opacity = \"0\";\n    }\n    firstPaint.current = false;\n    return () => {\n      firstPaint.current = true;\n    };\n  }, [ref]);\n\n  // Already ordered top -> lower -> shoes by the tuple's own type.\n  const orderedItems = look.items;\n  const activeItem = orderedItems[activeIdx] ?? orderedItems[0];\n  const isActiveAdded = bag.has(activeItem.id);\n  const itemsCount = orderedItems.length;\n  const tag = SLOT_TAG[activeItem.slot];\n\n  const toggle = (itemId: string) => {\n    setBag((prev) => {\n      const next = new Set(prev);\n      if (next.has(itemId)) {\n        next.delete(itemId);\n      } else {\n        next.add(itemId);\n      }\n      return next;\n    });\n  };\n  const prevItem = () => {\n    setActiveIdx((i) => (i - 1 + itemsCount) % itemsCount);\n  };\n  const nextItem = () => {\n    setActiveIdx((i) => (i + 1) % itemsCount);\n  };\n\n  const panelW = panelWidth(containerW);\n  // Start PANEL_GAP past the card's right edge and never run off the frame.\n  // `usesSideLayout` has already guaranteed the clamp cannot pull this back\n  // over the card — if it could, we would not be in this layout.\n  const panelLeft = Math.min(\n    focusedCardRight(containerW, containerH) + PANEL_GAP,\n    containerW - panelW - PANEL_EDGE,\n  );\n  // In the narrow layout the card sits at 0.2h + 28 and is 0.18h tall either\n  // side, so 0.38h + 38 clears its bottom edge.\n  const narrowTop = Math.round(0.38 * containerH + 38);\n\n  const wrapperStyle: CSSProperties = narrow\n    ? { bottom: 8, left: 8, right: 8, top: narrowTop }\n    : {\n        left: Math.round(panelLeft),\n        top: \"50%\",\n        transform: \"translateY(-50%)\",\n        width: Math.round(panelW),\n      };\n\n  const maxCardHeight = Math.round(containerH * 0.92);\n  // The narrow sheet only owns ~0.6h, and the hero competes with the info,\n  // the add button and the total for it — keep it small enough that the\n  // primary action stays above the fold on a short frame.\n  const heroHeight = narrow\n    ? Math.round(Math.min(containerH * 0.2, containerW * 0.45))\n    : Math.round(containerH * 0.44);\n\n  const cardStyle: CSSProperties = {\n    WebkitBackdropFilter: \"blur(40px) saturate(180%)\",\n    backdropFilter: \"blur(40px) saturate(180%)\",\n    background:\n      \"linear-gradient(135deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0.08) 55%, rgba(255,255,255,0.14) 100%)\",\n    border: \"1px solid rgba(255,255,255,0.28)\",\n    boxShadow: [\n      \"inset 0 1px 0 rgba(255,255,255,0.55)\",\n      \"inset 0 0 0 1px rgba(255,255,255,0.08)\",\n      \"inset 0 -1px 0 rgba(0,0,0,0.04)\",\n      \"0 30px 80px -16px rgba(0,0,0,0.32)\",\n      \"0 12px 28px -8px rgba(0,0,0,0.16)\",\n      `0 0 0 1px ${look.accent}10`,\n    ].join(\", \"),\n    height: narrow ? \"100%\" : undefined,\n    maxHeight: narrow ? \"100%\" : maxCardHeight,\n  };\n\n  return (\n    <div className=\"absolute z-40\" style={wrapperStyle}>\n      <div ref={ref} className=\"relative overflow-hidden rounded-[28px]\" style={cardStyle}>\n        {/* Top sheen */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 top-0 h-32\"\n          style={{\n            background:\n              \"linear-gradient(180deg, rgba(255,255,255,0.32) 0%, rgba(255,255,255,0.08) 40%, rgba(255,255,255,0) 100%)\",\n          }}\n        />\n        {/* Accent halo */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute -bottom-12 -left-12 size-56 rounded-full opacity-25 blur-3xl\"\n          style={{ background: look.accent }}\n        />\n\n        <div\n          className={`relative z-10 overflow-y-auto ${NO_SCROLLBAR} ${narrow ? \"h-full\" : \"\"}`}\n          style={{ maxHeight: narrow ? undefined : maxCardHeight }}\n        >\n          {/* Hero — a crop of the very same drawing the card shows */}\n          <div\n            className=\"relative mb-3 w-full\"\n            style={{ background: `${look.accent}14`, height: heroHeight }}\n            data-detail-anim\n          >\n            {orderedItems.map((item, i) => (\n              <div\n                key={item.id}\n                className={`absolute inset-0 transition-opacity duration-[450ms] ease-out ${\n                  i === activeIdx ? \"opacity-100\" : \"opacity-0\"\n                }`}\n              >\n                {/* The narrow sheet's hero is a ~2.3:1 letterbox while every\n                    crop is taller than it is wide, so \"meet\" would strand the\n                    drawing at ~37% of the width against flat accent fill.\n                    Fill it instead; the thumbnails below still show each crop\n                    whole. The wide layout is close enough to square to keep\n                    the full figure. */}\n                <LookFigure\n                  look={look}\n                  crop={item.slot}\n                  fit={narrow ? \"slice\" : \"meet\"}\n                  className=\"size-full\"\n                />\n              </div>\n            ))}\n\n            {/* Progress pips */}\n            <div className=\"absolute top-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5\">\n              {orderedItems.map((item, i) => (\n                <span\n                  key={item.id}\n                  className=\"block h-[3px] rounded-full transition-all duration-300\"\n                  style={{\n                    background: i === activeIdx ? look.accent : \"rgba(0,0,0,0.18)\",\n                    width: i === activeIdx ? 18 : 5,\n                  }}\n                />\n              ))}\n            </div>\n\n            {/* Look ID badge */}\n            <span\n              className=\"bg-background/85 absolute top-3 right-3 z-10 rounded-full border px-2.5 py-1 text-[9px] tracking-[0.34em] uppercase backdrop-blur-md\"\n              style={{ borderColor: `${look.accent}66`, color: look.accent }}\n            >\n              N° {look.lookNumber}\n            </span>\n\n            {/* Category badge */}\n            <span\n              // top-3, not the source's top-9: on a short hero the arrows sit\n              // at 50% and ran straight through a badge pinned lower down.\n              className=\"absolute top-3 left-3 z-10 rounded-md px-2 py-1 text-[8.5px] tracking-[0.24em] text-white uppercase shadow-sm\"\n              style={{ background: `${look.accent}e0` }}\n            >\n              {tag} · {activeItem.category}\n            </span>\n\n            {/* Prev / next */}\n            <button\n              type=\"button\"\n              onClick={prevItem}\n              aria-label=\"Previous piece\"\n              className=\"bg-background/85 border-foreground/15 hover:border-foreground/40 hover:bg-background absolute top-1/2 left-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full border shadow-md backdrop-blur-md transition-all\"\n            >\n              <ChevronLeft className=\"size-3.5\" />\n            </button>\n            <button\n              type=\"button\"\n              onClick={nextItem}\n              aria-label=\"Next piece\"\n              className=\"bg-background/85 border-foreground/15 hover:border-foreground/40 hover:bg-background absolute top-1/2 right-3 z-10 grid size-9 -translate-y-1/2 place-items-center rounded-full border shadow-md backdrop-blur-md transition-all\"\n            >\n              <ChevronRight className=\"size-3.5\" />\n            </button>\n\n            {/* Floating thumbnail pill */}\n            <div\n              className=\"pointer-events-none absolute bottom-0 left-1/2 z-20\"\n              style={{ transform: \"translate(-50%, 50%)\" }}\n            >\n              <div className=\"bg-background/85 border-foreground/12 pointer-events-auto flex gap-2 rounded-2xl border p-1.5 shadow-lg backdrop-blur-xl\">\n                {orderedItems.map((item, i) => {\n                  const isActive = i === activeIdx;\n                  const inBag = bag.has(item.id);\n                  return (\n                    <div key={item.id} className=\"group/thumb relative size-[72px]\">\n                      <button\n                        type=\"button\"\n                        onClick={() => setActiveIdx(i)}\n                        aria-pressed={isActive}\n                        aria-label={`View ${item.name}`}\n                        className=\"absolute inset-0 overflow-hidden rounded-xl transition-all duration-300\"\n                        style={{\n                          background: `${look.accent}12`,\n                          boxShadow: isActive\n                            ? \"inset 0 0 0 1.5px var(--foreground, #111)\"\n                            : \"inset 0 0 0 1px rgba(0,0,0,0.06)\",\n                          opacity: isActive ? 1 : 0.6,\n                        }}\n                      >\n                        <LookFigure\n                          look={look}\n                          crop={item.slot}\n                          className={`size-full transition-transform duration-500 ${\n                            isActive ? \"scale-100\" : \"scale-[1.06] group-hover/thumb:scale-100\"\n                          }`}\n                        />\n                      </button>\n                      <button\n                        type=\"button\"\n                        onClick={(e) => {\n                          e.stopPropagation();\n                          toggle(item.id);\n                        }}\n                        aria-label={inBag ? `Remove ${item.name}` : `Add ${item.name}`}\n                        aria-pressed={inBag}\n                        className={`absolute top-1 right-1 z-10 grid size-4 place-items-center rounded-full transition-all ${\n                          inBag\n                            ? \"bg-foreground text-background hover:bg-foreground/85 opacity-100\"\n                            : \"bg-background/85 text-foreground border-foreground/15 hover:bg-foreground hover:text-background hover:border-foreground border opacity-0 group-hover/thumb:opacity-100\"\n                        }`}\n                      >\n                        {inBag ? <Check className=\"size-2.5\" /> : <Plus className=\"size-2.5\" />}\n                      </button>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n\n          {/* Active item info */}\n          <div className=\"px-5 pt-12\" key={`info-${activeItem.id}`} data-detail-anim>\n            <div className=\"flex items-baseline justify-between gap-3\">\n              <h4 className=\"lbk-display truncate text-[19px] leading-tight\">{activeItem.name}</h4>\n              <span className=\"lbk-display shrink-0 text-[18px] tabular-nums\">\n                {fmtPrice(activeItem.priceCents)}\n              </span>\n            </div>\n            <p className=\"mt-1 truncate text-[11px] opacity-60\">{activeItem.material}</p>\n            <div className=\"mt-2.5 flex items-center gap-2 text-[9px] tracking-[0.2em] uppercase opacity-70\">\n              <span className=\"border-foreground/15 rounded-full border px-2 py-0.5\">\n                Size {activeItem.size}\n              </span>\n              <span>·</span>\n              <span>{look.season}</span>\n            </div>\n          </div>\n\n          {/* Add this piece */}\n          <div className=\"mt-4 px-5\" data-detail-anim>\n            <button\n              type=\"button\"\n              onClick={() => toggle(activeItem.id)}\n              aria-pressed={isActiveAdded}\n              className={`group/add w-full rounded-full border py-2.5 text-[10.5px] tracking-[0.22em] uppercase transition-all ${\n                isActiveAdded\n                  ? \"border-foreground bg-foreground/[0.06] hover:bg-foreground/[0.12]\"\n                  : \"border-foreground/20 hover:border-foreground/45 hover:bg-foreground/[0.04]\"\n              }`}\n            >\n              {isActiveAdded ? (\n                <span className=\"flex items-center justify-center gap-2\">\n                  <Check className=\"size-3 group-hover/add:hidden\" />\n                  <X className=\"hidden size-3 group-hover/add:block\" />\n                  <span className=\"group-hover/add:hidden\">\n                    Added · {fmtPrice(activeItem.priceCents)}\n                  </span>\n                  <span className=\"hidden group-hover/add:inline\">Remove from bag</span>\n                </span>\n              ) : (\n                `Add this piece · ${fmtPrice(activeItem.priceCents)}`\n              )}\n            </button>\n          </div>\n\n          {/* Divider */}\n          <div className=\"mt-4 mb-3 flex items-center gap-2 px-5\" data-detail-anim>\n            <div className=\"bg-foreground/10 h-px flex-1\" />\n            <span className=\"size-1 rounded-full\" style={{ background: look.accent }} />\n            <div className=\"bg-foreground/10 h-px flex-1\" />\n          </div>\n\n          {/* Price of the whole look — the bag counter beside it is progress\n              towards owning it, not a subtotal, so this figure is constant. */}\n          <div className=\"px-5 pb-6\" data-detail-anim>\n            <div className=\"mb-2 flex items-baseline justify-between px-0.5\">\n              <span className=\"text-[9px] tracking-[0.28em] uppercase opacity-55\">\n                Complete look · {bag.size}/{itemsCount} added\n              </span>\n              <span className=\"lbk-display text-[20px] tabular-nums\">\n                {fmtPrice(lookTotal(look))}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n","path":"/detail-panel.tsx","target":"uicapsule/lookbook-camera/detail-panel.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport type { ItemSlot, Look } from \"./lookbook-data\";\n\n/**\n * Every card in the lookbook is this component: the look's photograph placed\n * in a fixed 200x300 user-space box. The card renders the whole box; the detail\n * panel renders a crop of it (`CROPS`), so paging through a look's three pieces\n * is literally a camera move inside the same photograph.\n *\n * Photos are all framed the same way (full body, small margin above the head\n * and below the shoes, model centred), which is what lets one set of crops\n * serve all eighteen looks.\n */\n\nconst VIEW_W = 200;\nconst VIEW_H = 300;\nconst FULL_VIEW_BOX = `0 0 ${VIEW_W} ${VIEW_H}`;\n\n/** Slot -> the region of the photograph that piece occupies. */\nconst CROPS = {\n  lower: \"40 125 120 140\",\n  shoes: \"45 240 110 55\",\n  top: \"30 10 140 175\",\n} as const satisfies Record<ItemSlot, string>;\n\nexport interface LookFigureProps {\n  look: Look;\n  /** Omit for the whole figure; pass a slot to crop to that piece. */\n  crop?: ItemSlot;\n  /** \"meet\" fits the crop inside the box, \"slice\" fills it. */\n  fit?: \"meet\" | \"slice\";\n  className?: string;\n}\n\nexport const LookFigure = ({ look, crop, fit = \"meet\", className }: LookFigureProps) => {\n  const viewBox = crop === undefined ? FULL_VIEW_BOX : CROPS[crop];\n\n  return (\n    <svg\n      viewBox={viewBox}\n      preserveAspectRatio={`xMidYMid ${fit}`}\n      className={className}\n      aria-hidden\n      focusable=\"false\"\n    >\n      <rect width={VIEW_W} height={VIEW_H} fill={look.accent} fillOpacity={0.08} />\n      <image\n        href={look.imageUrl}\n        width={VIEW_W}\n        height={VIEW_H}\n        preserveAspectRatio=\"xMidYMid slice\"\n      />\n    </svg>\n  );\n};\n","path":"/look-figure.tsx","target":"uicapsule/lookbook-camera/look-figure.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { gsap } from \"gsap\";\nimport { X } from \"lucide-react\";\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\nimport type { Pos } from \"./lookbook-data\";\nimport { DetailPanel, NARROW_BREAKPOINT, usesSideLayout } from \"./detail-panel\";\nimport { LookFigure } from \"./look-figure\";\nimport {\n  BASE_LOOKS,\n  CARD_H,\n  CARD_W,\n  CELL_MARGIN_X,\n  CELL_MARGIN_Y,\n  chooseCols,\n  layoutPositions,\n  LOOKS,\n  wrapOffset,\n} from \"./lookbook-data\";\n\n/* ── Tunable constants ───────────────────────────────────────────── */\n\nconst CAMERA_INTRO_FACTOR = 0.52;\nconst CAMERA_REST_FACTOR = 1;\n\nconst NEIGHBOUR_HEIGHT_FRACTION = 0.3;\nconst NEIGHBOUR_HEIGHT_FRACTION_NARROW = 0.15;\nconst SELECTION_HEIGHT_FRACTION = 0.75;\nconst SELECTION_HEIGHT_FRACTION_NARROW = 0.36;\nconst POST_DESELECT_HEIGHT_FRACTION = 0.375;\n\nconst DRAG_THRESHOLD_PX = 6;\nconst WHEEL_SENSITIVITY = 0.0018;\nconst MAX_CAMERA_SCALE = 6;\nconst DEFAULT_MIN_ZOOM_CARD_HEIGHT = 240;\nconst DEFAULT_MIN_ZOOM_CARD_HEIGHT_NARROW = 200;\nconst DEFAULT_INERTIA_FRICTION = 0.93;\nconst AUTO_DRIFT_PX_PER_FRAME = 0.45;\n\nconst PARALLAX_RANGE = 320;\nconst PARALLAX_STRENGTH = 9;\nconst ENTRANCE_STAGGER = 0.04;\n\n/** One cycle — the only cards that are distinct looks, so the only tab stops. */\nconst KEYBOARD_REACHABLE = BASE_LOOKS.length;\n\n/* ── Types ───────────────────────────────────────────────────────── */\n\ninterface Dims {\n  w: number;\n  h: number;\n  worldW: number;\n  worldH: number;\n  baseFit: number;\n}\n\n/** The exact transform the RAF loop last wrote to a card, in GSAP's own props. */\ninterface WrittenTransform {\n  x: number;\n  y: number;\n  rotation: number;\n  scale: number;\n}\n\ninterface DragState {\n  active: boolean;\n  didMove: boolean;\n  pointerId: number;\n  startClientX: number;\n  startClientY: number;\n  lastSx: number;\n  lastSy: number;\n  prevSx: number;\n  prevSy: number;\n  vt: number;\n  vx: number;\n  vy: number;\n  worldAnchorX: number;\n  worldAnchorY: number;\n  inertiaActive: boolean;\n}\n\nconst clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));\n\n/**\n * Where the focused card's centre lands in container space.\n *\n * This defers to the detail panel's own layout predicate on purpose: the source\n * switched the landing point at 920 but the panel layout at 768, which left a\n * dead band where the panel was positioned beside a card that had already moved\n * to centre — at 800x600 the panel sat on top of the thing it described.\n */\nconst selectionTarget = (w: number, h: number) => {\n  if (usesSideLayout(w, h)) {\n    return { x: 0.34 * w, y: 0.5 * h };\n  }\n  return { x: 0.5 * w, y: 0.2 * h + 28 };\n};\n\n/** Everything a focused look needs, solved from one layout decision. */\ninterface SelectionGeometry {\n  /** Camera scale while the look is open. */\n  cameraScale: number;\n  /** Camera translation that puts the card on `selectionTarget`. */\n  cameraX: number;\n  cameraY: number;\n  /** Extra scale applied to the card itself, on top of the camera's. */\n  cardScale: number;\n  /** Card translation onto the wrapped copy nearest the camera. */\n  tileOffX: number;\n  tileOffY: number;\n}\n\n/**\n * Solve the open-look geometry for a container size and a camera world centre.\n *\n * The card's SIZE and the card's POSITION must come from the same\n * `usesSideLayout` verdict the detail panel branches on, or a frame that gets\n * the sheet layout still flies in a card sized for the wide one and the panel\n * lands on top of the thing it describes. Keeping both in here is what stops\n * the two from drifting apart — including across a resize, which re-solves.\n */\nconst solveSelection = (d: Dims, pos: Pos, worldCx: number, worldCy: number): SelectionGeometry => {\n  const narrow = !usesSideLayout(d.w, d.h);\n  const neighbourFraction = narrow ? NEIGHBOUR_HEIGHT_FRACTION_NARROW : NEIGHBOUR_HEIGHT_FRACTION;\n  const selectionFraction = narrow ? SELECTION_HEIGHT_FRACTION_NARROW : SELECTION_HEIGHT_FRACTION;\n  const cameraScale = (d.h * neighbourFraction) / CARD_H;\n  const target = selectionTarget(d.w, d.h);\n  const tileOffX = wrapOffset(pos.x, worldCx, d.worldW);\n  const tileOffY = wrapOffset(pos.y, worldCy, d.worldH);\n  return {\n    cameraScale,\n    cameraX: target.x - (pos.x + tileOffX) * cameraScale,\n    cameraY: target.y - (pos.y + tileOffY) * cameraScale,\n    cardScale: selectionFraction / neighbourFraction,\n    tileOffX,\n    tileOffY,\n  };\n};\n\n/** Fade every card except the one opening and the one it replaces. */\nconst dimOtherCards = (\n  refs: (HTMLDivElement | null)[],\n  idx: number,\n  prevIdx: number | null,\n): void => {\n  for (let i = 0; i < LOOKS.length; i += 1) {\n    if (i === idx || i === prevIdx) {\n      continue;\n    }\n    const el = refs[i];\n    if (!el) {\n      continue;\n    }\n    gsap.killTweensOf(el);\n    gsap.to(el, { duration: 0.55, ease: \"power2.out\", opacity: 0.22 });\n  }\n};\n\nconst restoreCloseButton = (btn: HTMLButtonElement | null): void => {\n  if (btn) {\n    gsap.killTweensOf(btn);\n    gsap.to(btn, { duration: 0.3, ease: \"power3.out\", opacity: 1, scale: 1 });\n  }\n};\n\nconst PARALLAX_REST = { extraX: 0, extraY: 0, lift: 1 };\n\n/** Cursor repulsion for a card `ddx, ddy` away from the pointer. */\nconst cursorParallax = (ddx: number, ddy: number) => {\n  const dist = Math.hypot(ddx, ddy);\n  const falloff = Math.max(0, 1 - dist / PARALLAX_RANGE);\n  const cEased = falloff * falloff * (3 - 2 * falloff);\n  if (dist <= 0.5) {\n    return { extraX: 0, extraY: 0, lift: 1 + cEased * 0.04 };\n  }\n  return {\n    extraX: (ddx / dist) * PARALLAX_STRENGTH * cEased,\n    extraY: (ddy / dist) * PARALLAX_STRENGTH * cEased,\n    lift: 1 + cEased * 0.04,\n  };\n};\n\n/* ── Decorative paper-grain contour lines ────────────────────────── */\n\n/** 14 wobbling contours — a paper ground that makes the drift legible. */\nconst CONTOUR_PATHS: string[] = Array.from({ length: 14 }, (_, i) => {\n  const y = (i / 14) * 100 + Math.sin(i * 1.7) * 4;\n  return `M0 ${y} Q25 ${y - 6} 50 ${y} T100 ${y}`;\n});\n\nconst BackgroundLines = () => (\n  <svg\n    className=\"text-foreground pointer-events-none absolute inset-0 size-full opacity-[0.06]\"\n    preserveAspectRatio=\"none\"\n    viewBox=\"0 0 100 100\"\n    aria-hidden\n  >\n    {CONTOUR_PATHS.map((d) => (\n      <path\n        key={d}\n        d={d}\n        stroke=\"currentColor\"\n        strokeWidth=\"0.06\"\n        fill=\"none\"\n        vectorEffect=\"non-scaling-stroke\"\n      />\n    ))}\n  </svg>\n);\n\n/* ── Host component ──────────────────────────────────────────────── */\n\nexport const LookbookCamera = () => {\n  const [selectedIdx, setSelectedIdx] = useState<number | null>(null);\n  const [dims, setDims] = useState<Dims | null>(null);\n  const [positions, setPositions] = useState<Pos[]>([]);\n  const [ready, setReady] = useState(false);\n\n  const containerRef = useRef<HTMLElement | null>(null);\n  const cameraRef = useRef<HTMLDivElement | null>(null);\n  const detailPanelRef = useRef<HTMLDivElement | null>(null);\n  const closeBtnRef = useRef<HTMLButtonElement | null>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  /**\n   * Mirror of the transforms the RAF loop writes as raw `style.transform`, which\n   * GSAP's per-target cache never observes. Handing these back to GSAP right\n   * before a tween starts is what keeps a selection from beginning a whole tile\n   * away from where the card actually is. Entries are mutated in place — the\n   * loop touches this every frame for every card.\n   */\n  const lastWrittenRef = useRef<WrittenTransform[]>([]);\n\n  const camera = useRef({ scale: 0.5, x: 0, y: 0 });\n  const drag = useRef<DragState>({\n    active: false,\n    didMove: false,\n    inertiaActive: false,\n    lastSx: 0,\n    lastSy: 0,\n    pointerId: -1,\n    prevSx: 0,\n    prevSy: 0,\n    startClientX: 0,\n    startClientY: 0,\n    vt: 0,\n    vx: 0,\n    vy: 0,\n    worldAnchorX: 0,\n    worldAnchorY: 0,\n  });\n  const mouse = useRef({ inside: false, x: 0, y: 0 });\n  const driftRamp = useRef({ amount: 0 });\n  /** Camera to restore on close; null when there is nothing worth restoring. */\n  const preSelectionCamera = useRef<{ x: number; y: number; scale: number } | null>(null);\n  const gsapManagedItems = useRef<Set<number>>(new Set());\n\n  const dimsRef = useRef<Dims>({\n    baseFit: 1,\n    h: 1,\n    w: 1,\n    worldH: 1,\n    worldW: 1,\n  });\n  const positionsRef = useRef<Pos[]>([]);\n  const rectRef = useRef({ left: 0, top: 0 });\n  const selectedIdxRef = useRef<number | null>(null);\n  const transitioningRef = useRef(false);\n  const enteredRef = useRef(false);\n  const cameraInitedRef = useRef(false);\n  const reduceMotionRef = useRef(false);\n  const animRef = useRef<number | null>(null);\n  /** The deferred half of a drag-deselect, held so unmount can kill it. */\n  const deselectCallRef = useRef<ReturnType<typeof gsap.delayedCall> | null>(null);\n  /** The one-frame wait before the detail panel's entrance is choreographed. */\n  const panelRafRef = useRef<number | null>(null);\n\n  const applyCameraTransform = useCallback(() => {\n    if (drag.current.active && drag.current.didMove) {\n      camera.current.x = drag.current.lastSx - drag.current.worldAnchorX * camera.current.scale;\n      camera.current.y = drag.current.lastSy - drag.current.worldAnchorY * camera.current.scale;\n    }\n    const c = cameraRef.current;\n    if (!c) {\n      return;\n    }\n    const { scale, x, y } = camera.current;\n    c.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;\n  }, []);\n\n  const effectiveMinScale = useCallback(() => {\n    const { w } = dimsRef.current;\n    const floor =\n      w < NARROW_BREAKPOINT ? DEFAULT_MIN_ZOOM_CARD_HEIGHT_NARROW : DEFAULT_MIN_ZOOM_CARD_HEIGHT;\n    return floor / CARD_H;\n  }, []);\n\n  /**\n   * The container's viewport offset, cached because pointer/wheel handlers need\n   * it on every event and a `getBoundingClientRect()` there would force layout\n   * against the RAF loop's transform writes. Page scroll moves it without\n   * resizing anything, so scrolling has to refresh it too.\n   */\n  const refreshRect = useCallback(() => {\n    const c = containerRef.current;\n    if (!c) {\n      return;\n    }\n    const r = c.getBoundingClientRect();\n    rectRef.current = { left: r.left, top: r.top };\n  }, []);\n\n  const measure = useCallback(() => {\n    const c = containerRef.current;\n    if (!c) {\n      return;\n    }\n    const r = c.getBoundingClientRect();\n    const w = r.width;\n    const h = r.height;\n    // A ResizeObserver fires once at 0x0 inside a freshly-mounted iframe.\n    if (w === 0 || h === 0) {\n      return;\n    }\n    // Read the camera's world centre against the OLD dims, before they change.\n    const prev = dimsRef.current;\n    const worldCx = (prev.w / 2 - camera.current.x) / camera.current.scale;\n    const worldCy = (prev.h / 2 - camera.current.y) / camera.current.scale;\n    const cols = chooseCols(LOOKS.length, w / h);\n    const rows = Math.ceil(LOOKS.length / cols);\n    const cellW = CARD_W + CELL_MARGIN_X;\n    const cellH = CARD_H + CELL_MARGIN_Y;\n    const worldW = cols * cellW;\n    const worldH = rows * cellH;\n    const baseFit = Math.min(w / worldW, h / worldH) * 0.94;\n    const d: Dims = { baseFit, h, w, worldH, worldW };\n    const p = layoutPositions(worldW, worldH, cols, LOOKS.length);\n    dimsRef.current = d;\n    positionsRef.current = p;\n    refreshRect();\n    setDims(d);\n    setPositions(p);\n    if (!cameraInitedRef.current) {\n      cameraInitedRef.current = true;\n      const introScale = CAMERA_INTRO_FACTOR * baseFit;\n      camera.current.scale = introScale;\n      camera.current.x = (w - worldW * introScale) / 2;\n      camera.current.y = (h - worldH * introScale) / 2;\n      for (const el of itemRefs.current) {\n        if (el) {\n          el.style.opacity = \"0\";\n          el.style.filter = \"blur(8px)\";\n        }\n      }\n      applyCameraTransform();\n      setReady(true);\n      return;\n    }\n\n    // An open look keeps the geometry chosen at click time, so a resize that\n    // flips `usesSideLayout` would leave the card parked where the old layout\n    // put it while the panel re-renders into the new one — landing on top of\n    // it. Re-solve and snap; a resize should not animate. Mid-transition the\n    // in-flight tween owns these values, so leave it alone.\n    const sel = selectedIdxRef.current;\n    const selPos = sel === null ? null : p[sel];\n    if (sel !== null && selPos && !transitioningRef.current) {\n      const g = solveSelection(d, selPos, worldCx, worldCy);\n      camera.current.scale = g.cameraScale;\n      camera.current.x = g.cameraX;\n      camera.current.y = g.cameraY;\n      applyCameraTransform();\n      const el = itemRefs.current[sel];\n      if (el) {\n        gsap.set(el, { rotation: 0, scale: g.cardScale, x: g.tileOffX, y: g.tileOffY });\n      }\n      // The saved viewport was framed for the old container; restoring it at\n      // the new size would land somewhere arbitrary. Fall back to the fit.\n      preSelectionCamera.current = null;\n    }\n  }, [applyCameraTransform, refreshRect]);\n\n  const triggerDragDeselect = useCallback(() => {\n    const idx = selectedIdxRef.current;\n    if (idx === null) {\n      return;\n    }\n    const realEl = itemRefs.current[idx];\n    if (!realEl) {\n      return;\n    }\n\n    selectedIdxRef.current = null;\n    transitioningRef.current = true;\n\n    gsap.killTweensOf(camera.current);\n    gsap.killTweensOf(realEl);\n\n    const d = dimsRef.current;\n    const pos = positionsRef.current;\n    const minScaleDeselect = effectiveMinScale();\n    const targetScale = Math.max((d.h * POST_DESELECT_HEIGHT_FRACTION) / CARD_H, minScaleDeselect);\n\n    gsap.to(camera.current, {\n      duration: 0.4,\n      ease: \"power2.out\",\n      onUpdate: applyCameraTransform,\n      scale: targetScale,\n    });\n\n    realEl.style.pointerEvents = \"\";\n    gsap.to(realEl, {\n      duration: 0.4,\n      ease: \"power2.out\",\n      onComplete: () => {\n        realEl.style.zIndex = \"\";\n      },\n      rotation: pos[idx]?.rot ?? 0,\n      scale: 1,\n    });\n\n    for (let i = 0; i < LOOKS.length; i += 1) {\n      if (i === idx) {\n        continue;\n      }\n      const el = itemRefs.current[i];\n      if (!el) {\n        continue;\n      }\n      gsap.killTweensOf(el);\n      gsap.to(el, { duration: 0.35, ease: \"power2.out\", opacity: 1 });\n    }\n\n    const dp = detailPanelRef.current;\n    const cb = closeBtnRef.current;\n    if (dp) {\n      // A re-selection's fade-out may still be running with an onComplete that\n      // re-opens the panel. GSAP does not overwrite by default, so kill it.\n      gsap.killTweensOf(dp);\n      gsap.to(dp, { duration: 0.3, ease: \"power2.in\", opacity: 0, x: 24 });\n    }\n    if (cb) {\n      gsap.to(cb, { duration: 0.25, ease: \"power2.in\", opacity: 0 });\n    }\n\n    deselectCallRef.current = gsap.delayedCall(0.4, () => {\n      deselectCallRef.current = null;\n      // Clear, not delete(idx): a deselect mid-reselection has killed the\n      // outgoing card's tween, so the onComplete that would have released it\n      // never fires and it would be excluded from the re-wrap forever.\n      gsapManagedItems.current.clear();\n      setSelectedIdx(null);\n      transitioningRef.current = false;\n      preSelectionCamera.current = null;\n      const cont = containerRef.current;\n      if (cont) {\n        cont.style.cursor = drag.current.active ? \"grabbing\" : \"grab\";\n      }\n    });\n  }, [applyCameraTransform, effectiveMinScale]);\n\n  const handleClose = useCallback(() => {\n    const idx = selectedIdxRef.current;\n    if (idx === null) {\n      return;\n    }\n    if (transitioningRef.current) {\n      return;\n    }\n    transitioningRef.current = true;\n\n    const d = dimsRef.current;\n    const pos = positionsRef.current[idx];\n    const realEl = itemRefs.current[idx];\n\n    const dp = detailPanelRef.current;\n    const cb = closeBtnRef.current;\n    if (dp) {\n      // Same reason as in triggerDragDeselect: closing during a re-selection\n      // would otherwise let that fade-out's onComplete re-open the panel\n      // partway through the close.\n      gsap.killTweensOf(dp);\n      gsap.to(dp, { duration: 0.35, ease: \"power2.in\", opacity: 0, x: 36 });\n    }\n    if (cb) {\n      gsap.to(cb, { duration: 0.3, ease: \"power2.in\", opacity: 0, scale: 0.85 });\n    }\n\n    const minScale = effectiveMinScale();\n    const saved = preSelectionCamera.current;\n    const restScale = saved\n      ? Math.max(saved.scale, minScale)\n      : Math.max(CAMERA_REST_FACTOR * d.baseFit, minScale);\n    const restX = saved ? saved.x : (d.w - d.worldW * restScale) / 2;\n    const restY = saved ? saved.y : (d.h - d.worldH * restScale) / 2;\n\n    gsap.killTweensOf(camera.current);\n    gsap.to(camera.current, {\n      delay: 0.18,\n      duration: 0.95,\n      ease: \"power3.inOut\",\n      onComplete: () => {\n        if (realEl) {\n          realEl.style.zIndex = \"\";\n          realEl.style.pointerEvents = \"\";\n        }\n        // Clear, not delete(idx), for the same reason as triggerDragDeselect.\n        // A chain of re-selections dims — and so kills the tween of — the card\n        // from two selections ago, and that tween carried the only release for\n        // its index. Anything still in the set here is stranded: the close has\n        // settled, so no item tween is in flight that still needs the guard.\n        gsapManagedItems.current.clear();\n        selectedIdxRef.current = null;\n        setSelectedIdx(null);\n        transitioningRef.current = false;\n        preSelectionCamera.current = null;\n      },\n      onUpdate: applyCameraTransform,\n      scale: restScale,\n      x: restX,\n      y: restY,\n    });\n\n    if (realEl && pos) {\n      gsap.killTweensOf(realEl);\n      const closeWorldCx = (d.w / 2 - restX) / restScale;\n      const closeWorldCy = (d.h / 2 - restY) / restScale;\n      gsap.to(realEl, {\n        delay: 0.18,\n        duration: 0.95,\n        ease: \"power3.inOut\",\n        rotation: pos.rot,\n        scale: 1,\n        x: wrapOffset(pos.x, closeWorldCx, d.worldW),\n        y: wrapOffset(pos.y, closeWorldCy, d.worldH),\n      });\n    }\n\n    for (let i = 0; i < LOOKS.length; i += 1) {\n      if (i === idx) {\n        continue;\n      }\n      const el = itemRefs.current[i];\n      if (!el) {\n        continue;\n      }\n      gsap.killTweensOf(el);\n      gsap.to(el, { delay: 0.18, duration: 0.6, ease: \"power2.out\", opacity: 1 });\n    }\n  }, [applyCameraTransform, effectiveMinScale]);\n\n  const handleClickItem = useCallback(\n    (idx: number) => {\n      if (!enteredRef.current) {\n        return;\n      }\n\n      const prevIdx = selectedIdxRef.current;\n      if (prevIdx === idx) {\n        return;\n      }\n      if (prevIdx === null && transitioningRef.current) {\n        return;\n      }\n\n      transitioningRef.current = true;\n\n      const d = dimsRef.current;\n      const pos = positionsRef.current;\n      const newPos = pos[idx];\n      const newEl = itemRefs.current[idx];\n      const prevPos = prevIdx === null ? null : pos[prevIdx];\n      const prevEl = prevIdx === null ? null : itemRefs.current[prevIdx];\n      if (!newPos || !newEl) {\n        transitioningRef.current = false;\n        return;\n      }\n\n      const preCamX = camera.current.x;\n      const preCamY = camera.current.y;\n      const preCamS = camera.current.scale;\n      const worldCx = (d.w / 2 - preCamX) / preCamS;\n      const worldCy = (d.h / 2 - preCamY) / preCamS;\n      const g = solveSelection(d, newPos, worldCx, worldCy);\n\n      if (prevIdx === null) {\n        preSelectionCamera.current = { scale: preCamS, x: preCamX, y: preCamY };\n      }\n\n      // Read BEFORE the add below: whether the RAF loop or GSAP owned this card\n      // a moment ago decides which of them holds its true transform.\n      const rafOwnedNew = !gsapManagedItems.current.has(idx);\n\n      gsapManagedItems.current.add(idx);\n      if (prevIdx !== null) {\n        gsapManagedItems.current.add(prevIdx);\n      }\n\n      // Claim the selection SYNCHRONOUSLY. Deferring this into the panel\n      // fade-out below leaves a ~280ms window in which a drag reads the stale\n      // index, deselects it, and is then overwritten by the deferred write —\n      // ref non-null, state null, every `sel === null` gate dead for good.\n      selectedIdxRef.current = idx;\n\n      gsap.killTweensOf(newEl);\n      // The RAF loop has been writing this card's `style.transform` raw, which\n      // GSAP's per-target transform cache never sees, so a tween would start\n      // from whatever GSAP last rendered — a whole tile away once the world has\n      // drifted past a wrap boundary — and the card would teleport off screen on\n      // frame one before flying back in. `parseTransform: true` is not enough:\n      // gsap reads `x`'s start value through `_get` with no uncache flag and\n      // only refreshes the cache afterwards, so `x` alone still starts stale.\n      // A zero-duration `set` renders immediately, which makes the cache\n      // authoritative for all four props before the tween samples any of them.\n      //\n      // Only when the RAF loop was the one writing. Re-selecting a card during\n      // the ~0.65s release tween of the look before it lands here with GSAP\n      // still mid-render: its cache is already correct and the RAF mirror is\n      // stale by however far the field has panned since, so replaying it would\n      // reintroduce the very jump this prevents.\n      const lastWritten = rafOwnedNew ? lastWrittenRef.current[idx] : undefined;\n      if (lastWritten) {\n        gsap.set(newEl, lastWritten);\n      }\n      gsap.to(newEl, {\n        duration: prevIdx === null ? 1 : 0.9,\n        ease: \"power3.inOut\",\n        opacity: 1,\n        rotation: 0,\n        scale: g.cardScale,\n        x: g.tileOffX,\n        y: g.tileOffY,\n      });\n      newEl.style.zIndex = \"60\";\n      newEl.style.pointerEvents = \"none\";\n\n      if (prevEl && prevPos) {\n        gsap.killTweensOf(prevEl);\n        gsap.to(prevEl, {\n          duration: 0.65,\n          ease: \"power3.inOut\",\n          onComplete: () => {\n            prevEl.style.zIndex = \"\";\n            if (prevIdx !== null) {\n              gsapManagedItems.current.delete(prevIdx);\n            }\n          },\n          onStart: () => {\n            prevEl.style.pointerEvents = \"\";\n          },\n          opacity: 0.22,\n          rotation: prevPos.rot,\n          scale: 1,\n          x: wrapOffset(prevPos.x, worldCx, d.worldW),\n          y: wrapOffset(prevPos.y, worldCy, d.worldH),\n        });\n      }\n\n      dimOtherCards(itemRefs.current, idx, prevIdx);\n\n      gsap.killTweensOf(camera.current);\n      gsap.to(camera.current, {\n        duration: prevIdx === null ? 1.05 : 0.95,\n        ease: \"power3.inOut\",\n        onComplete: () => {\n          transitioningRef.current = false;\n        },\n        onUpdate: applyCameraTransform,\n        scale: g.cameraScale,\n        x: g.cameraX,\n        y: g.cameraY,\n      });\n\n      if (prevIdx === null) {\n        setSelectedIdx(idx);\n        panelRafRef.current = requestAnimationFrame(() => {\n          panelRafRef.current = null;\n          const dp = detailPanelRef.current;\n          const cb = closeBtnRef.current;\n          if (dp) {\n            gsap.fromTo(\n              dp,\n              { opacity: 0, x: 48 },\n              { delay: 0.35, duration: 0.75, ease: \"power3.out\", opacity: 1, x: 0 },\n            );\n            const stages = dp.querySelectorAll<HTMLElement>(\"[data-detail-anim]\");\n            gsap.fromTo(\n              stages,\n              { filter: \"blur(6px)\", opacity: 0, y: 16 },\n              {\n                delay: 0.55,\n                duration: 0.65,\n                ease: \"power3.out\",\n                filter: \"blur(0px)\",\n                opacity: 1,\n                stagger: 0.08,\n                y: 0,\n              },\n            );\n          }\n          if (cb) {\n            gsap.fromTo(\n              cb,\n              { opacity: 0, scale: 0.85 },\n              { delay: 0.4, duration: 0.5, ease: \"power3.out\", opacity: 1, scale: 1 },\n            );\n          }\n        });\n      } else {\n        // Selecting another card DURING a close is a re-selection (the close\n        // has not cleared the index yet), so it lands here — but the close has\n        // already faded the button out and its restore lives on the fresh-open\n        // path above. Without this the look re-opens with no visible close\n        // affordance. On an ordinary re-selection the button is already at\n        // rest, so this is a no-op.\n        restoreCloseButton(closeBtnRef.current);\n        const dp = detailPanelRef.current;\n        if (dp) {\n          gsap.killTweensOf(dp);\n          gsap.killTweensOf(dp.querySelectorAll(\"[data-detail-anim]\"));\n          gsap.to(dp, {\n            duration: 0.28,\n            ease: \"power2.in\",\n            onComplete: () => {\n              setSelectedIdx(idx);\n              panelRafRef.current = requestAnimationFrame(() => {\n                panelRafRef.current = null;\n                const newDp = detailPanelRef.current;\n                if (!newDp) {\n                  return;\n                }\n                gsap.fromTo(\n                  newDp,\n                  { opacity: 0, x: 28 },\n                  { duration: 0.55, ease: \"power3.out\", opacity: 1, x: 0 },\n                );\n                const newStages = newDp.querySelectorAll<HTMLElement>(\"[data-detail-anim]\");\n                gsap.fromTo(\n                  newStages,\n                  { filter: \"blur(4px)\", opacity: 0, y: 12 },\n                  {\n                    delay: 0.08,\n                    duration: 0.5,\n                    ease: \"power3.out\",\n                    filter: \"blur(0px)\",\n                    opacity: 1,\n                    stagger: 0.06,\n                    y: 0,\n                  },\n                );\n              });\n            },\n            opacity: 0,\n            x: 20,\n          });\n        } else {\n          setSelectedIdx(idx);\n        }\n      }\n    },\n    [applyCameraTransform],\n  );\n\n  /* Deferred work scheduled from event handlers rather than from an effect —\n     it outlives the handler, so unmount has to reclaim it explicitly. */\n  useEffect(\n    () => () => {\n      deselectCallRef.current?.kill();\n      deselectCallRef.current = null;\n      if (panelRafRef.current !== null) {\n        cancelAnimationFrame(panelRafRef.current);\n      }\n      panelRafRef.current = null;\n    },\n    [],\n  );\n\n  /* Measure on mount + whenever the container box changes. A window resize\n     listener is not enough: the gallery resizes the frame directly. */\n  useLayoutEffect(() => {\n    const c = containerRef.current;\n    if (!c) {\n      return;\n    }\n    measure();\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(c);\n    return () => {\n      ro.disconnect();\n      // Re-arm so a StrictMode double-mount replays the entrance rather than\n      // silently skipping it.\n      cameraInitedRef.current = false;\n    };\n  }, [measure]);\n\n  /* Pointer / wheel / key listeners + the per-frame RAF loop. */\n  useEffect(() => {\n    const c = containerRef.current;\n    if (!c) {\n      return;\n    }\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (!enteredRef.current) {\n        return;\n      }\n      if (e.button !== 0 && e.pointerType === \"mouse\") {\n        return;\n      }\n      const target = e.target instanceof Node ? e.target : null;\n      if (target && detailPanelRef.current?.contains(target)) {\n        return;\n      }\n      if (target && closeBtnRef.current?.contains(target)) {\n        return;\n      }\n\n      const r = rectRef.current;\n      const sx = e.clientX - r.left;\n      const sy = e.clientY - r.top;\n      drag.current.active = true;\n      drag.current.pointerId = e.pointerId;\n      drag.current.startClientX = e.clientX;\n      drag.current.startClientY = e.clientY;\n      drag.current.lastSx = sx;\n      drag.current.lastSy = sy;\n      drag.current.prevSx = sx;\n      drag.current.prevSy = sy;\n      drag.current.vt = performance.now();\n      drag.current.vx = 0;\n      drag.current.vy = 0;\n      // World anchor: the pan stays exact at any zoom because the camera is\n      // re-derived from this point rather than accumulating deltas.\n      drag.current.worldAnchorX = (sx - camera.current.x) / camera.current.scale;\n      drag.current.worldAnchorY = (sy - camera.current.y) / camera.current.scale;\n      drag.current.didMove = false;\n      drag.current.inertiaActive = false;\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const r = rectRef.current;\n      const sx = e.clientX - r.left;\n      const sy = e.clientY - r.top;\n      mouse.current.x = sx;\n      mouse.current.y = sy;\n      const d = dimsRef.current;\n      mouse.current.inside = sx >= 0 && sy >= 0 && sx <= d.w && sy <= d.h;\n\n      if (!drag.current.active || drag.current.pointerId !== e.pointerId) {\n        return;\n      }\n\n      drag.current.lastSx = sx;\n      drag.current.lastSy = sy;\n      const dx = e.clientX - drag.current.startClientX;\n      const dy = e.clientY - drag.current.startClientY;\n\n      if (!drag.current.didMove && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {\n        drag.current.didMove = true;\n        c.style.cursor = \"grabbing\";\n        gsap.killTweensOf(camera.current);\n        if (selectedIdxRef.current !== null) {\n          triggerDragDeselect();\n        }\n      }\n\n      if (drag.current.didMove) {\n        const now = performance.now();\n        const dt = Math.max(1, now - drag.current.vt);\n        drag.current.vx = ((sx - drag.current.prevSx) / dt) * 16;\n        drag.current.vy = ((sy - drag.current.prevSy) / dt) * 16;\n        drag.current.vt = now;\n        drag.current.prevSx = sx;\n        drag.current.prevSy = sy;\n        applyCameraTransform();\n      }\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      if (drag.current.pointerId !== e.pointerId) {\n        return;\n      }\n      drag.current.active = false;\n      drag.current.pointerId = -1;\n      c.style.cursor = \"grab\";\n      if (drag.current.didMove && Math.hypot(drag.current.vx, drag.current.vy) > 0.6) {\n        drag.current.inertiaActive = true;\n      }\n    };\n\n    /** The pointer can leave the frame without a final in-bounds `pointermove`\n        (straight off the window edge), which would freeze the parallax on\n        whichever card it was last over. */\n    const onPointerLeave = () => {\n      mouse.current.inside = false;\n    };\n\n    const onWheel = (e: WheelEvent) => {\n      // An open look owns the wheel: the detail panel scrolls. Everything else\n      // is the camera's, so it swallows the event even when it cannot act on\n      // it — otherwise the entrance and every transition let the wheel through\n      // to whatever page has embedded this.\n      if (selectedIdxRef.current !== null) {\n        return;\n      }\n      e.preventDefault();\n      if (!enteredRef.current) {\n        return;\n      }\n      if (transitioningRef.current) {\n        return;\n      }\n      if (drag.current.active && drag.current.didMove) {\n        return;\n      }\n      drag.current.inertiaActive = false;\n      drag.current.vx = 0;\n      drag.current.vy = 0;\n      gsap.killTweensOf(camera.current);\n\n      const r = rectRef.current;\n      const sx = e.clientX - r.left;\n      const sy = e.clientY - r.top;\n      const currentScale = camera.current.scale;\n      const zoomFactor = Math.exp(-e.deltaY * WHEEL_SENSITIVITY);\n      const minScale = effectiveMinScale();\n      const newScale = clamp(currentScale * zoomFactor, minScale, MAX_CAMERA_SCALE);\n      if (newScale === currentScale) {\n        return;\n      }\n\n      // Re-solve the camera so the world point under the cursor stays pinned.\n      const worldX = (sx - camera.current.x) / currentScale;\n      const worldY = (sy - camera.current.y) / currentScale;\n      camera.current.scale = newScale;\n      camera.current.x = sx - worldX * newScale;\n      camera.current.y = sy - worldY * newScale;\n      applyCameraTransform();\n    };\n\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && selectedIdxRef.current !== null) {\n        handleClose();\n      }\n    };\n\n    c.addEventListener(\"pointerdown\", onPointerDown);\n    c.addEventListener(\"pointerleave\", onPointerLeave);\n    window.addEventListener(\"pointermove\", onPointerMove);\n    window.addEventListener(\"pointerup\", onPointerUp);\n    // Without this a cancelled pointer (OS gesture takeover, input redirection)\n    // never ends the drag: `active`/`didMove` stay true, so the camera keeps\n    // being rewritten from a stale anchor and clicks, drift and parallax all\n    // die permanently. `onPointerUp` already filters on the pointer id.\n    window.addEventListener(\"pointercancel\", onPointerUp);\n    c.addEventListener(\"wheel\", onWheel, { passive: false });\n    window.addEventListener(\"keydown\", onKey);\n    // Capture phase so a scrolling ancestor counts, not just the window.\n    window.addEventListener(\"scroll\", refreshRect, { capture: true, passive: true });\n\n    const stepInertia = (sel: number | null) => {\n      if (\n        drag.current.inertiaActive &&\n        !drag.current.active &&\n        sel === null &&\n        !transitioningRef.current\n      ) {\n        camera.current.x += drag.current.vx;\n        camera.current.y += drag.current.vy;\n        drag.current.vx *= DEFAULT_INERTIA_FRICTION;\n        drag.current.vy *= DEFAULT_INERTIA_FRICTION;\n        if (Math.hypot(drag.current.vx, drag.current.vy) < 0.12) {\n          drag.current.inertiaActive = false;\n          drag.current.vx = 0;\n          drag.current.vy = 0;\n        }\n        applyCameraTransform();\n      }\n    };\n\n    const stepDrift = (sel: number | null) => {\n      if (\n        AUTO_DRIFT_PX_PER_FRAME > 0 &&\n        !reduceMotionRef.current &&\n        enteredRef.current &&\n        sel === null &&\n        !transitioningRef.current &&\n        !drag.current.active &&\n        !drag.current.inertiaActive &&\n        driftRamp.current.amount > 0.001\n      ) {\n        const speed = AUTO_DRIFT_PX_PER_FRAME * driftRamp.current.amount;\n        camera.current.x -= speed;\n        camera.current.y -= speed;\n        applyCameraTransform();\n      }\n    };\n\n    const tick = () => {\n      const sel = selectedIdxRef.current;\n      const pos = positionsRef.current;\n      const d = dimsRef.current;\n\n      stepInertia(sel);\n      stepDrift(sel);\n\n      if (enteredRef.current) {\n        const atRest = sel === null && !transitioningRef.current;\n        const camS = camera.current.scale;\n        const camX = camera.current.x;\n        const camY = camera.current.y;\n        const doParallax =\n          atRest && mouse.current.inside && !drag.current.active && !reduceMotionRef.current;\n        const mx = mouse.current.x;\n        const my = mouse.current.y;\n        const worldCx = (d.w / 2 - camX) / camS;\n        const worldCy = (d.h / 2 - camY) / camS;\n        const tileW = d.worldW;\n        const tileH = d.worldH;\n        const managed = gsapManagedItems.current;\n        const refs = itemRefs.current;\n        const written = lastWrittenRef.current;\n\n        for (let i = 0; i < refs.length; i += 1) {\n          if (managed.has(i)) {\n            continue;\n          }\n          const el = refs[i];\n          const p = pos[i];\n          if (!el || !p) {\n            continue;\n          }\n\n          // Re-wrap every card against the camera's world centre, every frame.\n          // This is the mechanic: the field has no edges, ever.\n          const tileOffX = wrapOffset(p.x, worldCx, tileW);\n          const tileOffY = wrapOffset(p.y, worldCy, tileH);\n\n          let par = PARALLAX_REST;\n          if (doParallax) {\n            const wrappedX = p.x + tileOffX;\n            const wrappedY = p.y + tileOffY;\n            const icx = camX + wrappedX * camS;\n            const icy = camY + wrappedY * camS;\n            par = cursorParallax(mx - icx, my - icy);\n          }\n\n          const ox = par.extraX + tileOffX;\n          const oy = par.extraY + tileOffY;\n          el.style.transform = `translate3d(${ox}px, ${oy}px, 0) rotate(${p.rot}deg) scale(${par.lift})`;\n\n          const w = written[i];\n          if (w) {\n            w.x = ox;\n            w.y = oy;\n            w.rotation = p.rot;\n            w.scale = par.lift;\n          } else {\n            written[i] = { rotation: p.rot, scale: par.lift, x: ox, y: oy };\n          }\n        }\n      }\n\n      animRef.current = requestAnimationFrame(tick);\n    };\n    animRef.current = requestAnimationFrame(tick);\n\n    return () => {\n      c.removeEventListener(\"pointerdown\", onPointerDown);\n      c.removeEventListener(\"pointerleave\", onPointerLeave);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      window.removeEventListener(\"pointercancel\", onPointerUp);\n      c.removeEventListener(\"wheel\", onWheel);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"scroll\", refreshRect, { capture: true });\n      if (animRef.current !== null) {\n        cancelAnimationFrame(animRef.current);\n      }\n    };\n  }, [applyCameraTransform, effectiveMinScale, handleClose, refreshRect, triggerDragDeselect]);\n\n  /* Grid entrance (camera dolly + item de-blur). Fires once the first\n     measurement lands. Tween cleanup makes it Strict-Mode safe: a teardown\n     kills the in-flight dolly and the re-run restarts it from the current\n     camera state, so it always settles at rest. */\n  useEffect(() => {\n    if (!ready) {\n      return;\n    }\n\n    enteredRef.current = true;\n    const realItems = itemRefs.current.filter((el): el is HTMLDivElement => el !== null);\n    const restScale = Math.max(CAMERA_REST_FACTOR * dimsRef.current.baseFit, effectiveMinScale());\n    // These two refs hold stable objects that GSAP tweens in place; bind them\n    // now so the cleanup kills the same objects it started tweening.\n    const cameraObj = camera.current;\n    const driftObj = driftRamp.current;\n\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    reduceMotionRef.current = reduce;\n    if (reduce) {\n      const dd = dimsRef.current;\n      camera.current.scale = restScale;\n      camera.current.x = (dd.w - dd.worldW * restScale) / 2;\n      camera.current.y = (dd.h - dd.worldH * restScale) / 2;\n      applyCameraTransform();\n      for (const el of realItems) {\n        el.style.opacity = \"1\";\n        el.style.filter = \"none\";\n      }\n      // Unlike the source, reduced motion also parks the perpetual drift and\n      // the cursor repulsion — both read this ref inside the RAF loop.\n      driftRamp.current.amount = 0;\n      return;\n    }\n\n    driftObj.amount = 0;\n    gsap.to(driftObj, { amount: 1, delay: 1.5, duration: 2.5, ease: \"power2.in\" });\n\n    const tl = gsap.timeline({ delay: 0.12 });\n    tl.to(\n      realItems,\n      {\n        duration: 0.95,\n        ease: \"power3.out\",\n        filter: \"blur(0px)\",\n        opacity: 1,\n        stagger: { each: ENTRANCE_STAGGER, from: \"random\" },\n      },\n      0,\n    );\n    tl.to(\n      cameraObj,\n      {\n        duration: 1.6,\n        ease: \"power4.out\",\n        onUpdate: () => {\n          if (!drag.current.active) {\n            const dd = dimsRef.current;\n            const s = camera.current.scale;\n            camera.current.x = (dd.w - dd.worldW * s) / 2;\n            camera.current.y = (dd.h - dd.worldH * s) / 2;\n          }\n          applyCameraTransform();\n        },\n        scale: restScale,\n      },\n      0,\n    );\n\n    return () => {\n      tl.kill();\n      gsap.killTweensOf(cameraObj);\n      gsap.killTweensOf(driftObj);\n      gsap.killTweensOf(realItems);\n    };\n  }, [ready, applyCameraTransform, effectiveMinScale]);\n\n  const selectedLook = selectedIdx === null ? null : (LOOKS[selectedIdx] ?? null);\n\n  return (\n    <section\n      ref={containerRef}\n      data-lookbook-root\n      aria-label=\"Interactive lookbook canvas\"\n      // `text-foreground` is load-bearing, not decoration: the scoped palette\n      // below only reaches utilities that name a token, and the detail panel's\n      // headings, prices and the close icon all inherit `color`. Without an\n      // anchor here they inherit the HOST page's — white, under a dark theme,\n      // on this component's permanently cream glass.\n      className=\"bg-background text-foreground relative size-full overflow-hidden select-none\"\n      style={{ cursor: \"grab\", touchAction: \"none\" }}\n    >\n      {/* Scoped palette. uicapsule declares its tokens with `@theme inline`, so\n          `--color-background` expands to `var(--background)` at the use site —\n          redefining the raw token here is what makes every `bg-background` /\n          `text-foreground` utility below resolve warm, and immune to whatever\n          light/dark theme surrounds the frame. */}\n      <style>{`\n        [data-lookbook-root]{\n          --background:#fffdf8;\n          --foreground:#221e18;\n          --muted:#ece4d4;\n          --muted-foreground:#6f6757;\n          --border:#ddd3c0;\n        }\n        [data-lookbook-root] .lbk-display{\n          font-family:'Playfair Display',Georgia,'Times New Roman',serif;\n          font-style:italic;\n          font-weight:500;\n        }\n      `}</style>\n\n      <BackgroundLines />\n\n      {/* Camera + world */}\n      <div\n        ref={cameraRef}\n        className=\"absolute top-0 left-0\"\n        style={{\n          transformOrigin: \"0 0\",\n          // The gallery server-renders this component, so the first HTML has no\n          // measurements and every card is stacked at 0,0. Hide the camera node\n          // rather than unmounting the cards: `measure()` needs them mounted to\n          // zero them, and that zeroed state is what the entrance animates from.\n          visibility: ready ? undefined : \"hidden\",\n          willChange: \"transform\",\n        }}\n      >\n        <div\n          style={{\n            height: dims?.worldH ?? 0,\n            position: \"relative\",\n            width: dims?.worldW ?? 0,\n          }}\n        >\n          {LOOKS.map((look, i) => {\n            const p = positions[i];\n            // 72 cards, 18 distinct looks: the other three cycles are visual\n            // repeats. Exposing them as buttons put 54 unreachable duplicates\n            // in the accessibility tree with labels identical to their twins,\n            // so they are hidden from it entirely — which also means they must\n            // not be focusable at all, not merely untabbable.\n            const isDuplicate = i >= KEYBOARD_REACHABLE;\n            return (\n              // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- role is deliberately absent on the aria-hidden duplicates (they must not be focusable), so it cannot be a literal\n              <div\n                key={look.id}\n                ref={(el) => {\n                  itemRefs.current[i] = el;\n                }}\n                onClick={() => {\n                  // Swallow the synthetic click that closes out a pan. The\n                  // keyboard path below deliberately skips this: `didMove`\n                  // survives until the next pointerdown, and gating Enter on it\n                  // left activation dead for as long as that took.\n                  if (drag.current.didMove) {\n                    return;\n                  }\n                  handleClickItem(i);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" || e.key === \" \") {\n                    e.preventDefault();\n                    handleClickItem(i);\n                  }\n                }}\n                role={isDuplicate ? undefined : \"button\"}\n                tabIndex={isDuplicate ? undefined : 0}\n                aria-hidden={isDuplicate || undefined}\n                // Keyed off look identity, not index: the 54 aria-hidden\n                // duplicates stay mouse-clickable, and opening one has to be\n                // reported by the one real button that names the same look.\n                // `cloneLook` preserves `lookNumber`, so it survives the cycles.\n                aria-expanded={\n                  isDuplicate ? undefined : selectedLook?.lookNumber === look.lookNumber\n                }\n                aria-label={\n                  isDuplicate ? undefined : `${look.name}, look number ${look.lookNumber}`\n                }\n                data-look-card={look.id}\n                className=\"focus-visible:ring-foreground/40 focus-visible:ring-offset-background absolute cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-2\"\n                style={{\n                  height: CARD_H,\n                  left: p ? p.x - CARD_W / 2 : 0,\n                  top: p ? p.y - CARD_H / 2 : 0,\n                  width: CARD_W,\n                  willChange: \"transform\",\n                }}\n              >\n                <LookFigure look={look} className=\"pointer-events-none size-full\" />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {/* Hint */}\n      <div\n        className=\"pointer-events-none absolute bottom-6 left-8 z-20\"\n        style={{\n          opacity: selectedIdx === null ? 1 : 0,\n          transition: \"opacity 0.5s ease\",\n        }}\n      >\n        <p className=\"text-muted-foreground text-[10px] tracking-[0.3em] uppercase\">\n          Drag to pan · scroll to zoom · click a look\n        </p>\n      </div>\n\n      {/* Selection chrome */}\n      {selectedLook && dims && (\n        <>\n          <button\n            type=\"button\"\n            ref={closeBtnRef}\n            onClick={handleClose}\n            aria-label=\"Close look\"\n            className=\"bg-background/85 border-foreground/15 hover:border-foreground/40 hover:bg-background absolute top-5 right-5 z-50 grid size-10 place-items-center rounded-full border shadow-md backdrop-blur-md transition-all\"\n          >\n            <X className=\"size-4\" />\n          </button>\n          <DetailPanel\n            key={selectedLook.id}\n            ref={detailPanelRef}\n            look={selectedLook}\n            containerW={dims.w}\n            containerH={dims.h}\n          />\n        </>\n      )}\n    </section>\n  );\n};\n","path":"/lookbook-camera.tsx","target":"uicapsule/lookbook-camera/lookbook-camera.tsx","type":"registry:file"},{"content":"/**\n * Lookbook data layer.\n *\n * Owns every constant the renderer reads: the eighteen base looks, the four\n * cycle permutations that expand them into 72 unique cards, the card/cell\n * geometry, and the deterministic grid layout helpers.\n *\n * Every look is one photograph, generated to a fixed framing (full body, model\n * centred, small margin above the head and below the shoes) so\n * `look-figure.tsx` can crop any piece out of any look with one set of boxes.\n */\n\nconst LOOK_IMAGE_BASE =\n  \"https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/lookbook-camera/looks\";\n\nexport type ItemCategory = \"Outerwear\" | \"Knitwear\" | \"Tops\" | \"Trousers\" | \"Skirt\" | \"Footwear\";\n\ninterface ItemBase {\n  id: string;\n  name: string;\n  category: ItemCategory;\n  material: string;\n  size: string;\n  priceCents: number;\n}\n\n/**\n * The slot is a literal discriminant, not a free string: it is what the detail\n * panel reads to crop the figure down to the piece being shown, so an item can\n * never point at a region of the drawing that does not exist.\n */\nexport type TopItem = ItemBase & { slot: \"top\" };\nexport type LowerItem = ItemBase & { slot: \"lower\" };\nexport type ShoeItem = ItemBase & { slot: \"shoes\" };\nexport type ClothingItem = TopItem | LowerItem | ShoeItem;\nexport type ItemSlot = ClothingItem[\"slot\"];\n\nexport interface Look {\n  id: string;\n  lookNumber: string;\n  name: string;\n  season: string;\n  accent: string;\n  imageUrl: string;\n  items: [TopItem, LowerItem, ShoeItem];\n}\n\nconst SS = \"Spring / Summer\";\nconst AW = \"Autumn / Winter\";\nconst RESORT = \"Resort\";\nconst PREFALL = \"Pre-Fall\";\n\nconst RAW_LOOKS: Omit<Look, \"imageUrl\">[] = [\n  {\n    accent: \"#4a4540\",\n    id: \"look-01\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"01-top\",\n        material: \"Worsted wool, silk lining\",\n        name: \"Worsted Wool Blazer\",\n        priceCents: 86_000,\n        size: \"M / 38R\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"01-lower\",\n        material: \"Crêpe de chine\",\n        name: \"Pleated Crepe Trouser\",\n        priceCents: 42_000,\n        size: \"32 / 30L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"01-shoes\",\n        material: \"Suede, leather sole\",\n        name: \"Suede Derby\",\n        priceCents: 39_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"01\",\n    name: \"Off-Hour Tailoring\",\n    season: SS,\n  },\n  {\n    accent: \"#1b1b1f\",\n    id: \"look-02\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"02-top\",\n        material: \"Garment-dyed organic cotton\",\n        name: \"Boxy Cotton Overshirt\",\n        priceCents: 38_000,\n        size: \"M\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"02-lower\",\n        material: \"Cotton drill\",\n        name: \"Tapered Drill Trouser\",\n        priceCents: 34_000,\n        size: \"32 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"02-shoes\",\n        material: \"Full-grain leather, rubber sole\",\n        name: \"Leather Court Sneaker\",\n        priceCents: 42_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"02\",\n    name: \"Studio Black\",\n    season: SS,\n  },\n  {\n    accent: \"#6c7257\",\n    id: \"look-03\",\n    items: [\n      {\n        category: \"Knitwear\",\n        id: \"03-top\",\n        material: \"Lambswool, hand-linked rib\",\n        name: \"Chunky Lambswool Sweater\",\n        priceCents: 32_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"03-lower\",\n        material: \"Brushed virgin wool\",\n        name: \"Brushed Wool Trouser\",\n        priceCents: 39_000,\n        size: \"33 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"03-shoes\",\n        material: \"Calf leather, shearling lining\",\n        name: \"Shearling Hiker Boot\",\n        priceCents: 58_000,\n        size: \"EU 43\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"03\",\n    name: \"Northern Wool\",\n    season: AW,\n  },\n  {\n    accent: \"#5b2434\",\n    id: \"look-04\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"04-top\",\n        material: \"Cotton velvet, cupro lining\",\n        name: \"Velvet Dinner Jacket\",\n        priceCents: 94_000,\n        size: \"M / 38R\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"04-lower\",\n        material: \"Silk-wool blend\",\n        name: \"Silk-Wool Trouser\",\n        priceCents: 46_000,\n        size: \"32 / 31L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"04-shoes\",\n        material: \"Patent calf, leather sole\",\n        name: \"Patent Leather Loafer\",\n        priceCents: 49_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"04\",\n    name: \"Velvet Hour\",\n    season: RESORT,\n  },\n  {\n    accent: \"#8a8783\",\n    id: \"look-05\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"05-top\",\n        material: \"Washed cotton canvas\",\n        name: \"Washed Field Jacket\",\n        priceCents: 44_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"05-lower\",\n        material: \"Heavyweight cotton twill\",\n        name: \"Relaxed Carpenter Pant\",\n        priceCents: 32_000,\n        size: \"34 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"05-shoes\",\n        material: \"Organic canvas, gum sole\",\n        name: \"Canvas Court Trainer\",\n        priceCents: 21_000,\n        size: \"EU 43\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"05\",\n    name: \"Concrete Stone\",\n    season: SS,\n  },\n  {\n    accent: \"#a3b18a\",\n    id: \"look-06\",\n    items: [\n      {\n        category: \"Tops\",\n        id: \"06-top\",\n        material: \"Washed European linen\",\n        name: \"Linen Camp Shirt\",\n        priceCents: 24_000,\n        size: \"M\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"06-lower\",\n        material: \"Linen-viscose blend\",\n        name: \"Drawstring Linen Trouser\",\n        priceCents: 26_000,\n        size: \"M\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"06-shoes\",\n        material: \"Vegetable-tanned leather\",\n        name: \"Woven Leather Sandal\",\n        priceCents: 28_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"06\",\n    name: \"Spring Field\",\n    season: SS,\n  },\n  {\n    accent: \"#2c3a4a\",\n    id: \"look-07\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"07-top\",\n        material: \"Bonded cotton, taped seams\",\n        name: \"Bonded Trench Coat\",\n        priceCents: 78_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"07-lower\",\n        material: \"Technical nylon blend\",\n        name: \"Water-Repellent Trouser\",\n        priceCents: 38_000,\n        size: \"32 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"07-shoes\",\n        material: \"Vulcanised rubber, leather\",\n        name: \"Rubberised Chelsea Boot\",\n        priceCents: 44_000,\n        size: \"EU 43\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"07\",\n    name: \"Rain Coat\",\n    season: AW,\n  },\n  {\n    accent: \"#b5896a\",\n    id: \"look-08\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"08-top\",\n        material: \"Camel hair, horn buttons\",\n        name: \"Camel Hair Overcoat\",\n        priceCents: 112_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"08-lower\",\n        material: \"Cotton-linen twill\",\n        name: \"Pleated Cotton Chino\",\n        priceCents: 30_000,\n        size: \"33 / 31L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"08-shoes\",\n        material: \"Oiled suede, crepe sole\",\n        name: \"Suede Desert Boot\",\n        priceCents: 33_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"08\",\n    name: \"Sand Dune\",\n    season: RESORT,\n  },\n  {\n    accent: \"#1a1a2c\",\n    id: \"look-09\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"09-top\",\n        material: \"Wool barathea, satin lapel\",\n        name: \"Satin-Lapel Tuxedo Jacket\",\n        priceCents: 98_000,\n        size: \"M / 38R\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"09-lower\",\n        material: \"Wool, satin side stripe\",\n        name: \"Tuxedo Trouser\",\n        priceCents: 44_000,\n        size: \"32 / 31L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"09-shoes\",\n        material: \"Patent leather, grosgrain bow\",\n        name: \"Opera Pump\",\n        priceCents: 47_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"09\",\n    name: \"Midnight Cut\",\n    season: PREFALL,\n  },\n  {\n    accent: \"#b08bb8\",\n    id: \"look-10\",\n    items: [\n      {\n        category: \"Tops\",\n        id: \"10-top\",\n        material: \"Printed cupro\",\n        name: \"Printed Cupro Shirt\",\n        priceCents: 27_000,\n        size: \"M\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"10-lower\",\n        material: \"Tencel-linen blend\",\n        name: \"Cropped Wide Trouser\",\n        priceCents: 29_000,\n        size: \"S\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"10-shoes\",\n        material: \"Nappa leather\",\n        name: \"Block-Heel Mule\",\n        priceCents: 31_000,\n        size: \"EU 39\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"10\",\n    name: \"Garden Party\",\n    season: SS,\n  },\n  {\n    accent: \"#7a6448\",\n    id: \"look-11\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"11-top\",\n        material: \"Waxed cotton, corduroy collar\",\n        name: \"Waxed Chore Jacket\",\n        priceCents: 42_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"11-lower\",\n        material: \"Cotton canvas, reinforced knee\",\n        name: \"Double-Knee Work Pant\",\n        priceCents: 28_000,\n        size: \"34 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"11-shoes\",\n        material: \"Oiled leather, wedge sole\",\n        name: \"Moc-Toe Work Boot\",\n        priceCents: 41_000,\n        size: \"EU 43\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"11\",\n    name: \"Workwear\",\n    season: AW,\n  },\n  {\n    accent: \"#b5953a\",\n    id: \"look-12\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"12-top\",\n        material: \"Metallic lamé, cupro lining\",\n        name: \"Lamé Bomber Jacket\",\n        priceCents: 64_000,\n        size: \"M\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"12-lower\",\n        material: \"Fluid satin\",\n        name: \"Liquid Satin Trouser\",\n        priceCents: 38_000,\n        size: \"S\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"12-shoes\",\n        material: \"Laminated leather\",\n        name: \"Metallic Strap Heel\",\n        priceCents: 36_000,\n        size: \"EU 39\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"12\",\n    name: \"Evening Glow\",\n    season: RESORT,\n  },\n  {\n    accent: \"#c8b9a0\",\n    id: \"look-13\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"13-top\",\n        material: \"Slubbed European linen\",\n        name: \"Unstructured Linen Blazer\",\n        priceCents: 52_000,\n        size: \"M / 38R\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"13-lower\",\n        material: \"Heavy Irish linen\",\n        name: \"Wide Linen Trouser\",\n        priceCents: 28_000,\n        size: \"33 / 30L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"13-shoes\",\n        material: \"Suede, jute sole\",\n        name: \"Leather Espadrille\",\n        priceCents: 19_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"13\",\n    name: \"Linen Hours\",\n    season: SS,\n  },\n  {\n    accent: \"#3f4855\",\n    id: \"look-14\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"14-top\",\n        material: \"Brushed wool flannel\",\n        name: \"Flannel Suit Jacket\",\n        priceCents: 88_000,\n        size: \"M / 38R\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"14-lower\",\n        material: \"Brushed wool flannel\",\n        name: \"Flannel Suit Trouser\",\n        priceCents: 42_000,\n        size: \"32 / 31L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"14-shoes\",\n        material: \"Calf leather, leather sole\",\n        name: \"Leather Monk Strap\",\n        priceCents: 46_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"14\",\n    name: \"Slate Tailoring\",\n    season: AW,\n  },\n  {\n    accent: \"#4a5c3a\",\n    id: \"look-15\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"15-top\",\n        material: \"Recycled nylon, jersey lining\",\n        name: \"Panelled Track Jacket\",\n        priceCents: 34_000,\n        size: \"M\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"15-lower\",\n        material: \"Brushed-back jersey\",\n        name: \"Tapered Track Pant\",\n        priceCents: 24_000,\n        size: \"M\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"15-shoes\",\n        material: \"Suede-mesh, EVA sole\",\n        name: \"Suede Mesh Runner\",\n        priceCents: 29_000,\n        size: \"EU 43\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"15\",\n    name: \"Track Side\",\n    season: PREFALL,\n  },\n  {\n    accent: \"#7c5a3a\",\n    id: \"look-16\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"16-top\",\n        material: \"Cotton corduroy, elbow patches\",\n        name: \"Corduroy Blazer\",\n        priceCents: 48_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"16-lower\",\n        material: \"Wool gabardine\",\n        name: \"Pleated Wool Trouser\",\n        priceCents: 36_000,\n        size: \"33 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"16-shoes\",\n        material: \"Burnished calf, leather sole\",\n        name: \"Penny Loafer\",\n        priceCents: 38_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"16\",\n    name: \"Library\",\n    season: AW,\n  },\n  {\n    accent: \"#1f3a5f\",\n    id: \"look-17\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"17-top\",\n        material: \"Melton wool, anchor buttons\",\n        name: \"Double-Breasted Peacoat\",\n        priceCents: 68_000,\n        size: \"L\",\n        slot: \"top\",\n      },\n      {\n        category: \"Trousers\",\n        id: \"17-lower\",\n        material: \"Cotton-wool twill\",\n        name: \"Sailor Trouser\",\n        priceCents: 32_000,\n        size: \"32 / 32L\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"17-shoes\",\n        material: \"Leather, siped sole\",\n        name: \"Hand-Sewn Deck Shoe\",\n        priceCents: 27_000,\n        size: \"EU 42\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"17\",\n    name: \"Maritime\",\n    season: RESORT,\n  },\n  {\n    accent: \"#c98a8a\",\n    id: \"look-18\",\n    items: [\n      {\n        category: \"Outerwear\",\n        id: \"18-top\",\n        material: \"Cotton bouclé\",\n        name: \"Cropped Bouclé Jacket\",\n        priceCents: 46_000,\n        size: \"S\",\n        slot: \"top\",\n      },\n      {\n        category: \"Skirt\",\n        id: \"18-lower\",\n        material: \"Stretch cotton twill\",\n        name: \"A-Line Midi Skirt\",\n        priceCents: 28_000,\n        size: \"S\",\n        slot: \"lower\",\n      },\n      {\n        category: \"Footwear\",\n        id: \"18-shoes\",\n        material: \"Nappa leather\",\n        name: \"Slingback Flat\",\n        priceCents: 33_000,\n        size: \"EU 38\",\n        slot: \"shoes\",\n      },\n    ],\n    lookNumber: \"18\",\n    name: \"Powder Rose\",\n    season: SS,\n  },\n];\n\n/**\n * Cycle expansion. The 18 base looks are emitted four times into a single\n * LOOKS array of length 72. Each cycle uses a different permutation (generated\n * offline by simulated annealing against an adjacency-cost objective for column\n * counts 8..15) so duplicates never sit adjacent at any chooseCols result.\n * Cycles 1-3 suffix every id so React keys stay unique while cycle 0 keeps the\n * base objects by reference.\n */\nconst CYCLE_ORDERS: number[][] = [\n  [0, 8, 2, 16, 4, 5, 11, 7, 1, 10, 17, 6, 12, 13, 14, 15, 3, 9],\n  [13, 15, 3, 8, 9, 2, 16, 7, 5, 0, 1, 10, 6, 17, 11, 4, 14, 12],\n  [17, 4, 13, 8, 14, 12, 2, 16, 9, 3, 15, 6, 5, 11, 7, 1, 0, 10],\n  [5, 10, 4, 1, 0, 13, 14, 2, 8, 15, 12, 3, 17, 7, 16, 6, 9, 11],\n];\n\nconst CYCLE_SUFFIXES = [\"\", \"b\", \"c\", \"d\"];\n\nexport const BASE_LOOKS: Look[] = RAW_LOOKS.map((look) =>\n  Object.assign(look, { imageUrl: `${LOOK_IMAGE_BASE}/${look.id}.jpg` }),\n);\n\nconst cloneLook = (look: Look, suffix: string): Look => {\n  const [top, lower, shoes] = look.items;\n  return {\n    ...look,\n    id: `${look.id}-${suffix}`,\n    items: [\n      { ...top, id: `${top.id}-${suffix}` },\n      { ...lower, id: `${lower.id}-${suffix}` },\n      { ...shoes, id: `${shoes.id}-${suffix}` },\n    ],\n  };\n};\n\nexport const LOOKS: Look[] = CYCLE_ORDERS.flatMap((order, cycleIdx) => {\n  const suffix = CYCLE_SUFFIXES[cycleIdx] ?? \"\";\n  const out: Look[] = [];\n  for (const baseIdx of order) {\n    const base = BASE_LOOKS[baseIdx];\n    if (!base) {\n      continue;\n    }\n    // Cycle 0 keeps the base objects by reference — only 1-3 clone, which is\n    // what keeps `look-01` unsuffixed and every key unique.\n    out.push(cycleIdx === 0 ? base : cloneLook(base, suffix));\n  }\n  return out;\n});\n\nexport const lookTotal = (look: Look): number => {\n  let sum = 0;\n  for (const it of look.items) {\n    sum += it.priceCents;\n  }\n  return sum;\n};\n\nexport const fmtPrice = (cents: number): string =>\n  `$${Math.round(cents / 100).toLocaleString(\"en-US\")}`;\n\n/* ── Layout geometry (world coordinates) ─────────────────────────── */\n\nexport const CARD_W = 188;\nexport const CARD_H = 282;\nexport const CELL_MARGIN_X = 22;\nexport const CELL_MARGIN_Y = 28;\n\nexport interface Pos {\n  x: number;\n  y: number;\n  rot: number;\n}\n\n/**\n * Offset that moves a world coordinate onto the tiled copy nearest `centre`.\n *\n * The world is a torus: every card also exists at `v + k * tile` for every\n * integer k. Picking the k closest to the camera's world centre is the entire\n * edgeless mechanic — the RAF loop applies it per card per frame, and a\n * selection uses it so the camera flies to the copy already on screen rather\n * than to the canonical one a tile away.\n */\nexport const wrapOffset = (v: number, centre: number, tile: number): number =>\n  -Math.round((v - centre) / tile) * tile;\n\n/** Stable pseudo-random in [0, 1) from a numeric seed. */\nconst hash = (n: number): number => {\n  const s = Math.sin(n * 12.9898 + 78.233) * 43_758.5453;\n  return s - Math.floor(s);\n};\n\n/**\n * Pick the column count whose rendered pixel-grid aspect mirrors the viewport.\n * Only perfect divisions are considered so the wrap-tiled world never reveals\n * an empty cell.\n */\nexport const chooseCols = (count: number, viewportAspect: number): number => {\n  const cardAspect = CARD_W / CARD_H;\n  const target = viewportAspect / cardAspect;\n  let best = 1;\n  let bestDiff = Infinity;\n  for (let cols = 1; cols <= count; cols += 1) {\n    if (count % cols !== 0) {\n      continue;\n    }\n    const rows = count / cols;\n    const diff = Math.abs(cols / rows - target);\n    if (diff < bestDiff) {\n      bestDiff = diff;\n      best = cols;\n    }\n  }\n  return best;\n};\n\n/**\n * Deterministic grid with mild per-cell jitter. Each item sits at its cell\n * centre plus a hashed offset (±0.09 cellW, ±0.07 cellH) and a rotation of up\n * to ±2°. Positions are stable across renders.\n */\nexport const layoutPositions = (\n  worldW: number,\n  worldH: number,\n  cols: number,\n  count: number,\n): Pos[] => {\n  const rows = Math.ceil(count / cols);\n  const cellW = worldW / cols;\n  const cellH = worldH / rows;\n  const out: Pos[] = [];\n  for (let i = 0; i < count; i += 1) {\n    const col = i % cols;\n    const row = Math.floor(i / cols);\n    const seed = i * 37 + 7;\n    const ox = (hash(seed) - 0.5) * cellW * 0.18;\n    const oy = (hash(seed + 1) - 0.5) * cellH * 0.14;\n    const rot = (hash(seed + 2) - 0.5) * 4;\n    out.push({\n      rot,\n      x: col * cellW + cellW / 2 + ox,\n      y: row * cellH + cellH / 2 + oy,\n    });\n  }\n  return out;\n};\n","path":"/lookbook-data.ts","target":"uicapsule/lookbook-camera/lookbook-data.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport { LookbookCamera } from \"./lookbook-camera\";\n\nconst Preview = () => (\n  <div className=\"h-dvh w-full overflow-hidden\">\n    <LookbookCamera />\n  </div>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/lookbook-camera/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/lookbook-camera","name":"lookbook-camera","registryDependencies":[],"title":"Lookbook Camera","type":"registry:block"}