# Lookbook Camera

> 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.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/lookbook-camera).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/lookbook-camera
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/lookbook-camera-light-abf46466103607eb.png
- Registry item: https://uicapsule.com/r/lookbook-camera.json
- Categories: cards-grids
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/lookbook-camera.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `gsap`, `lucide-react`

## Files

### `/detail-panel.tsx`

```tsx
"use client";

import type { CSSProperties, RefObject } from "react";
import { Check, ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
import { useLayoutEffect, useRef, useState } from "react";

import type { Look } from "./lookbook-data";
import { LookFigure } from "./look-figure";
import { fmtPrice, lookTotal } from "./lookbook-data";

/**
 * Below this container width the panel always drops underneath the focused
 * card instead of sitting beside it. It is a floor, not the whole rule —
 * `usesSideLayout` is what both this panel and the host actually branch on.
 */
export const NARROW_BREAKPOINT = 920;

/** Gap the beside-card layout wants between the card and the panel. */
const PANEL_GAP = 40;
/** Smallest gap the beside-card layout will accept before giving up on it. */
const PANEL_MIN_GAP = 16;
/** Space kept between the panel and the right edge of the frame. */
const PANEL_EDGE = 16;

const panelWidth = (containerW: number): number => Math.min(420, containerW * 0.36);

/**
 * Right edge of the focused card in the beside-card layout: it lands centred
 * on 0.34w and renders 0.75h tall, so it is 0.25h wide either side.
 */
const focusedCardRight = (containerW: number, containerH: number): number =>
  0.34 * containerW + 0.25 * containerH;

/**
 * Whether the panel sits beside the focused card rather than beneath it.
 *
 * Width alone is not enough. The focused card is sized off the container
 * HEIGHT, so a tall-narrow desktop frame keeps growing it until the panel no
 * longer fits alongside — at which point the right-edge clamp below drags the
 * panel back on top of the card it describes (measured: 15px of overlap at
 * 920x1100). Falling back to the sheet layout is only safe because the host
 * reads this same predicate to choose where to fly the camera; the two must
 * never disagree about which layout is in play.
 */
export const usesSideLayout = (containerW: number, containerH: number): boolean => {
  if (containerW < NARROW_BREAKPOINT) {
    return false;
  }
  const rightEdge =
    focusedCardRight(containerW, containerH) + PANEL_MIN_GAP + panelWidth(containerW) + PANEL_EDGE;
  return rightEdge <= containerW;
};

const SLOT_TAG = { lower: "B", shoes: "C", top: "A" } as const;

/** Utilities-layer scrollbar suppression — the app's base layer styles one in. */
const NO_SCROLLBAR =
  "[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden";

export interface DetailPanelProps {
  look: Look;
  /** Measured container box — never `window`, so the panel matches the frame. */
  containerW: number;
  containerH: number;
  /** The glass card itself — the host choreographs its entrance and exit. */
  ref: RefObject<HTMLDivElement | null>;
}

export const DetailPanel = ({ look, containerW, containerH, ref }: DetailPanelProps) => {
  const [activeIdx, setActiveIdx] = useState(0);
  const [bag, setBag] = useState<ReadonlySet<string>>(new Set<string>());

  const narrow = !usesSideLayout(containerW, containerH);

  const firstPaint = useRef(true);
  // Hide before first paint so the open animation never flashes, and so
  // re-renders (thumbnail switches) can't reset opacity mid-view.
  useLayoutEffect(() => {
    if (firstPaint.current && ref.current) {
      ref.current.style.opacity = "0";
    }
    firstPaint.current = false;
    return () => {
      firstPaint.current = true;
    };
  }, [ref]);

  // Already ordered top -> lower -> shoes by the tuple's own type.
  const orderedItems = look.items;
  const activeItem = orderedItems[activeIdx] ?? orderedItems[0];
  const isActiveAdded = bag.has(activeItem.id);
  const itemsCount = orderedItems.length;
  const tag = SLOT_TAG[activeItem.slot];

  const toggle = (itemId: string) => {
    setBag((prev) => {
      const next = new Set(prev);
      if (next.has(itemId)) {
        next.delete(itemId);
      } else {
        next.add(itemId);
      }
      return next;
    });
  };
  const prevItem = () => {
    setActiveIdx((i) => (i - 1 + itemsCount) % itemsCount);
  };
  const nextItem = () => {
    setActiveIdx((i) => (i + 1) % itemsCount);
  };

  const panelW = panelWidth(containerW);
  // Start PANEL_GAP past the card's right edge and never run off the frame.
  // `usesSideLayout` has already guaranteed the clamp cannot pull this back
  // over the card — if it could, we would not be in this layout.
  const panelLeft = Math.min(
    focusedCardRight(containerW, containerH) + PANEL_GAP,
    containerW - panelW - PANEL_EDGE,
  );
  // In the narrow layout the card sits at 0.2h + 28 and is 0.18h tall either
  // side, so 0.38h + 38 clears its bottom edge.
  const narrowTop = Math.round(0.38 * containerH + 38);

  const wrapperStyle: CSSProperties = narrow
    ? { bottom: 8, left: 8, right: 8, top: narrowTop }
    : {
        left: Math.round(panelLeft),
        top: "50%",
        transform: "translateY(-50%)",
        width: Math.round(panelW),
      };

  const maxCardHeight = Math.round(containerH * 0.92);
  // The narrow sheet only owns ~0.6h, and the hero competes with the info,
  // the add button and the total for it — keep it small enough that the
  // primary action stays above the fold on a short frame.
  const heroHeight = narrow
    ? Math.round(Math.min(containerH * 0.2, containerW * 0.45))
    : Math.round(containerH * 0.44);

  const cardStyle: CSSProperties = {
    WebkitBackdropFilter: "blur(40px) saturate(180%)",
    backdropFilter: "blur(40px) saturate(180%)",
    background:
      "linear-gradient(135deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0.08) 55%, rgba(255,255,255,0.14) 100%)",
    border: "1px solid rgba(255,255,255,0.28)",
    boxShadow: [
      "inset 0 1px 0 rgba(255,255,255,0.55)",
      "inset 0 0 0 1px rgba(255,255,255,0.08)",
      "inset 0 -1px 0 rgba(0,0,0,0.04)",
      "0 30px 80px -16px rgba(0,0,0,0.32)",
      "0 12px 28px -8px rgba(0,0,0,0.16)",
      `0 0 0 1px ${look.accent}10`,
    ].join(", "),
    height: narrow ? "100%" : undefined,
    maxHeight: narrow ? "100%" : maxCardHeight,
  };

  return (
    <div className="absolute z-40" style={wrapperStyle}>
      <div ref={ref} className="relative overflow-hidden rounded-[28px]" style={cardStyle}>
        {/* Top sheen */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-x-0 top-0 h-32"
          style={{
            background:
              "linear-gradient(180deg, rgba(255,255,255,0.32) 0%, rgba(255,255,255,0.08) 40%, rgba(255,255,255,0) 100%)",
          }}
        />
        {/* Accent halo */}
        <div
          aria-hidden
          className="pointer-events-none absolute -bottom-12 -left-12 size-56 rounded-full opacity-25 blur-3xl"
          style={{ background: look.accent }}
        />

        <div
          className={`relative z-10 overflow-y-auto ${NO_SCROLLBAR} ${narrow ? "h-full" : ""}`}
          style={{ maxHeight: narrow ? undefined : maxCardHeight }}
        >
          {/* Hero — a crop of the very same drawing the card shows */}
          <div
            className="relative mb-3 w-full"
            style={{ background: `${look.accent}14`, height: heroHeight }}
            data-detail-anim
          >
            {orderedItems.map((item, i) => (
              <div
                key={item.id}
                className={`absolute inset-0 transition-opacity duration-[450ms] ease-out ${
                  i === activeIdx ? "opacity-100" : "opacity-0"
                }`}
              >
                {/* The narrow sheet's hero is a ~2.3:1 letterbox while every
                    crop is taller than it is wide, so "meet" would strand the
                    drawing at ~37% of the width against flat accent fill.
                    Fill it instead; the thumbnails below still show each crop
                    whole. The wide layout is close enough to square to keep
                    the full figure. */}
                <LookFigure
                  look={look}
                  crop={item.slot}
                  fit={narrow ? "slice" : "meet"}
                  className="size-full"
                />
              </div>
            ))}

            {/* Progress pips */}
            <div className="absolute top-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5">
              {orderedItems.map((item, i) => (
                <span
                  key={item.id}
                  className="block h-[3px] rounded-full transition-all duration-300"
                  style={{
                    background: i === activeIdx ? look.accent : "rgba(0,0,0,0.18)",
                    width: i === activeIdx ? 18 : 5,
                  }}
                />
              ))}
            </div>

            {/* Look ID badge */}
            <span
              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"
              style={{ borderColor: `${look.accent}66`, color: look.accent }}
            >
              N° {look.lookNumber}
            </span>

            {/* Category badge */}
            <span
              // top-3, not the source's top-9: on a short hero the arrows sit
              // at 50% and ran straight through a badge pinned lower down.
              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"
              style={{ background: `${look.accent}e0` }}
            >
              {tag} · {activeItem.category}
            </span>

            {/* Prev / next */}
            <button
              type="button"
              onClick={prevItem}
              aria-label="Previous piece"
              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"
            >
              <ChevronLeft className="size-3.5" />
            </button>
            <button
              type="button"
              onClick={nextItem}
              aria-label="Next piece"
              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"
            >
              <ChevronRight className="size-3.5" />
            </button>

            {/* Floating thumbnail pill */}
            <div
              className="pointer-events-none absolute bottom-0 left-1/2 z-20"
              style={{ transform: "translate(-50%, 50%)" }}
            >
              <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">
                {orderedItems.map((item, i) => {
                  const isActive = i === activeIdx;
                  const inBag = bag.has(item.id);
                  return (
                    <div key={item.id} className="group/thumb relative size-[72px]">
                      <button
                        type="button"
                        onClick={() => setActiveIdx(i)}
                        aria-pressed={isActive}
                        aria-label={`View ${item.name}`}
                        className="absolute inset-0 overflow-hidden rounded-xl transition-all duration-300"
                        style={{
                          background: `${look.accent}12`,
                          boxShadow: isActive
                            ? "inset 0 0 0 1.5px var(--foreground, #111)"
                            : "inset 0 0 0 1px rgba(0,0,0,0.06)",
                          opacity: isActive ? 1 : 0.6,
                        }}
                      >
                        <LookFigure
                          look={look}
                          crop={item.slot}
                          className={`size-full transition-transform duration-500 ${
                            isActive ? "scale-100" : "scale-[1.06] group-hover/thumb:scale-100"
                          }`}
                        />
                      </button>
                      <button
                        type="button"
                        onClick={(e) => {
                          e.stopPropagation();
                          toggle(item.id);
                        }}
                        aria-label={inBag ? `Remove ${item.name}` : `Add ${item.name}`}
                        aria-pressed={inBag}
                        className={`absolute top-1 right-1 z-10 grid size-4 place-items-center rounded-full transition-all ${
                          inBag
                            ? "bg-foreground text-background hover:bg-foreground/85 opacity-100"
                            : "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"
                        }`}
                      >
                        {inBag ? <Check className="size-2.5" /> : <Plus className="size-2.5" />}
                      </button>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>

          {/* Active item info */}
          <div className="px-5 pt-12" key={`info-${activeItem.id}`} data-detail-anim>
            <div className="flex items-baseline justify-between gap-3">
              <h4 className="lbk-display truncate text-[19px] leading-tight">{activeItem.name}</h4>
              <span className="lbk-display shrink-0 text-[18px] tabular-nums">
                {fmtPrice(activeItem.priceCents)}
              </span>
            </div>
            <p className="mt-1 truncate text-[11px] opacity-60">{activeItem.material}</p>
            <div className="mt-2.5 flex items-center gap-2 text-[9px] tracking-[0.2em] uppercase opacity-70">
              <span className="border-foreground/15 rounded-full border px-2 py-0.5">
                Size {activeItem.size}
              </span>
              <span>·</span>
              <span>{look.season}</span>
            </div>
          </div>

          {/* Add this piece */}
          <div className="mt-4 px-5" data-detail-anim>
            <button
              type="button"
              onClick={() => toggle(activeItem.id)}
              aria-pressed={isActiveAdded}
              className={`group/add w-full rounded-full border py-2.5 text-[10.5px] tracking-[0.22em] uppercase transition-all ${
                isActiveAdded
                  ? "border-foreground bg-foreground/[0.06] hover:bg-foreground/[0.12]"
                  : "border-foreground/20 hover:border-foreground/45 hover:bg-foreground/[0.04]"
              }`}
            >
              {isActiveAdded ? (
                <span className="flex items-center justify-center gap-2">
                  <Check className="size-3 group-hover/add:hidden" />
                  <X className="hidden size-3 group-hover/add:block" />
                  <span className="group-hover/add:hidden">
                    Added · {fmtPrice(activeItem.priceCents)}
                  </span>
                  <span className="hidden group-hover/add:inline">Remove from bag</span>
                </span>
              ) : (
                `Add this piece · ${fmtPrice(activeItem.priceCents)}`
              )}
            </button>
          </div>

          {/* Divider */}
          <div className="mt-4 mb-3 flex items-center gap-2 px-5" data-detail-anim>
            <div className="bg-foreground/10 h-px flex-1" />
            <span className="size-1 rounded-full" style={{ background: look.accent }} />
            <div className="bg-foreground/10 h-px flex-1" />
          </div>

          {/* Price of the whole look — the bag counter beside it is progress
              towards owning it, not a subtotal, so this figure is constant. */}
          <div className="px-5 pb-6" data-detail-anim>
            <div className="mb-2 flex items-baseline justify-between px-0.5">
              <span className="text-[9px] tracking-[0.28em] uppercase opacity-55">
                Complete look · {bag.size}/{itemsCount} added
              </span>
              <span className="lbk-display text-[20px] tabular-nums">
                {fmtPrice(lookTotal(look))}
              </span>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};
```

### `/look-figure.tsx`

```tsx
"use client";

import type { ItemSlot, Look } from "./lookbook-data";

/**
 * Every card in the lookbook is this component: the look's photograph placed
 * in a fixed 200x300 user-space box. The card renders the whole box; the detail
 * panel renders a crop of it (`CROPS`), so paging through a look's three pieces
 * is literally a camera move inside the same photograph.
 *
 * Photos are all framed the same way (full body, small margin above the head
 * and below the shoes, model centred), which is what lets one set of crops
 * serve all eighteen looks.
 */

const VIEW_W = 200;
const VIEW_H = 300;
const FULL_VIEW_BOX = `0 0 ${VIEW_W} ${VIEW_H}`;

/** Slot -> the region of the photograph that piece occupies. */
const CROPS = {
  lower: "40 125 120 140",
  shoes: "45 240 110 55",
  top: "30 10 140 175",
} as const satisfies Record<ItemSlot, string>;

export interface LookFigureProps {
  look: Look;
  /** Omit for the whole figure; pass a slot to crop to that piece. */
  crop?: ItemSlot;
  /** "meet" fits the crop inside the box, "slice" fills it. */
  fit?: "meet" | "slice";
  className?: string;
}

export const LookFigure = ({ look, crop, fit = "meet", className }: LookFigureProps) => {
  const viewBox = crop === undefined ? FULL_VIEW_BOX : CROPS[crop];

  return (
    <svg
      viewBox={viewBox}
      preserveAspectRatio={`xMidYMid ${fit}`}
      className={className}
      aria-hidden
      focusable="false"
    >
      <rect width={VIEW_W} height={VIEW_H} fill={look.accent} fillOpacity={0.08} />
      <image
        href={look.imageUrl}
        width={VIEW_W}
        height={VIEW_H}
        preserveAspectRatio="xMidYMid slice"
      />
    </svg>
  );
};
```

### `/lookbook-camera.tsx`

```tsx
"use client";

import { gsap } from "gsap";
import { X } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";

import type { Pos } from "./lookbook-data";
import { DetailPanel, NARROW_BREAKPOINT, usesSideLayout } from "./detail-panel";
import { LookFigure } from "./look-figure";
import {
  BASE_LOOKS,
  CARD_H,
  CARD_W,
  CELL_MARGIN_X,
  CELL_MARGIN_Y,
  chooseCols,
  layoutPositions,
  LOOKS,
  wrapOffset,
} from "./lookbook-data";

/* ── Tunable constants ───────────────────────────────────────────── */

const CAMERA_INTRO_FACTOR = 0.52;
const CAMERA_REST_FACTOR = 1;

const NEIGHBOUR_HEIGHT_FRACTION = 0.3;
const NEIGHBOUR_HEIGHT_FRACTION_NARROW = 0.15;
const SELECTION_HEIGHT_FRACTION = 0.75;
const SELECTION_HEIGHT_FRACTION_NARROW = 0.36;
const POST_DESELECT_HEIGHT_FRACTION = 0.375;

const DRAG_THRESHOLD_PX = 6;
const WHEEL_SENSITIVITY = 0.0018;
const MAX_CAMERA_SCALE = 6;
const DEFAULT_MIN_ZOOM_CARD_HEIGHT = 240;
const DEFAULT_MIN_ZOOM_CARD_HEIGHT_NARROW = 200;
const DEFAULT_INERTIA_FRICTION = 0.93;
const AUTO_DRIFT_PX_PER_FRAME = 0.45;

const PARALLAX_RANGE = 320;
const PARALLAX_STRENGTH = 9;
const ENTRANCE_STAGGER = 0.04;

/** One cycle — the only cards that are distinct looks, so the only tab stops. */
const KEYBOARD_REACHABLE = BASE_LOOKS.length;

/* ── Types ───────────────────────────────────────────────────────── */

interface Dims {
  w: number;
  h: number;
  worldW: number;
  worldH: number;
  baseFit: number;
}

/** The exact transform the RAF loop last wrote to a card, in GSAP's own props. */
interface WrittenTransform {
  x: number;
  y: number;
  rotation: number;
  scale: number;
}

interface DragState {
  active: boolean;
  didMove: boolean;
  pointerId: number;
  startClientX: number;
  startClientY: number;
  lastSx: number;
  lastSy: number;
  prevSx: number;
  prevSy: number;
  vt: number;
  vx: number;
  vy: number;
  worldAnchorX: number;
  worldAnchorY: number;
  inertiaActive: boolean;
}

const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));

/**
 * Where the focused card's centre lands in container space.
 *
 * This defers to the detail panel's own layout predicate on purpose: the source
 * switched the landing point at 920 but the panel layout at 768, which left a
 * dead band where the panel was positioned beside a card that had already moved
 * to centre — at 800x600 the panel sat on top of the thing it described.
 */
const selectionTarget = (w: number, h: number) => {
  if (usesSideLayout(w, h)) {
    return { x: 0.34 * w, y: 0.5 * h };
  }
  return { x: 0.5 * w, y: 0.2 * h + 28 };
};

/** Everything a focused look needs, solved from one layout decision. */
interface SelectionGeometry {
  /** Camera scale while the look is open. */
  cameraScale: number;
  /** Camera translation that puts the card on `selectionTarget`. */
  cameraX: number;
  cameraY: number;
  /** Extra scale applied to the card itself, on top of the camera's. */
  cardScale: number;
  /** Card translation onto the wrapped copy nearest the camera. */
  tileOffX: number;
  tileOffY: number;
}

/**
 * Solve the open-look geometry for a container size and a camera world centre.
 *
 * The card's SIZE and the card's POSITION must come from the same
 * `usesSideLayout` verdict the detail panel branches on, or a frame that gets
 * the sheet layout still flies in a card sized for the wide one and the panel
 * lands on top of the thing it describes. Keeping both in here is what stops
 * the two from drifting apart — including across a resize, which re-solves.
 */
const solveSelection = (d: Dims, pos: Pos, worldCx: number, worldCy: number): SelectionGeometry => {
  const narrow = !usesSideLayout(d.w, d.h);
  const neighbourFraction = narrow ? NEIGHBOUR_HEIGHT_FRACTION_NARROW : NEIGHBOUR_HEIGHT_FRACTION;
  const selectionFraction = narrow ? SELECTION_HEIGHT_FRACTION_NARROW : SELECTION_HEIGHT_FRACTION;
  const cameraScale = (d.h * neighbourFraction) / CARD_H;
  const target = selectionTarget(d.w, d.h);
  const tileOffX = wrapOffset(pos.x, worldCx, d.worldW);
  const tileOffY = wrapOffset(pos.y, worldCy, d.worldH);
  return {
    cameraScale,
    cameraX: target.x - (pos.x + tileOffX) * cameraScale,
    cameraY: target.y - (pos.y + tileOffY) * cameraScale,
    cardScale: selectionFraction / neighbourFraction,
    tileOffX,
    tileOffY,
  };
};

/** Fade every card except the one opening and the one it replaces. */
const dimOtherCards = (
  refs: (HTMLDivElement | null)[],
  idx: number,
  prevIdx: number | null,
): void => {
  for (let i = 0; i < LOOKS.length; i += 1) {
    if (i === idx || i === prevIdx) {
      continue;
    }
    const el = refs[i];
    if (!el) {
      continue;
    }
    gsap.killTweensOf(el);
    gsap.to(el, { duration: 0.55, ease: "power2.out", opacity: 0.22 });
  }
};

const restoreCloseButton = (btn: HTMLButtonElement | null): void => {
  if (btn) {
    gsap.killTweensOf(btn);
    gsap.to(btn, { duration: 0.3, ease: "power3.out", opacity: 1, scale: 1 });
  }
};

const PARALLAX_REST = { extraX: 0, extraY: 0, lift: 1 };

/** Cursor repulsion for a card `ddx, ddy` away from the pointer. */
const cursorParallax = (ddx: number, ddy: number) => {
  const dist = Math.hypot(ddx, ddy);
  const falloff = Math.max(0, 1 - dist / PARALLAX_RANGE);
  const cEased = falloff * falloff * (3 - 2 * falloff);
  if (dist <= 0.5) {
    return { extraX: 0, extraY: 0, lift: 1 + cEased * 0.04 };
  }
  return {
    extraX: (ddx / dist) * PARALLAX_STRENGTH * cEased,
    extraY: (ddy / dist) * PARALLAX_STRENGTH * cEased,
    lift: 1 + cEased * 0.04,
  };
};

/* ── Decorative paper-grain contour lines ────────────────────────── */

/** 14 wobbling contours — a paper ground that makes the drift legible. */
const CONTOUR_PATHS: string[] = Array.from({ length: 14 }, (_, i) => {
  const y = (i / 14) * 100 + Math.sin(i * 1.7) * 4;
  return `M0 ${y} Q25 ${y - 6} 50 ${y} T100 ${y}`;
});

const BackgroundLines = () => (
  <svg
    className="text-foreground pointer-events-none absolute inset-0 size-full opacity-[0.06]"
    preserveAspectRatio="none"
    viewBox="0 0 100 100"
    aria-hidden
  >
    {CONTOUR_PATHS.map((d) => (
      <path
        key={d}
        d={d}
        stroke="currentColor"
        strokeWidth="0.06"
        fill="none"
        vectorEffect="non-scaling-stroke"
      />
    ))}
  </svg>
);

/* ── Host component ──────────────────────────────────────────────── */

export const LookbookCamera = () => {
  const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
  const [dims, setDims] = useState<Dims | null>(null);
  const [positions, setPositions] = useState<Pos[]>([]);
  const [ready, setReady] = useState(false);

  const containerRef = useRef<HTMLElement | null>(null);
  const cameraRef = useRef<HTMLDivElement | null>(null);
  const detailPanelRef = useRef<HTMLDivElement | null>(null);
  const closeBtnRef = useRef<HTMLButtonElement | null>(null);
  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
  /**
   * Mirror of the transforms the RAF loop writes as raw `style.transform`, which
   * GSAP's per-target cache never observes. Handing these back to GSAP right
   * before a tween starts is what keeps a selection from beginning a whole tile
   * away from where the card actually is. Entries are mutated in place — the
   * loop touches this every frame for every card.
   */
  const lastWrittenRef = useRef<WrittenTransform[]>([]);

  const camera = useRef({ scale: 0.5, x: 0, y: 0 });
  const drag = useRef<DragState>({
    active: false,
    didMove: false,
    inertiaActive: false,
    lastSx: 0,
    lastSy: 0,
    pointerId: -1,
    prevSx: 0,
    prevSy: 0,
    startClientX: 0,
    startClientY: 0,
    vt: 0,
    vx: 0,
    vy: 0,
    worldAnchorX: 0,
    worldAnchorY: 0,
  });
  const mouse = useRef({ inside: false, x: 0, y: 0 });
  const driftRamp = useRef({ amount: 0 });
  /** Camera to restore on close; null when there is nothing worth restoring. */
  const preSelectionCamera = useRef<{ x: number; y: number; scale: number } | null>(null);
  const gsapManagedItems = useRef<Set<number>>(new Set());

  const dimsRef = useRef<Dims>({
    baseFit: 1,
    h: 1,
    w: 1,
    worldH: 1,
    worldW: 1,
  });
  const positionsRef = useRef<Pos[]>([]);
  const rectRef = useRef({ left: 0, top: 0 });
  const selectedIdxRef = useRef<number | null>(null);
  const transitioningRef = useRef(false);
  const enteredRef = useRef(false);
  const cameraInitedRef = useRef(false);
  const reduceMotionRef = useRef(false);
  const animRef = useRef<number | null>(null);
  /** The deferred half of a drag-deselect, held so unmount can kill it. */
  const deselectCallRef = useRef<ReturnType<typeof gsap.delayedCall> | null>(null);
  /** The one-frame wait before the detail panel's entrance is choreographed. */
  const panelRafRef = useRef<number | null>(null);

  const applyCameraTransform = useCallback(() => {
    if (drag.current.active && drag.current.didMove) {
      camera.current.x = drag.current.lastSx - drag.current.worldAnchorX * camera.current.scale;
      camera.current.y = drag.current.lastSy - drag.current.worldAnchorY * camera.current.scale;
    }
    const c = cameraRef.current;
    if (!c) {
      return;
    }
    const { scale, x, y } = camera.current;
    c.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
  }, []);

  const effectiveMinScale = useCallback(() => {
    const { w } = dimsRef.current;
    const floor =
      w < NARROW_BREAKPOINT ? DEFAULT_MIN_ZOOM_CARD_HEIGHT_NARROW : DEFAULT_MIN_ZOOM_CARD_HEIGHT;
    return floor / CARD_H;
  }, []);

  /**
   * The container's viewport offset, cached because pointer/wheel handlers need
   * it on every event and a `getBoundingClientRect()` there would force layout
   * against the RAF loop's transform writes. Page scroll moves it without
   * resizing anything, so scrolling has to refresh it too.
   */
  const refreshRect = useCallback(() => {
    const c = containerRef.current;
    if (!c) {
      return;
    }
    const r = c.getBoundingClientRect();
    rectRef.current = { left: r.left, top: r.top };
  }, []);

  const measure = useCallback(() => {
    const c = containerRef.current;
    if (!c) {
      return;
    }
    const r = c.getBoundingClientRect();
    const w = r.width;
    const h = r.height;
    // A ResizeObserver fires once at 0x0 inside a freshly-mounted iframe.
    if (w === 0 || h === 0) {
      return;
    }
    // Read the camera's world centre against the OLD dims, before they change.
    const prev = dimsRef.current;
    const worldCx = (prev.w / 2 - camera.current.x) / camera.current.scale;
    const worldCy = (prev.h / 2 - camera.current.y) / camera.current.scale;
    const cols = chooseCols(LOOKS.length, w / h);
    const rows = Math.ceil(LOOKS.length / cols);
    const cellW = CARD_W + CELL_MARGIN_X;
    const cellH = CARD_H + CELL_MARGIN_Y;
    const worldW = cols * cellW;
    const worldH = rows * cellH;
    const baseFit = Math.min(w / worldW, h / worldH) * 0.94;
    const d: Dims = { baseFit, h, w, worldH, worldW };
    const p = layoutPositions(worldW, worldH, cols, LOOKS.length);
    dimsRef.current = d;
    positionsRef.current = p;
    refreshRect();
    setDims(d);
    setPositions(p);
    if (!cameraInitedRef.current) {
      cameraInitedRef.current = true;
      const introScale = CAMERA_INTRO_FACTOR * baseFit;
      camera.current.scale = introScale;
      camera.current.x = (w - worldW * introScale) / 2;
      camera.current.y = (h - worldH * introScale) / 2;
      for (const el of itemRefs.current) {
        if (el) {
          el.style.opacity = "0";
          el.style.filter = "blur(8px)";
        }
      }
      applyCameraTransform();
      setReady(true);
      return;
    }

    // An open look keeps the geometry chosen at click time, so a resize that
    // flips `usesSideLayout` would leave the card parked where the old layout
    // put it while the panel re-renders into the new one — landing on top of
    // it. Re-solve and snap; a resize should not animate. Mid-transition the
    // in-flight tween owns these values, so leave it alone.
    const sel = selectedIdxRef.current;
    const selPos = sel === null ? null : p[sel];
    if (sel !== null && selPos && !transitioningRef.current) {
      const g = solveSelection(d, selPos, worldCx, worldCy);
      camera.current.scale = g.cameraScale;
      camera.current.x = g.cameraX;
      camera.current.y = g.cameraY;
      applyCameraTransform();
      const el = itemRefs.current[sel];
      if (el) {
        gsap.set(el, { rotation: 0, scale: g.cardScale, x: g.tileOffX, y: g.tileOffY });
      }
      // The saved viewport was framed for the old container; restoring it at
      // the new size would land somewhere arbitrary. Fall back to the fit.
      preSelectionCamera.current = null;
    }
  }, [applyCameraTransform, refreshRect]);

  const triggerDragDeselect = useCallback(() => {
    const idx = selectedIdxRef.current;
    if (idx === null) {
      return;
    }
    const realEl = itemRefs.current[idx];
    if (!realEl) {
      return;
    }

    selectedIdxRef.current = null;
    transitioningRef.current = true;

    gsap.killTweensOf(camera.current);
    gsap.killTweensOf(realEl);

    const d = dimsRef.current;
    const pos = positionsRef.current;
    const minScaleDeselect = effectiveMinScale();
    const targetScale = Math.max((d.h * POST_DESELECT_HEIGHT_FRACTION) / CARD_H, minScaleDeselect);

    gsap.to(camera.current, {
      duration: 0.4,
      ease: "power2.out",
      onUpdate: applyCameraTransform,
      scale: targetScale,
    });

    realEl.style.pointerEvents = "";
    gsap.to(realEl, {
      duration: 0.4,
      ease: "power2.out",
      onComplete: () => {
        realEl.style.zIndex = "";
      },
      rotation: pos[idx]?.rot ?? 0,
      scale: 1,
    });

    for (let i = 0; i < LOOKS.length; i += 1) {
      if (i === idx) {
        continue;
      }
      const el = itemRefs.current[i];
      if (!el) {
        continue;
      }
      gsap.killTweensOf(el);
      gsap.to(el, { duration: 0.35, ease: "power2.out", opacity: 1 });
    }

    const dp = detailPanelRef.current;
    const cb = closeBtnRef.current;
    if (dp) {
      // A re-selection's fade-out may still be running with an onComplete that
      // re-opens the panel. GSAP does not overwrite by default, so kill it.
      gsap.killTweensOf(dp);
      gsap.to(dp, { duration: 0.3, ease: "power2.in", opacity: 0, x: 24 });
    }
    if (cb) {
      gsap.to(cb, { duration: 0.25, ease: "power2.in", opacity: 0 });
    }

    deselectCallRef.current = gsap.delayedCall(0.4, () => {
      deselectCallRef.current = null;
      // Clear, not delete(idx): a deselect mid-reselection has killed the
      // outgoing card's tween, so the onComplete that would have released it
      // never fires and it would be excluded from the re-wrap forever.
      gsapManagedItems.current.clear();
      setSelectedIdx(null);
      transitioningRef.current = false;
      preSelectionCamera.current = null;
      const cont = containerRef.current;
      if (cont) {
        cont.style.cursor = drag.current.active ? "grabbing" : "grab";
      }
    });
  }, [applyCameraTransform, effectiveMinScale]);

  const handleClose = useCallback(() => {
    const idx = selectedIdxRef.current;
    if (idx === null) {
      return;
    }
    if (transitioningRef.current) {
      return;
    }
    transitioningRef.current = true;

    const d = dimsRef.current;
    const pos = positionsRef.current[idx];
    const realEl = itemRefs.current[idx];

    const dp = detailPanelRef.current;
    const cb = closeBtnRef.current;
    if (dp) {
      // Same reason as in triggerDragDeselect: closing during a re-selection
      // would otherwise let that fade-out's onComplete re-open the panel
      // partway through the close.
      gsap.killTweensOf(dp);
      gsap.to(dp, { duration: 0.35, ease: "power2.in", opacity: 0, x: 36 });
    }
    if (cb) {
      gsap.to(cb, { duration: 0.3, ease: "power2.in", opacity: 0, scale: 0.85 });
    }

    const minScale = effectiveMinScale();
    const saved = preSelectionCamera.current;
    const restScale = saved
      ? Math.max(saved.scale, minScale)
      : Math.max(CAMERA_REST_FACTOR * d.baseFit, minScale);
    const restX = saved ? saved.x : (d.w - d.worldW * restScale) / 2;
    const restY = saved ? saved.y : (d.h - d.worldH * restScale) / 2;

    gsap.killTweensOf(camera.current);
    gsap.to(camera.current, {
      delay: 0.18,
      duration: 0.95,
      ease: "power3.inOut",
      onComplete: () => {
        if (realEl) {
          realEl.style.zIndex = "";
          realEl.style.pointerEvents = "";
        }
        // Clear, not delete(idx), for the same reason as triggerDragDeselect.
        // A chain of re-selections dims — and so kills the tween of — the card
        // from two selections ago, and that tween carried the only release for
        // its index. Anything still in the set here is stranded: the close has
        // settled, so no item tween is in flight that still needs the guard.
        gsapManagedItems.current.clear();
        selectedIdxRef.current = null;
        setSelectedIdx(null);
        transitioningRef.current = false;
        preSelectionCamera.current = null;
      },
      onUpdate: applyCameraTransform,
      scale: restScale,
      x: restX,
      y: restY,
    });

    if (realEl && pos) {
      gsap.killTweensOf(realEl);
      const closeWorldCx = (d.w / 2 - restX) / restScale;
      const closeWorldCy = (d.h / 2 - restY) / restScale;
      gsap.to(realEl, {
        delay: 0.18,
        duration: 0.95,
        ease: "power3.inOut",
        rotation: pos.rot,
        scale: 1,
        x: wrapOffset(pos.x, closeWorldCx, d.worldW),
        y: wrapOffset(pos.y, closeWorldCy, d.worldH),
      });
    }

    for (let i = 0; i < LOOKS.length; i += 1) {
      if (i === idx) {
        continue;
      }
      const el = itemRefs.current[i];
      if (!el) {
        continue;
      }
      gsap.killTweensOf(el);
      gsap.to(el, { delay: 0.18, duration: 0.6, ease: "power2.out", opacity: 1 });
    }
  }, [applyCameraTransform, effectiveMinScale]);

  const handleClickItem = useCallback(
    (idx: number) => {
      if (!enteredRef.current) {
        return;
      }

      const prevIdx = selectedIdxRef.current;
      if (prevIdx === idx) {
        return;
      }
      if (prevIdx === null && transitioningRef.current) {
        return;
      }

      transitioningRef.current = true;

      const d = dimsRef.current;
      const pos = positionsRef.current;
      const newPos = pos[idx];
      const newEl = itemRefs.current[idx];
      const prevPos = prevIdx === null ? null : pos[prevIdx];
      const prevEl = prevIdx === null ? null : itemRefs.current[prevIdx];
      if (!newPos || !newEl) {
        transitioningRef.current = false;
        return;
      }

      const preCamX = camera.current.x;
      const preCamY = camera.current.y;
      const preCamS = camera.current.scale;
      const worldCx = (d.w / 2 - preCamX) / preCamS;
      const worldCy = (d.h / 2 - preCamY) / preCamS;
      const g = solveSelection(d, newPos, worldCx, worldCy);

      if (prevIdx === null) {
        preSelectionCamera.current = { scale: preCamS, x: preCamX, y: preCamY };
      }

      // Read BEFORE the add below: whether the RAF loop or GSAP owned this card
      // a moment ago decides which of them holds its true transform.
      const rafOwnedNew = !gsapManagedItems.current.has(idx);

      gsapManagedItems.current.add(idx);
      if (prevIdx !== null) {
        gsapManagedItems.current.add(prevIdx);
      }

      // Claim the selection SYNCHRONOUSLY. Deferring this into the panel
      // fade-out below leaves a ~280ms window in which a drag reads the stale
      // index, deselects it, and is then overwritten by the deferred write —
      // ref non-null, state null, every `sel === null` gate dead for good.
      selectedIdxRef.current = idx;

      gsap.killTweensOf(newEl);
      // The RAF loop has been writing this card's `style.transform` raw, which
      // GSAP's per-target transform cache never sees, so a tween would start
      // from whatever GSAP last rendered — a whole tile away once the world has
      // drifted past a wrap boundary — and the card would teleport off screen on
      // frame one before flying back in. `parseTransform: true` is not enough:
      // gsap reads `x`'s start value through `_get` with no uncache flag and
      // only refreshes the cache afterwards, so `x` alone still starts stale.
      // A zero-duration `set` renders immediately, which makes the cache
      // authoritative for all four props before the tween samples any of them.
      //
      // Only when the RAF loop was the one writing. Re-selecting a card during
      // the ~0.65s release tween of the look before it lands here with GSAP
      // still mid-render: its cache is already correct and the RAF mirror is
      // stale by however far the field has panned since, so replaying it would
      // reintroduce the very jump this prevents.
      const lastWritten = rafOwnedNew ? lastWrittenRef.current[idx] : undefined;
      if (lastWritten) {
        gsap.set(newEl, lastWritten);
      }
      gsap.to(newEl, {
        duration: prevIdx === null ? 1 : 0.9,
        ease: "power3.inOut",
        opacity: 1,
        rotation: 0,
        scale: g.cardScale,
        x: g.tileOffX,
        y: g.tileOffY,
      });
      newEl.style.zIndex = "60";
      newEl.style.pointerEvents = "none";

      if (prevEl && prevPos) {
        gsap.killTweensOf(prevEl);
        gsap.to(prevEl, {
          duration: 0.65,
          ease: "power3.inOut",
          onComplete: () => {
            prevEl.style.zIndex = "";
            if (prevIdx !== null) {
              gsapManagedItems.current.delete(prevIdx);
            }
          },
          onStart: () => {
            prevEl.style.pointerEvents = "";
          },
          opacity: 0.22,
          rotation: prevPos.rot,
          scale: 1,
          x: wrapOffset(prevPos.x, worldCx, d.worldW),
          y: wrapOffset(prevPos.y, worldCy, d.worldH),
        });
      }

      dimOtherCards(itemRefs.current, idx, prevIdx);

      gsap.killTweensOf(camera.current);
      gsap.to(camera.current, {
        duration: prevIdx === null ? 1.05 : 0.95,
        ease: "power3.inOut",
        onComplete: () => {
          transitioningRef.current = false;
        },
        onUpdate: applyCameraTransform,
        scale: g.cameraScale,
        x: g.cameraX,
        y: g.cameraY,
      });

      if (prevIdx === null) {
        setSelectedIdx(idx);
        panelRafRef.current = requestAnimationFrame(() => {
          panelRafRef.current = null;
          const dp = detailPanelRef.current;
          const cb = closeBtnRef.current;
          if (dp) {
            gsap.fromTo(
              dp,
              { opacity: 0, x: 48 },
              { delay: 0.35, duration: 0.75, ease: "power3.out", opacity: 1, x: 0 },
            );
            const stages = dp.querySelectorAll<HTMLElement>("[data-detail-anim]");
            gsap.fromTo(
              stages,
              { filter: "blur(6px)", opacity: 0, y: 16 },
              {
                delay: 0.55,
                duration: 0.65,
                ease: "power3.out",
                filter: "blur(0px)",
                opacity: 1,
                stagger: 0.08,
                y: 0,
              },
            );
          }
          if (cb) {
            gsap.fromTo(
              cb,
              { opacity: 0, scale: 0.85 },
              { delay: 0.4, duration: 0.5, ease: "power3.out", opacity: 1, scale: 1 },
            );
          }
        });
      } else {
        // Selecting another card DURING a close is a re-selection (the close
        // has not cleared the index yet), so it lands here — but the close has
        // already faded the button out and its restore lives on the fresh-open
        // path above. Without this the look re-opens with no visible close
        // affordance. On an ordinary re-selection the button is already at
        // rest, so this is a no-op.
        restoreCloseButton(closeBtnRef.current);
        const dp = detailPanelRef.current;
        if (dp) {
          gsap.killTweensOf(dp);
          gsap.killTweensOf(dp.querySelectorAll("[data-detail-anim]"));
          gsap.to(dp, {
            duration: 0.28,
            ease: "power2.in",
            onComplete: () => {
              setSelectedIdx(idx);
              panelRafRef.current = requestAnimationFrame(() => {
                panelRafRef.current = null;
                const newDp = detailPanelRef.current;
                if (!newDp) {
                  return;
                }
                gsap.fromTo(
                  newDp,
                  { opacity: 0, x: 28 },
                  { duration: 0.55, ease: "power3.out", opacity: 1, x: 0 },
                );
                const newStages = newDp.querySelectorAll<HTMLElement>("[data-detail-anim]");
                gsap.fromTo(
                  newStages,
                  { filter: "blur(4px)", opacity: 0, y: 12 },
                  {
                    delay: 0.08,
                    duration: 0.5,
                    ease: "power3.out",
                    filter: "blur(0px)",
                    opacity: 1,
                    stagger: 0.06,
                    y: 0,
                  },
                );
              });
            },
            opacity: 0,
            x: 20,
          });
        } else {
          setSelectedIdx(idx);
        }
      }
    },
    [applyCameraTransform],
  );

  /* Deferred work scheduled from event handlers rather than from an effect —
     it outlives the handler, so unmount has to reclaim it explicitly. */
  useEffect(
    () => () => {
      deselectCallRef.current?.kill();
      deselectCallRef.current = null;
      if (panelRafRef.current !== null) {
        cancelAnimationFrame(panelRafRef.current);
      }
      panelRafRef.current = null;
    },
    [],
  );

  /* Measure on mount + whenever the container box changes. A window resize
     listener is not enough: the gallery resizes the frame directly. */
  useLayoutEffect(() => {
    const c = containerRef.current;
    if (!c) {
      return;
    }
    measure();
    const ro = new ResizeObserver(() => measure());
    ro.observe(c);
    return () => {
      ro.disconnect();
      // Re-arm so a StrictMode double-mount replays the entrance rather than
      // silently skipping it.
      cameraInitedRef.current = false;
    };
  }, [measure]);

  /* Pointer / wheel / key listeners + the per-frame RAF loop. */
  useEffect(() => {
    const c = containerRef.current;
    if (!c) {
      return;
    }

    const onPointerDown = (e: PointerEvent) => {
      if (!enteredRef.current) {
        return;
      }
      if (e.button !== 0 && e.pointerType === "mouse") {
        return;
      }
      const target = e.target instanceof Node ? e.target : null;
      if (target && detailPanelRef.current?.contains(target)) {
        return;
      }
      if (target && closeBtnRef.current?.contains(target)) {
        return;
      }

      const r = rectRef.current;
      const sx = e.clientX - r.left;
      const sy = e.clientY - r.top;
      drag.current.active = true;
      drag.current.pointerId = e.pointerId;
      drag.current.startClientX = e.clientX;
      drag.current.startClientY = e.clientY;
      drag.current.lastSx = sx;
      drag.current.lastSy = sy;
      drag.current.prevSx = sx;
      drag.current.prevSy = sy;
      drag.current.vt = performance.now();
      drag.current.vx = 0;
      drag.current.vy = 0;
      // World anchor: the pan stays exact at any zoom because the camera is
      // re-derived from this point rather than accumulating deltas.
      drag.current.worldAnchorX = (sx - camera.current.x) / camera.current.scale;
      drag.current.worldAnchorY = (sy - camera.current.y) / camera.current.scale;
      drag.current.didMove = false;
      drag.current.inertiaActive = false;
    };

    const onPointerMove = (e: PointerEvent) => {
      const r = rectRef.current;
      const sx = e.clientX - r.left;
      const sy = e.clientY - r.top;
      mouse.current.x = sx;
      mouse.current.y = sy;
      const d = dimsRef.current;
      mouse.current.inside = sx >= 0 && sy >= 0 && sx <= d.w && sy <= d.h;

      if (!drag.current.active || drag.current.pointerId !== e.pointerId) {
        return;
      }

      drag.current.lastSx = sx;
      drag.current.lastSy = sy;
      const dx = e.clientX - drag.current.startClientX;
      const dy = e.clientY - drag.current.startClientY;

      if (!drag.current.didMove && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
        drag.current.didMove = true;
        c.style.cursor = "grabbing";
        gsap.killTweensOf(camera.current);
        if (selectedIdxRef.current !== null) {
          triggerDragDeselect();
        }
      }

      if (drag.current.didMove) {
        const now = performance.now();
        const dt = Math.max(1, now - drag.current.vt);
        drag.current.vx = ((sx - drag.current.prevSx) / dt) * 16;
        drag.current.vy = ((sy - drag.current.prevSy) / dt) * 16;
        drag.current.vt = now;
        drag.current.prevSx = sx;
        drag.current.prevSy = sy;
        applyCameraTransform();
      }
    };

    const onPointerUp = (e: PointerEvent) => {
      if (drag.current.pointerId !== e.pointerId) {
        return;
      }
      drag.current.active = false;
      drag.current.pointerId = -1;
      c.style.cursor = "grab";
      if (drag.current.didMove && Math.hypot(drag.current.vx, drag.current.vy) > 0.6) {
        drag.current.inertiaActive = true;
      }
    };

    /** The pointer can leave the frame without a final in-bounds `pointermove`
        (straight off the window edge), which would freeze the parallax on
        whichever card it was last over. */
    const onPointerLeave = () => {
      mouse.current.inside = false;
    };

    const onWheel = (e: WheelEvent) => {
      // An open look owns the wheel: the detail panel scrolls. Everything else
      // is the camera's, so it swallows the event even when it cannot act on
      // it — otherwise the entrance and every transition let the wheel through
      // to whatever page has embedded this.
      if (selectedIdxRef.current !== null) {
        return;
      }
      e.preventDefault();
      if (!enteredRef.current) {
        return;
      }
      if (transitioningRef.current) {
        return;
      }
      if (drag.current.active && drag.current.didMove) {
        return;
      }
      drag.current.inertiaActive = false;
      drag.current.vx = 0;
      drag.current.vy = 0;
      gsap.killTweensOf(camera.current);

      const r = rectRef.current;
      const sx = e.clientX - r.left;
      const sy = e.clientY - r.top;
      const currentScale = camera.current.scale;
      const zoomFactor = Math.exp(-e.deltaY * WHEEL_SENSITIVITY);
      const minScale = effectiveMinScale();
      const newScale = clamp(currentScale * zoomFactor, minScale, MAX_CAMERA_SCALE);
      if (newScale === currentScale) {
        return;
      }

      // Re-solve the camera so the world point under the cursor stays pinned.
      const worldX = (sx - camera.current.x) / currentScale;
      const worldY = (sy - camera.current.y) / currentScale;
      camera.current.scale = newScale;
      camera.current.x = sx - worldX * newScale;
      camera.current.y = sy - worldY * newScale;
      applyCameraTransform();
    };

    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape" && selectedIdxRef.current !== null) {
        handleClose();
      }
    };

    c.addEventListener("pointerdown", onPointerDown);
    c.addEventListener("pointerleave", onPointerLeave);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);
    // Without this a cancelled pointer (OS gesture takeover, input redirection)
    // never ends the drag: `active`/`didMove` stay true, so the camera keeps
    // being rewritten from a stale anchor and clicks, drift and parallax all
    // die permanently. `onPointerUp` already filters on the pointer id.
    window.addEventListener("pointercancel", onPointerUp);
    c.addEventListener("wheel", onWheel, { passive: false });
    window.addEventListener("keydown", onKey);
    // Capture phase so a scrolling ancestor counts, not just the window.
    window.addEventListener("scroll", refreshRect, { capture: true, passive: true });

    const stepInertia = (sel: number | null) => {
      if (
        drag.current.inertiaActive &&
        !drag.current.active &&
        sel === null &&
        !transitioningRef.current
      ) {
        camera.current.x += drag.current.vx;
        camera.current.y += drag.current.vy;
        drag.current.vx *= DEFAULT_INERTIA_FRICTION;
        drag.current.vy *= DEFAULT_INERTIA_FRICTION;
        if (Math.hypot(drag.current.vx, drag.current.vy) < 0.12) {
          drag.current.inertiaActive = false;
          drag.current.vx = 0;
          drag.current.vy = 0;
        }
        applyCameraTransform();
      }
    };

    const stepDrift = (sel: number | null) => {
      if (
        AUTO_DRIFT_PX_PER_FRAME > 0 &&
        !reduceMotionRef.current &&
        enteredRef.current &&
        sel === null &&
        !transitioningRef.current &&
        !drag.current.active &&
        !drag.current.inertiaActive &&
        driftRamp.current.amount > 0.001
      ) {
        const speed = AUTO_DRIFT_PX_PER_FRAME * driftRamp.current.amount;
        camera.current.x -= speed;
        camera.current.y -= speed;
        applyCameraTransform();
      }
    };

    const tick = () => {
      const sel = selectedIdxRef.current;
      const pos = positionsRef.current;
      const d = dimsRef.current;

      stepInertia(sel);
      stepDrift(sel);

      if (enteredRef.current) {
        const atRest = sel === null && !transitioningRef.current;
        const camS = camera.current.scale;
        const camX = camera.current.x;
        const camY = camera.current.y;
        const doParallax =
          atRest && mouse.current.inside && !drag.current.active && !reduceMotionRef.current;
        const mx = mouse.current.x;
        const my = mouse.current.y;
        const worldCx = (d.w / 2 - camX) / camS;
        const worldCy = (d.h / 2 - camY) / camS;
        const tileW = d.worldW;
        const tileH = d.worldH;
        const managed = gsapManagedItems.current;
        const refs = itemRefs.current;
        const written = lastWrittenRef.current;

        for (let i = 0; i < refs.length; i += 1) {
          if (managed.has(i)) {
            continue;
          }
          const el = refs[i];
          const p = pos[i];
          if (!el || !p) {
            continue;
          }

          // Re-wrap every card against the camera's world centre, every frame.
          // This is the mechanic: the field has no edges, ever.
          const tileOffX = wrapOffset(p.x, worldCx, tileW);
          const tileOffY = wrapOffset(p.y, worldCy, tileH);

          let par = PARALLAX_REST;
          if (doParallax) {
            const wrappedX = p.x + tileOffX;
            const wrappedY = p.y + tileOffY;
            const icx = camX + wrappedX * camS;
            const icy = camY + wrappedY * camS;
            par = cursorParallax(mx - icx, my - icy);
          }

          const ox = par.extraX + tileOffX;
          const oy = par.extraY + tileOffY;
          el.style.transform = `translate3d(${ox}px, ${oy}px, 0) rotate(${p.rot}deg) scale(${par.lift})`;

          const w = written[i];
          if (w) {
            w.x = ox;
            w.y = oy;
            w.rotation = p.rot;
            w.scale = par.lift;
          } else {
            written[i] = { rotation: p.rot, scale: par.lift, x: ox, y: oy };
          }
        }
      }

      animRef.current = requestAnimationFrame(tick);
    };
    animRef.current = requestAnimationFrame(tick);

    return () => {
      c.removeEventListener("pointerdown", onPointerDown);
      c.removeEventListener("pointerleave", onPointerLeave);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
      window.removeEventListener("pointercancel", onPointerUp);
      c.removeEventListener("wheel", onWheel);
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", refreshRect, { capture: true });
      if (animRef.current !== null) {
        cancelAnimationFrame(animRef.current);
      }
    };
  }, [applyCameraTransform, effectiveMinScale, handleClose, refreshRect, triggerDragDeselect]);

  /* Grid entrance (camera dolly + item de-blur). Fires once the first
     measurement lands. Tween cleanup makes it Strict-Mode safe: a teardown
     kills the in-flight dolly and the re-run restarts it from the current
     camera state, so it always settles at rest. */
  useEffect(() => {
    if (!ready) {
      return;
    }

    enteredRef.current = true;
    const realItems = itemRefs.current.filter((el): el is HTMLDivElement => el !== null);
    const restScale = Math.max(CAMERA_REST_FACTOR * dimsRef.current.baseFit, effectiveMinScale());
    // These two refs hold stable objects that GSAP tweens in place; bind them
    // now so the cleanup kills the same objects it started tweening.
    const cameraObj = camera.current;
    const driftObj = driftRamp.current;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    reduceMotionRef.current = reduce;
    if (reduce) {
      const dd = dimsRef.current;
      camera.current.scale = restScale;
      camera.current.x = (dd.w - dd.worldW * restScale) / 2;
      camera.current.y = (dd.h - dd.worldH * restScale) / 2;
      applyCameraTransform();
      for (const el of realItems) {
        el.style.opacity = "1";
        el.style.filter = "none";
      }
      // Unlike the source, reduced motion also parks the perpetual drift and
      // the cursor repulsion — both read this ref inside the RAF loop.
      driftRamp.current.amount = 0;
      return;
    }

    driftObj.amount = 0;
    gsap.to(driftObj, { amount: 1, delay: 1.5, duration: 2.5, ease: "power2.in" });

    const tl = gsap.timeline({ delay: 0.12 });
    tl.to(
      realItems,
      {
        duration: 0.95,
        ease: "power3.out",
        filter: "blur(0px)",
        opacity: 1,
        stagger: { each: ENTRANCE_STAGGER, from: "random" },
      },
      0,
    );
    tl.to(
      cameraObj,
      {
        duration: 1.6,
        ease: "power4.out",
        onUpdate: () => {
          if (!drag.current.active) {
            const dd = dimsRef.current;
            const s = camera.current.scale;
            camera.current.x = (dd.w - dd.worldW * s) / 2;
            camera.current.y = (dd.h - dd.worldH * s) / 2;
          }
          applyCameraTransform();
        },
        scale: restScale,
      },
      0,
    );

    return () => {
      tl.kill();
      gsap.killTweensOf(cameraObj);
      gsap.killTweensOf(driftObj);
      gsap.killTweensOf(realItems);
    };
  }, [ready, applyCameraTransform, effectiveMinScale]);

  const selectedLook = selectedIdx === null ? null : (LOOKS[selectedIdx] ?? null);

  return (
    <section
      ref={containerRef}
      data-lookbook-root
      aria-label="Interactive lookbook canvas"
      // `text-foreground` is load-bearing, not decoration: the scoped palette
      // below only reaches utilities that name a token, and the detail panel's
      // headings, prices and the close icon all inherit `color`. Without an
      // anchor here they inherit the HOST page's — white, under a dark theme,
      // on this component's permanently cream glass.
      className="bg-background text-foreground relative size-full overflow-hidden select-none"
      style={{ cursor: "grab", touchAction: "none" }}
    >
      {/* Scoped palette. uicapsule declares its tokens with `@theme inline`, so
          `--color-background` expands to `var(--background)` at the use site —
          redefining the raw token here is what makes every `bg-background` /
          `text-foreground` utility below resolve warm, and immune to whatever
          light/dark theme surrounds the frame. */}
      <style>{`
        [data-lookbook-root]{
          --background:#fffdf8;
          --foreground:#221e18;
          --muted:#ece4d4;
          --muted-foreground:#6f6757;
          --border:#ddd3c0;
        }
        [data-lookbook-root] .lbk-display{
          font-family:'Playfair Display',Georgia,'Times New Roman',serif;
          font-style:italic;
          font-weight:500;
        }
      `}</style>

      <BackgroundLines />

      {/* Camera + world */}
      <div
        ref={cameraRef}
        className="absolute top-0 left-0"
        style={{
          transformOrigin: "0 0",
          // The gallery server-renders this component, so the first HTML has no
          // measurements and every card is stacked at 0,0. Hide the camera node
          // rather than unmounting the cards: `measure()` needs them mounted to
          // zero them, and that zeroed state is what the entrance animates from.
          visibility: ready ? undefined : "hidden",
          willChange: "transform",
        }}
      >
        <div
          style={{
            height: dims?.worldH ?? 0,
            position: "relative",
            width: dims?.worldW ?? 0,
          }}
        >
          {LOOKS.map((look, i) => {
            const p = positions[i];
            // 72 cards, 18 distinct looks: the other three cycles are visual
            // repeats. Exposing them as buttons put 54 unreachable duplicates
            // in the accessibility tree with labels identical to their twins,
            // so they are hidden from it entirely — which also means they must
            // not be focusable at all, not merely untabbable.
            const isDuplicate = i >= KEYBOARD_REACHABLE;
            return (
              // 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
              <div
                key={look.id}
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                onClick={() => {
                  // Swallow the synthetic click that closes out a pan. The
                  // keyboard path below deliberately skips this: `didMove`
                  // survives until the next pointerdown, and gating Enter on it
                  // left activation dead for as long as that took.
                  if (drag.current.didMove) {
                    return;
                  }
                  handleClickItem(i);
                }}
                onKeyDown={(e) => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    handleClickItem(i);
                  }
                }}
                role={isDuplicate ? undefined : "button"}
                tabIndex={isDuplicate ? undefined : 0}
                aria-hidden={isDuplicate || undefined}
                // Keyed off look identity, not index: the 54 aria-hidden
                // duplicates stay mouse-clickable, and opening one has to be
                // reported by the one real button that names the same look.
                // `cloneLook` preserves `lookNumber`, so it survives the cycles.
                aria-expanded={
                  isDuplicate ? undefined : selectedLook?.lookNumber === look.lookNumber
                }
                aria-label={
                  isDuplicate ? undefined : `${look.name}, look number ${look.lookNumber}`
                }
                data-look-card={look.id}
                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"
                style={{
                  height: CARD_H,
                  left: p ? p.x - CARD_W / 2 : 0,
                  top: p ? p.y - CARD_H / 2 : 0,
                  width: CARD_W,
                  willChange: "transform",
                }}
              >
                <LookFigure look={look} className="pointer-events-none size-full" />
              </div>
            );
          })}
        </div>
      </div>

      {/* Hint */}
      <div
        className="pointer-events-none absolute bottom-6 left-8 z-20"
        style={{
          opacity: selectedIdx === null ? 1 : 0,
          transition: "opacity 0.5s ease",
        }}
      >
        <p className="text-muted-foreground text-[10px] tracking-[0.3em] uppercase">
          Drag to pan · scroll to zoom · click a look
        </p>
      </div>

      {/* Selection chrome */}
      {selectedLook && dims && (
        <>
          <button
            type="button"
            ref={closeBtnRef}
            onClick={handleClose}
            aria-label="Close look"
            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"
          >
            <X className="size-4" />
          </button>
          <DetailPanel
            key={selectedLook.id}
            ref={detailPanelRef}
            look={selectedLook}
            containerW={dims.w}
            containerH={dims.h}
          />
        </>
      )}
    </section>
  );
};
```

### `/lookbook-data.ts`

```ts
/**
 * Lookbook data layer.
 *
 * Owns every constant the renderer reads: the eighteen base looks, the four
 * cycle permutations that expand them into 72 unique cards, the card/cell
 * geometry, and the deterministic grid layout helpers.
 *
 * Every look is one photograph, generated to a fixed framing (full body, model
 * centred, small margin above the head and below the shoes) so
 * `look-figure.tsx` can crop any piece out of any look with one set of boxes.
 */

const LOOK_IMAGE_BASE =
  "https://d24l2zb4cwkekfpl.public.blob.vercel-storage.com/lookbook-camera/looks";

export type ItemCategory = "Outerwear" | "Knitwear" | "Tops" | "Trousers" | "Skirt" | "Footwear";

interface ItemBase {
  id: string;
  name: string;
  category: ItemCategory;
  material: string;
  size: string;
  priceCents: number;
}

/**
 * The slot is a literal discriminant, not a free string: it is what the detail
 * panel reads to crop the figure down to the piece being shown, so an item can
 * never point at a region of the drawing that does not exist.
 */
export type TopItem = ItemBase & { slot: "top" };
export type LowerItem = ItemBase & { slot: "lower" };
export type ShoeItem = ItemBase & { slot: "shoes" };
export type ClothingItem = TopItem | LowerItem | ShoeItem;
export type ItemSlot = ClothingItem["slot"];

export interface Look {
  id: string;
  lookNumber: string;
  name: string;
  season: string;
  accent: string;
  imageUrl: string;
  items: [TopItem, LowerItem, ShoeItem];
}

const SS = "Spring / Summer";
const AW = "Autumn / Winter";
const RESORT = "Resort";
const PREFALL = "Pre-Fall";

const RAW_LOOKS: Omit<Look, "imageUrl">[] = [
  {
    accent: "#4a4540",
    id: "look-01",
    items: [
      {
        category: "Outerwear",
        id: "01-top",
        material: "Worsted wool, silk lining",
        name: "Worsted Wool Blazer",
        priceCents: 86_000,
        size: "M / 38R",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "01-lower",
        material: "Crêpe de chine",
        name: "Pleated Crepe Trouser",
        priceCents: 42_000,
        size: "32 / 30L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "01-shoes",
        material: "Suede, leather sole",
        name: "Suede Derby",
        priceCents: 39_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "01",
    name: "Off-Hour Tailoring",
    season: SS,
  },
  {
    accent: "#1b1b1f",
    id: "look-02",
    items: [
      {
        category: "Outerwear",
        id: "02-top",
        material: "Garment-dyed organic cotton",
        name: "Boxy Cotton Overshirt",
        priceCents: 38_000,
        size: "M",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "02-lower",
        material: "Cotton drill",
        name: "Tapered Drill Trouser",
        priceCents: 34_000,
        size: "32 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "02-shoes",
        material: "Full-grain leather, rubber sole",
        name: "Leather Court Sneaker",
        priceCents: 42_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "02",
    name: "Studio Black",
    season: SS,
  },
  {
    accent: "#6c7257",
    id: "look-03",
    items: [
      {
        category: "Knitwear",
        id: "03-top",
        material: "Lambswool, hand-linked rib",
        name: "Chunky Lambswool Sweater",
        priceCents: 32_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "03-lower",
        material: "Brushed virgin wool",
        name: "Brushed Wool Trouser",
        priceCents: 39_000,
        size: "33 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "03-shoes",
        material: "Calf leather, shearling lining",
        name: "Shearling Hiker Boot",
        priceCents: 58_000,
        size: "EU 43",
        slot: "shoes",
      },
    ],
    lookNumber: "03",
    name: "Northern Wool",
    season: AW,
  },
  {
    accent: "#5b2434",
    id: "look-04",
    items: [
      {
        category: "Outerwear",
        id: "04-top",
        material: "Cotton velvet, cupro lining",
        name: "Velvet Dinner Jacket",
        priceCents: 94_000,
        size: "M / 38R",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "04-lower",
        material: "Silk-wool blend",
        name: "Silk-Wool Trouser",
        priceCents: 46_000,
        size: "32 / 31L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "04-shoes",
        material: "Patent calf, leather sole",
        name: "Patent Leather Loafer",
        priceCents: 49_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "04",
    name: "Velvet Hour",
    season: RESORT,
  },
  {
    accent: "#8a8783",
    id: "look-05",
    items: [
      {
        category: "Outerwear",
        id: "05-top",
        material: "Washed cotton canvas",
        name: "Washed Field Jacket",
        priceCents: 44_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "05-lower",
        material: "Heavyweight cotton twill",
        name: "Relaxed Carpenter Pant",
        priceCents: 32_000,
        size: "34 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "05-shoes",
        material: "Organic canvas, gum sole",
        name: "Canvas Court Trainer",
        priceCents: 21_000,
        size: "EU 43",
        slot: "shoes",
      },
    ],
    lookNumber: "05",
    name: "Concrete Stone",
    season: SS,
  },
  {
    accent: "#a3b18a",
    id: "look-06",
    items: [
      {
        category: "Tops",
        id: "06-top",
        material: "Washed European linen",
        name: "Linen Camp Shirt",
        priceCents: 24_000,
        size: "M",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "06-lower",
        material: "Linen-viscose blend",
        name: "Drawstring Linen Trouser",
        priceCents: 26_000,
        size: "M",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "06-shoes",
        material: "Vegetable-tanned leather",
        name: "Woven Leather Sandal",
        priceCents: 28_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "06",
    name: "Spring Field",
    season: SS,
  },
  {
    accent: "#2c3a4a",
    id: "look-07",
    items: [
      {
        category: "Outerwear",
        id: "07-top",
        material: "Bonded cotton, taped seams",
        name: "Bonded Trench Coat",
        priceCents: 78_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "07-lower",
        material: "Technical nylon blend",
        name: "Water-Repellent Trouser",
        priceCents: 38_000,
        size: "32 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "07-shoes",
        material: "Vulcanised rubber, leather",
        name: "Rubberised Chelsea Boot",
        priceCents: 44_000,
        size: "EU 43",
        slot: "shoes",
      },
    ],
    lookNumber: "07",
    name: "Rain Coat",
    season: AW,
  },
  {
    accent: "#b5896a",
    id: "look-08",
    items: [
      {
        category: "Outerwear",
        id: "08-top",
        material: "Camel hair, horn buttons",
        name: "Camel Hair Overcoat",
        priceCents: 112_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "08-lower",
        material: "Cotton-linen twill",
        name: "Pleated Cotton Chino",
        priceCents: 30_000,
        size: "33 / 31L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "08-shoes",
        material: "Oiled suede, crepe sole",
        name: "Suede Desert Boot",
        priceCents: 33_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "08",
    name: "Sand Dune",
    season: RESORT,
  },
  {
    accent: "#1a1a2c",
    id: "look-09",
    items: [
      {
        category: "Outerwear",
        id: "09-top",
        material: "Wool barathea, satin lapel",
        name: "Satin-Lapel Tuxedo Jacket",
        priceCents: 98_000,
        size: "M / 38R",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "09-lower",
        material: "Wool, satin side stripe",
        name: "Tuxedo Trouser",
        priceCents: 44_000,
        size: "32 / 31L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "09-shoes",
        material: "Patent leather, grosgrain bow",
        name: "Opera Pump",
        priceCents: 47_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "09",
    name: "Midnight Cut",
    season: PREFALL,
  },
  {
    accent: "#b08bb8",
    id: "look-10",
    items: [
      {
        category: "Tops",
        id: "10-top",
        material: "Printed cupro",
        name: "Printed Cupro Shirt",
        priceCents: 27_000,
        size: "M",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "10-lower",
        material: "Tencel-linen blend",
        name: "Cropped Wide Trouser",
        priceCents: 29_000,
        size: "S",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "10-shoes",
        material: "Nappa leather",
        name: "Block-Heel Mule",
        priceCents: 31_000,
        size: "EU 39",
        slot: "shoes",
      },
    ],
    lookNumber: "10",
    name: "Garden Party",
    season: SS,
  },
  {
    accent: "#7a6448",
    id: "look-11",
    items: [
      {
        category: "Outerwear",
        id: "11-top",
        material: "Waxed cotton, corduroy collar",
        name: "Waxed Chore Jacket",
        priceCents: 42_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "11-lower",
        material: "Cotton canvas, reinforced knee",
        name: "Double-Knee Work Pant",
        priceCents: 28_000,
        size: "34 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "11-shoes",
        material: "Oiled leather, wedge sole",
        name: "Moc-Toe Work Boot",
        priceCents: 41_000,
        size: "EU 43",
        slot: "shoes",
      },
    ],
    lookNumber: "11",
    name: "Workwear",
    season: AW,
  },
  {
    accent: "#b5953a",
    id: "look-12",
    items: [
      {
        category: "Outerwear",
        id: "12-top",
        material: "Metallic lamé, cupro lining",
        name: "Lamé Bomber Jacket",
        priceCents: 64_000,
        size: "M",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "12-lower",
        material: "Fluid satin",
        name: "Liquid Satin Trouser",
        priceCents: 38_000,
        size: "S",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "12-shoes",
        material: "Laminated leather",
        name: "Metallic Strap Heel",
        priceCents: 36_000,
        size: "EU 39",
        slot: "shoes",
      },
    ],
    lookNumber: "12",
    name: "Evening Glow",
    season: RESORT,
  },
  {
    accent: "#c8b9a0",
    id: "look-13",
    items: [
      {
        category: "Outerwear",
        id: "13-top",
        material: "Slubbed European linen",
        name: "Unstructured Linen Blazer",
        priceCents: 52_000,
        size: "M / 38R",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "13-lower",
        material: "Heavy Irish linen",
        name: "Wide Linen Trouser",
        priceCents: 28_000,
        size: "33 / 30L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "13-shoes",
        material: "Suede, jute sole",
        name: "Leather Espadrille",
        priceCents: 19_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "13",
    name: "Linen Hours",
    season: SS,
  },
  {
    accent: "#3f4855",
    id: "look-14",
    items: [
      {
        category: "Outerwear",
        id: "14-top",
        material: "Brushed wool flannel",
        name: "Flannel Suit Jacket",
        priceCents: 88_000,
        size: "M / 38R",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "14-lower",
        material: "Brushed wool flannel",
        name: "Flannel Suit Trouser",
        priceCents: 42_000,
        size: "32 / 31L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "14-shoes",
        material: "Calf leather, leather sole",
        name: "Leather Monk Strap",
        priceCents: 46_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "14",
    name: "Slate Tailoring",
    season: AW,
  },
  {
    accent: "#4a5c3a",
    id: "look-15",
    items: [
      {
        category: "Outerwear",
        id: "15-top",
        material: "Recycled nylon, jersey lining",
        name: "Panelled Track Jacket",
        priceCents: 34_000,
        size: "M",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "15-lower",
        material: "Brushed-back jersey",
        name: "Tapered Track Pant",
        priceCents: 24_000,
        size: "M",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "15-shoes",
        material: "Suede-mesh, EVA sole",
        name: "Suede Mesh Runner",
        priceCents: 29_000,
        size: "EU 43",
        slot: "shoes",
      },
    ],
    lookNumber: "15",
    name: "Track Side",
    season: PREFALL,
  },
  {
    accent: "#7c5a3a",
    id: "look-16",
    items: [
      {
        category: "Outerwear",
        id: "16-top",
        material: "Cotton corduroy, elbow patches",
        name: "Corduroy Blazer",
        priceCents: 48_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "16-lower",
        material: "Wool gabardine",
        name: "Pleated Wool Trouser",
        priceCents: 36_000,
        size: "33 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "16-shoes",
        material: "Burnished calf, leather sole",
        name: "Penny Loafer",
        priceCents: 38_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "16",
    name: "Library",
    season: AW,
  },
  {
    accent: "#1f3a5f",
    id: "look-17",
    items: [
      {
        category: "Outerwear",
        id: "17-top",
        material: "Melton wool, anchor buttons",
        name: "Double-Breasted Peacoat",
        priceCents: 68_000,
        size: "L",
        slot: "top",
      },
      {
        category: "Trousers",
        id: "17-lower",
        material: "Cotton-wool twill",
        name: "Sailor Trouser",
        priceCents: 32_000,
        size: "32 / 32L",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "17-shoes",
        material: "Leather, siped sole",
        name: "Hand-Sewn Deck Shoe",
        priceCents: 27_000,
        size: "EU 42",
        slot: "shoes",
      },
    ],
    lookNumber: "17",
    name: "Maritime",
    season: RESORT,
  },
  {
    accent: "#c98a8a",
    id: "look-18",
    items: [
      {
        category: "Outerwear",
        id: "18-top",
        material: "Cotton bouclé",
        name: "Cropped Bouclé Jacket",
        priceCents: 46_000,
        size: "S",
        slot: "top",
      },
      {
        category: "Skirt",
        id: "18-lower",
        material: "Stretch cotton twill",
        name: "A-Line Midi Skirt",
        priceCents: 28_000,
        size: "S",
        slot: "lower",
      },
      {
        category: "Footwear",
        id: "18-shoes",
        material: "Nappa leather",
        name: "Slingback Flat",
        priceCents: 33_000,
        size: "EU 38",
        slot: "shoes",
      },
    ],
    lookNumber: "18",
    name: "Powder Rose",
    season: SS,
  },
];

/**
 * Cycle expansion. The 18 base looks are emitted four times into a single
 * LOOKS array of length 72. Each cycle uses a different permutation (generated
 * offline by simulated annealing against an adjacency-cost objective for column
 * counts 8..15) so duplicates never sit adjacent at any chooseCols result.
 * Cycles 1-3 suffix every id so React keys stay unique while cycle 0 keeps the
 * base objects by reference.
 */
const CYCLE_ORDERS: number[][] = [
  [0, 8, 2, 16, 4, 5, 11, 7, 1, 10, 17, 6, 12, 13, 14, 15, 3, 9],
  [13, 15, 3, 8, 9, 2, 16, 7, 5, 0, 1, 10, 6, 17, 11, 4, 14, 12],
  [17, 4, 13, 8, 14, 12, 2, 16, 9, 3, 15, 6, 5, 11, 7, 1, 0, 10],
  [5, 10, 4, 1, 0, 13, 14, 2, 8, 15, 12, 3, 17, 7, 16, 6, 9, 11],
];

const CYCLE_SUFFIXES = ["", "b", "c", "d"];

export const BASE_LOOKS: Look[] = RAW_LOOKS.map((look) =>
  Object.assign(look, { imageUrl: `${LOOK_IMAGE_BASE}/${look.id}.jpg` }),
);

const cloneLook = (look: Look, suffix: string): Look => {
  const [top, lower, shoes] = look.items;
  return {
    ...look,
    id: `${look.id}-${suffix}`,
    items: [
      { ...top, id: `${top.id}-${suffix}` },
      { ...lower, id: `${lower.id}-${suffix}` },
      { ...shoes, id: `${shoes.id}-${suffix}` },
    ],
  };
};

export const LOOKS: Look[] = CYCLE_ORDERS.flatMap((order, cycleIdx) => {
  const suffix = CYCLE_SUFFIXES[cycleIdx] ?? "";
  const out: Look[] = [];
  for (const baseIdx of order) {
    const base = BASE_LOOKS[baseIdx];
    if (!base) {
      continue;
    }
    // Cycle 0 keeps the base objects by reference — only 1-3 clone, which is
    // what keeps `look-01` unsuffixed and every key unique.
    out.push(cycleIdx === 0 ? base : cloneLook(base, suffix));
  }
  return out;
});

export const lookTotal = (look: Look): number => {
  let sum = 0;
  for (const it of look.items) {
    sum += it.priceCents;
  }
  return sum;
};

export const fmtPrice = (cents: number): string =>
  `$${Math.round(cents / 100).toLocaleString("en-US")}`;

/* ── Layout geometry (world coordinates) ─────────────────────────── */

export const CARD_W = 188;
export const CARD_H = 282;
export const CELL_MARGIN_X = 22;
export const CELL_MARGIN_Y = 28;

export interface Pos {
  x: number;
  y: number;
  rot: number;
}

/**
 * Offset that moves a world coordinate onto the tiled copy nearest `centre`.
 *
 * The world is a torus: every card also exists at `v + k * tile` for every
 * integer k. Picking the k closest to the camera's world centre is the entire
 * edgeless mechanic — the RAF loop applies it per card per frame, and a
 * selection uses it so the camera flies to the copy already on screen rather
 * than to the canonical one a tile away.
 */
export const wrapOffset = (v: number, centre: number, tile: number): number =>
  -Math.round((v - centre) / tile) * tile;

/** Stable pseudo-random in [0, 1) from a numeric seed. */
const hash = (n: number): number => {
  const s = Math.sin(n * 12.9898 + 78.233) * 43_758.5453;
  return s - Math.floor(s);
};

/**
 * Pick the column count whose rendered pixel-grid aspect mirrors the viewport.
 * Only perfect divisions are considered so the wrap-tiled world never reveals
 * an empty cell.
 */
export const chooseCols = (count: number, viewportAspect: number): number => {
  const cardAspect = CARD_W / CARD_H;
  const target = viewportAspect / cardAspect;
  let best = 1;
  let bestDiff = Infinity;
  for (let cols = 1; cols <= count; cols += 1) {
    if (count % cols !== 0) {
      continue;
    }
    const rows = count / cols;
    const diff = Math.abs(cols / rows - target);
    if (diff < bestDiff) {
      bestDiff = diff;
      best = cols;
    }
  }
  return best;
};

/**
 * Deterministic grid with mild per-cell jitter. Each item sits at its cell
 * centre plus a hashed offset (±0.09 cellW, ±0.07 cellH) and a rotation of up
 * to ±2°. Positions are stable across renders.
 */
export const layoutPositions = (
  worldW: number,
  worldH: number,
  cols: number,
  count: number,
): Pos[] => {
  const rows = Math.ceil(count / cols);
  const cellW = worldW / cols;
  const cellH = worldH / rows;
  const out: Pos[] = [];
  for (let i = 0; i < count; i += 1) {
    const col = i % cols;
    const row = Math.floor(i / cols);
    const seed = i * 37 + 7;
    const ox = (hash(seed) - 0.5) * cellW * 0.18;
    const oy = (hash(seed + 1) - 0.5) * cellH * 0.14;
    const rot = (hash(seed + 2) - 0.5) * 4;
    out.push({
      rot,
      x: col * cellW + cellW / 2 + ox,
      y: row * cellH + cellH / 2 + oy,
    });
  }
  return out;
};
```

### `/preview.tsx`

```tsx
"use client";

import { LookbookCamera } from "./lookbook-camera";

const Preview = () => (
  <div className="h-dvh w-full overflow-hidden">
    <LookbookCamera />
  </div>
);

export default Preview;
```
