{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids"],"dependencies":["gsap","lucide-react"],"description":"A drifting wall of photos bends away from the cursor like a gravity well, promoting whichever picture sits in the clearing — click and the whole wall pans to bring it to center.","devDependencies":[],"files":[{"content":"import type { Photo } from \"./photos\";\n\n/* ── Grid + tile geometry ─────────────────────────────────────────────── */\nexport const CELL_H_DESKTOP = 100;\nexport const CELL_H_MOBILE = 72;\nexport const CELL_GAP_DESKTOP = 12;\nexport const CELL_GAP_MOBILE = 8;\n/* The wrapping tile is this many viewports across. It only has to exceed the\n   frame (each cell renders once, at whichever torus repeat sits nearest the\n   frame centre) — the source used 3, trimmed here because the gallery can run\n   several previews at once and every cell is a live DOM node. */\nexport const TILE_SCALE_DESKTOP = 2.4;\nexport const TILE_SCALE_MOBILE = 2.4;\n\nexport const MOBILE_BREAKPOINT = 768;\n\n/* ── Gravity void radii (idle < hover < expanded) ─────────────────────── */\nexport const VOID_RADIUS_IDLE_DESKTOP = 200;\nexport const VOID_RADIUS_HOVER_DESKTOP = 280;\nexport const VOID_RADIUS_EXPANDED_DESKTOP = 420;\nexport const VOID_RADIUS_IDLE_MOBILE = 130;\nexport const VOID_RADIUS_HOVER_MOBILE = 180;\nexport const VOID_RADIUS_EXPANDED_MOBILE = 280;\n/* Absolute radii are tuned for a full viewport; the preview frame can be much\n   shorter. Cap the clearing so it can never swallow the whole wall. */\nexport const VOID_RADIUS_CAP_RATIO = 0.4;\nexport const VOID_RADIUS_CAP_RATIO_EXPANDED = 0.52;\n\n/* ── Physics tuning ───────────────────────────────────────────────────── */\nexport const VOID_LERP = 0.25;\nexport const PAN_LERP = 0.07;\nexport const RADIUS_LERP = 0.07;\nexport const SOFT_PUSH_RATIO = 1.3;\n/* Cells this far outside the frame skip their transform write. Generous\n   enough to cover the largest displacement the void can apply. */\nexport const CULL_MARGIN = 240;\n\nexport const CELL_CLASS =\n  \"absolute left-0 top-0 select-none overflow-hidden rounded-[3px] pointer-events-none will-change-transform origin-center [backface-visibility:hidden]\";\n\n/* ── Deterministic helpers (no Math.random anywhere) ──────────────────── */\nconst hash = (n: number): number => {\n  const x = Math.sin(n * 12.9898 + 78.233) * 43_758.5453;\n  return x - Math.floor(x);\n};\n\nexport const smoothstep = (a: number, b: number, x: number): number => {\n  const t = Math.max(0, Math.min(1, (x - a) / (b - a)));\n  return t * t * (3 - 2 * t);\n};\n\nexport const rowDriftSpeed = (rowIndex: number): number => {\n  const magnitude = 0.05 + hash(rowIndex * 41.3 + 17.1) * 0.18;\n  const sign = rowIndex % 2 === 0 ? -1 : 1;\n  return magnitude * sign;\n};\n\n/* ── Types ────────────────────────────────────────────────────────────── */\nexport interface Cell {\n  index: number;\n  rowIndex: number;\n  baseX: number;\n  baseY: number;\n  width: number;\n  height: number;\n  photoIndex: number;\n  angle: number;\n  rotation: number;\n  scale: number;\n}\n\nexport interface TileSize {\n  width: number;\n  height: number;\n  rows: number;\n}\n\nexport interface Dims {\n  vw: number;\n  vh: number;\n  isMobile: boolean;\n}\n\ninterface RawItem {\n  photoIndex: number;\n  width: number;\n  rawX: number;\n  colIdx: number;\n}\n\n/* Photo index sitting above `rawX` in the previous row, or -1 with no row above. */\nconst photoAbove = (prevRow: RawItem[] | null, rawX: number, gap: number): number => {\n  if (!prevRow) {\n    return -1;\n  }\n  for (const prev of prevRow) {\n    if (rawX >= prev.rawX && rawX < prev.rawX + prev.width + gap) {\n      return prev.photoIndex;\n    }\n  }\n  return -1;\n};\n\n/* Walks forward from `seed` until the pick differs from both neighbours. */\nconst pickPhoto = (seed: number, leftIdx: number, aboveIdx: number, poolSize: number): number => {\n  let candidate = seed;\n  let attempts = 0;\n  while ((candidate === leftIdx || candidate === aboveIdx) && attempts < poolSize) {\n    candidate = (candidate + 1) % poolSize;\n    attempts += 1;\n  }\n  return candidate;\n};\n\n/* ── Build the wrapping tile of cells ─────────────────────────────────── */\nexport const buildCells = (dims: Dims, photos: readonly [Photo, ...Photo[]]) => {\n  const { vw, vh, isMobile } = dims;\n  const cellH = isMobile ? CELL_H_MOBILE : CELL_H_DESKTOP;\n  const gap = isMobile ? CELL_GAP_MOBILE : CELL_GAP_DESKTOP;\n  const scale = isMobile ? TILE_SCALE_MOBILE : TILE_SCALE_DESKTOP;\n  const stepY = cellH + gap;\n\n  const targetTileW = vw * scale;\n  const targetTileH = vh * scale;\n\n  const rows = Math.ceil(targetTileH / stepY);\n  const tileH = rows * stepY;\n  const firstRowY = -tileH / 2 + cellH / 2 + gap / 2;\n\n  /* Pass 1 — pick photos with anti-collision, record natural row widths.\n     The left-neighbour half is exact. The above-neighbour half is only\n     best-effort: it tests the un-respaced `rawX` spans, and pass 2 then\n     redistributes the slack per row independently, so the column alignment\n     reasoned about here no longer holds by the time cells are committed.\n     Measured, it cuts vertical repeats to roughly a quarter of chance rather\n     than eliminating them. Making it exact means deferring photo selection to\n     pass 2, which is not worth the restructure at this repeat rate. */\n  const rawRows: RawItem[][] = [];\n  let maxNaturalWidth = 0;\n  let prevRow: RawItem[] | null = null;\n\n  for (let r = 0; r < rows; r += 1) {\n    const rowItems: RawItem[] = [];\n    let rawX = 0;\n    let colIdx = 0;\n\n    while (rawX < targetTileW) {\n      const left = rowItems.at(-1);\n      const leftIdx = left ? left.photoIndex : -1;\n      const candidate = pickPhoto(\n        Math.floor(hash(r * 17.3 + colIdx * 31.7 + 5) * photos.length),\n        leftIdx,\n        photoAbove(prevRow, rawX, gap),\n        photos.length,\n      );\n\n      const photo = photos[candidate] ?? photos[0];\n      const width = cellH * photo.aspect;\n      rowItems.push({ colIdx, photoIndex: candidate, rawX, width });\n      rawX += width + gap;\n      colIdx += 1;\n    }\n\n    rawRows.push(rowItems);\n    prevRow = rowItems;\n\n    const rowNaturalWidth = rawX - gap;\n    if (rowNaturalWidth > maxNaturalWidth) {\n      maxNaturalWidth = rowNaturalWidth;\n    }\n  }\n\n  /* Tile width = widest natural row; shorter rows widen their gaps to match,\n     so the horizontal wrap seam is clean. */\n  const tileW = maxNaturalWidth + gap;\n\n  /* Pass 2 — commit Cell objects, each row centred on world x = 0. */\n  const cells: Cell[] = [];\n  let idx = 0;\n\n  for (let r = 0; r < rows; r += 1) {\n    const rowItems = rawRows[r];\n    if (!rowItems || rowItems.length === 0) {\n      continue;\n    }\n\n    const last = rowItems.at(-1);\n    if (!last) {\n      continue;\n    }\n    const rowNaturalWidth = last.rawX + last.width;\n    const extra = tileW - rowNaturalWidth - gap;\n    const extraPerGap = rowItems.length > 1 ? extra / rowItems.length : 0;\n\n    let runningX = -tileW / 2 + gap / 2;\n    for (const item of rowItems) {\n      const cellCenterX = runningX + item.width / 2;\n      cells.push({\n        angle: hash(r * 31 + item.colIdx * 17 + 7) * Math.PI * 2,\n        baseX: cellCenterX,\n        baseY: r * stepY + firstRowY,\n        height: cellH,\n        index: idx,\n        photoIndex: item.photoIndex,\n        rotation: (hash(r * 5 + item.colIdx * 11 + 3) - 0.5) * 3.6,\n        rowIndex: r,\n        scale: 1 + (hash(r * 7 + item.colIdx * 13 + 5) - 0.5) * 0.05,\n        width: item.width,\n      });\n      idx += 1;\n      runningX += item.width + gap + extraPerGap;\n    }\n  }\n\n  return { cells, tile: { height: tileH, rows, width: tileW } };\n};\n","path":"/build-cells.ts","target":"uicapsule/gravity-wall/build-cells.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport type { ComponentProps, FC, ReactNode, Ref } from \"react\";\nimport { useEffect, useRef } from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { Bookmark, ChevronLeft, ChevronRight, Heart, X } from \"lucide-react\";\n\nimport type { Photo } from \"./photos\";\n\n/* The source shipped these as a `.moments-icon-btn` global class; inlined here\n   so the package carries no stylesheet. */\nconst ICON_BTN =\n  \"flex items-center justify-center rounded-full border border-white/10 bg-white/10 text-white/85 backdrop-blur-[12px] transition-colors hover:bg-white/20 hover:text-white\";\n\n/* Playfair Display came from next/font in the source app, which a content\n   package cannot use. Georgia keeps the serif character. */\nconst DISPLAY_FONT = \"Georgia, 'Times New Roman', serif\";\n\n/* The expanded state hangs satellite UI off the card: prev/next plus a title\n   that can wrap to two lines above (~150px), caption and description below\n   (~95px). The card is centred in the frame, so the taller side governs — the\n   budget below reserves twice the top chrome. Under ~430px of frame height the\n   chrome cannot fit at any card size; MIN_EXPANDED_H stops the card collapsing\n   to nothing while it degrades. Both are inert at the designed size, where\n   `expH` wins the Math.min. */\nconst EXPANDED_CHROME_H = 300;\nconst MIN_EXPANDED_H = 120;\n\ninterface IconButtonProps {\n  onClick: () => void;\n  label: string;\n  /** `sm` is the prev/next pair; `md` is like / save / close. */\n  size: \"sm\" | \"md\";\n  /** Only set for the two toggles, which are the only buttons with a state. */\n  pressed?: boolean;\n  ref?: Ref<HTMLButtonElement>;\n  children: ReactNode;\n}\n\n/* Card-level clicks open the detail view, so every control inside has to stop\n   the event before running its own action. */\nconst IconButton: FC<IconButtonProps> = ({ onClick, label, size, pressed, ref, children }) => (\n  <button\n    ref={ref}\n    type=\"button\"\n    onClick={(e) => {\n      e.stopPropagation();\n      onClick();\n    }}\n    className={`${ICON_BTN} ${size === \"sm\" ? \"size-8\" : \"size-9\"}`}\n    aria-label={label}\n    aria-pressed={pressed}\n  >\n    {children}\n  </button>\n);\n\ninterface ToggleButtonProps {\n  icon: LucideIcon;\n  pressed: boolean;\n  onLabel: string;\n  offLabel: string;\n  onClick: () => void;\n}\n\n/* Like / save: the only buttons with a state, so the only ones whose label flips. */\nconst ToggleButton: FC<ToggleButtonProps> = ({\n  icon: Icon,\n  pressed,\n  onLabel,\n  offLabel,\n  onClick,\n}) => (\n  <IconButton onClick={onClick} label={pressed ? onLabel : offLabel} size=\"md\" pressed={pressed}>\n    <Icon className=\"size-4\" fill={pressed ? \"currentColor\" : \"none\"} />\n  </IconButton>\n);\n\n/* Expanded, the card behaves as a modal — Escape and a backdrop click both\n   close it, and the wall behind is `aria-hidden`. Collapsed, it is the button\n   that opens the detail view. */\nconst surfaceProps = (\n  expanded: boolean,\n  title: string,\n  onOpen: () => void,\n): ComponentProps<\"div\"> => {\n  if (expanded) {\n    return {\n      \"aria-label\": title,\n      \"aria-modal\": true,\n      onClick: onOpen,\n      role: \"dialog\",\n      tabIndex: -1,\n    };\n  }\n  return {\n    \"aria-label\": `Open ${title}`,\n    onClick: onOpen,\n    onKeyDown: (e) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        onOpen();\n      }\n    },\n    role: \"button\",\n    tabIndex: 0,\n  };\n};\n\ninterface CardFrame {\n  w: number;\n  frameH: number;\n}\n\n/* Sizes the card for its state; the frame budgets stop it outgrowing the preview box. */\nconst cardFrame = (\n  aspect: number,\n  expanded: boolean,\n  isMobile: boolean,\n  vw: number,\n  vh: number,\n): CardFrame => {\n  const restH = isMobile ? 220 : 280;\n  const restMaxW = isMobile ? 270 : 400;\n  const expH = isMobile ? 280 : 360;\n  const expMaxW = isMobile ? 300 : 480;\n  const expMinW = isMobile ? 260 : 340;\n\n  const heightBudget = expanded\n    ? Math.min(vh * 0.44, Math.max(MIN_EXPANDED_H, vh - EXPANDED_CHROME_H))\n    : vh * 0.62;\n  const widthBudget = vw * (expanded ? 0.78 : 0.86);\n\n  const h = Math.min(expanded ? expH : restH, heightBudget);\n  const maxW = Math.min(expanded ? expMaxW : restMaxW, widthBudget);\n  /* Clamp against the height budget too: without it, a tall-but-narrow photo\n     takes the min-width branch below and recomputes frameH from width alone,\n     re-inflating past the budget and clipping the satellite UI in short frames. */\n  const minW = Math.min(expMinW, widthBudget, heightBudget * aspect);\n\n  let w = h * aspect;\n  let frameH = h;\n  if (w > maxW) {\n    w = maxW;\n    frameH = maxW / aspect;\n  } else if (expanded && w < minW) {\n    w = minW;\n    frameH = minW / aspect;\n  }\n  return { frameH, w };\n};\n\ninterface FeaturedCardProps {\n  photo: Photo;\n  expanded: boolean;\n  isMobile: boolean;\n  /** Frame size, so the card can never outgrow the preview box. */\n  vw: number;\n  vh: number;\n  liked: boolean;\n  saved: boolean;\n  onOpen: () => void;\n  onClose: () => void;\n  onNext: () => void;\n  onPrev: () => void;\n  onToggleLike: () => void;\n  onToggleSave: () => void;\n}\n\nexport const FeaturedCard: FC<FeaturedCardProps> = ({\n  photo,\n  expanded,\n  isMobile,\n  vw,\n  vh,\n  liked,\n  saved,\n  onOpen,\n  onClose,\n  onNext,\n  onPrev,\n  onToggleLike,\n  onToggleSave,\n}) => {\n  const cardRef = useRef<HTMLDivElement | null>(null);\n  const closeRef = useRef<HTMLButtonElement | null>(null);\n  /* Expanding strips the card's own `role`/`tabIndex`, which destroys the\n     focusability of the element that was just activated — so hand focus to\n     Close, and give it back on collapse. Seeded from `expanded` so a mount in\n     either state never steals focus from the surrounding page. */\n  const wasExpanded = useRef(expanded);\n\n  useEffect(() => {\n    if (wasExpanded.current === expanded) {\n      return;\n    }\n    wasExpanded.current = expanded;\n    if (expanded) {\n      closeRef.current?.focus();\n    } else {\n      cardRef.current?.focus();\n    }\n  }, [expanded]);\n\n  const { w, frameH } = cardFrame(photo.aspect, expanded, isMobile, vw, vh);\n\n  const likeBtn = (\n    <ToggleButton\n      icon={Heart}\n      pressed={liked}\n      onLabel=\"Remove like\"\n      offLabel=\"Like\"\n      onClick={onToggleLike}\n    />\n  );\n  const saveBtn = (\n    <ToggleButton\n      icon={Bookmark}\n      pressed={saved}\n      onLabel=\"Remove from saved\"\n      offLabel=\"Save\"\n      onClick={onToggleSave}\n    />\n  );\n  const closeBtn = (\n    <IconButton ref={closeRef} onClick={onClose} label=\"Close\" size=\"md\">\n      <X className=\"size-4\" />\n    </IconButton>\n  );\n\n  return (\n    <div\n      ref={cardRef}\n      className={`pointer-events-auto absolute${expanded ? \"\" : \" cursor-pointer\"}`}\n      style={{\n        height: frameH,\n        transform: \"translate(-50%, -50%)\",\n        transition:\n          \"width 0.25s cubic-bezier(0.16, 1, 0.3, 1), height 0.25s cubic-bezier(0.16, 1, 0.3, 1)\",\n        width: w,\n      }}\n      {...surfaceProps(expanded, photo.title, onOpen)}\n    >\n      {expanded && (\n        <div className=\"absolute bottom-full left-1/2 mb-4 w-max max-w-[80%] -translate-x-1/2 text-center\">\n          <div className=\"flex items-center justify-center gap-3\">\n            <IconButton onClick={onPrev} label=\"Previous photo\" size=\"sm\">\n              <ChevronLeft className=\"size-4\" />\n            </IconButton>\n            <IconButton onClick={onNext} label=\"Next photo\" size=\"sm\">\n              <ChevronRight className=\"size-4\" />\n            </IconButton>\n          </div>\n          <div className=\"mt-3 text-[10px] tracking-[0.24em] text-white/60 uppercase\">\n            {photo.category}\n          </div>\n          <div\n            className=\"mt-1 text-white\"\n            style={{\n              fontFamily: DISPLAY_FONT,\n              fontSize: isMobile ? 24 : 30,\n              lineHeight: 1.1,\n            }}\n          >\n            {photo.title}\n          </div>\n        </div>\n      )}\n\n      {expanded && !isMobile && (\n        <div className=\"absolute top-1/2 right-full mr-4 flex -translate-y-1/2 flex-col gap-2\">\n          {likeBtn}\n          {saveBtn}\n        </div>\n      )}\n\n      {/* Close sits on the card's own corner: with Share/Download dropped a\n          second side rail would be a lone floating button. */}\n      {expanded && !isMobile && <div className=\"absolute -top-3 -right-3 z-10\">{closeBtn}</div>}\n\n      <div\n        className=\"relative h-full w-full overflow-hidden rounded-[6px] bg-black\"\n        style={{ boxShadow: \"0 30px 70px -15px rgb(0 0 0 / 0.7)\" }}\n      >\n        <img\n          src={expanded ? photo.imageUrl : photo.thumbUrl}\n          alt={photo.title}\n          draggable={false}\n          decoding=\"async\"\n          className=\"h-full w-full object-cover\"\n        />\n        {!expanded && (\n          <div\n            className=\"absolute inset-x-0 bottom-0 p-3\"\n            style={{\n              background:\n                \"linear-gradient(to top, rgb(0 0 0 / 0.85) 0%, rgb(0 0 0 / 0.4) 50%, transparent 100%)\",\n            }}\n          >\n            <div className=\"text-[10px] tracking-[0.22em] text-white/75 uppercase\">\n              {photo.category}\n            </div>\n            <div className=\"truncate text-white\" style={{ fontFamily: DISPLAY_FONT, fontSize: 16 }}>\n              {photo.title}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {expanded && (\n        <div\n          className=\"absolute top-full left-1/2 mt-4 -translate-x-1/2 text-center\"\n          style={{\n            maxWidth: vw * 0.9,\n            minWidth: Math.min(isMobile ? 260 : 320, vw * 0.9),\n          }}\n        >\n          <div className=\"text-[11px] tracking-[0.24em] text-white/60 uppercase\">{photo.year}</div>\n\n          {isMobile && (\n            <div className=\"mt-3 flex items-center justify-center gap-2\">\n              {likeBtn}\n              {saveBtn}\n              {closeBtn}\n            </div>\n          )}\n\n          {!isMobile && (\n            <p\n              className=\"mx-auto mt-3 text-[13px] text-white/70\"\n              style={{ lineHeight: 1.6, maxWidth: 440 }}\n            >\n              {photo.description}\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n","path":"/featured-card.tsx","target":"uicapsule/gravity-wall/featured-card.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport type { Dispatch, FC, RefObject, SetStateAction } from \"react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\n\nimport type { Cell, Dims } from \"./build-cells\";\nimport type { Photo } from \"./photos\";\nimport {\n  buildCells,\n  CELL_CLASS,\n  CULL_MARGIN,\n  MOBILE_BREAKPOINT,\n  PAN_LERP,\n  RADIUS_LERP,\n  rowDriftSpeed,\n  smoothstep,\n  SOFT_PUSH_RATIO,\n  VOID_LERP,\n  VOID_RADIUS_CAP_RATIO,\n  VOID_RADIUS_CAP_RATIO_EXPANDED,\n  VOID_RADIUS_EXPANDED_DESKTOP,\n  VOID_RADIUS_EXPANDED_MOBILE,\n  VOID_RADIUS_HOVER_DESKTOP,\n  VOID_RADIUS_HOVER_MOBILE,\n  VOID_RADIUS_IDLE_DESKTOP,\n  VOID_RADIUS_IDLE_MOBILE,\n} from \"./build-cells\";\nimport { FeaturedCard } from \"./featured-card\";\n\n/* ── The wall — memoised so featured/expanded changes never re-render it ─ */\ninterface WallProps {\n  cells: Cell[];\n  photos: readonly [Photo, ...Photo[]];\n  itemsRef: RefObject<(HTMLDivElement | null)[]>;\n}\n\nconst WallBase = ({ cells, photos, itemsRef }: WallProps) => (\n  <>\n    {cells.map((cell, i) => {\n      const photo = photos[cell.photoIndex];\n      if (!photo) {\n        return null;\n      }\n      return (\n        <div\n          key={cell.index}\n          ref={(el) => {\n            itemsRef.current[i] = el;\n          }}\n          className={CELL_CLASS}\n          style={{ height: cell.height, opacity: 0, width: cell.width }}\n        >\n          <img\n            src={photo.thumbUrl}\n            alt=\"\"\n            draggable={false}\n            decoding=\"async\"\n            className=\"h-full w-full object-cover select-none\"\n          />\n        </div>\n      );\n    })}\n  </>\n);\n\nconst Wall = memo(WallBase);\n\n/* ── Mutable per-frame state, kept off React so the loop never re-renders ─ */\ninterface PhysicsState {\n  mouse: { x: number; y: number; has: boolean };\n  panOff: { x: number; y: number };\n  panTarget: { x: number; y: number };\n  voidWorld: { x: number; y: number };\n  voidRadius: number;\n  targetRadius: number;\n  rowOffsets: number[];\n  hovering: boolean;\n  pinned: boolean;\n  /** Mirrors the `expanded` state; written by `open`/`close` beside setState. */\n  expanded: boolean;\n  /** Index into the *current* cell array. Meaningless across a rebuild.\n   *  Mirrors the `featuredIdx` state; written by `setFeatured` beside setState. */\n  featuredIdx: number;\n  /** Index into `photos`, which survives a rebuild — see the seed step. */\n  featuredPhoto: number;\n}\n\ninterface IntroState {\n  entrance: number;\n  voidEmerge: number;\n  featuredEmerge: number;\n}\n\n/* Per-build constants the frame loop reads every tick. */\ninterface Frame {\n  vw: number;\n  vh: number;\n  tileW: number;\n  tileH: number;\n  halfDiag: number;\n  idleRadius: number;\n  hoverRadius: number;\n  expandedRadius: number;\n}\n\n/* Steps A–D of a tick: row drift, pan lerp, void position, void radius. */\nconst advanceVoid = (\n  p: PhysicsState,\n  introVals: IntroState,\n  frame: Frame,\n  reduceMotion: boolean,\n) => {\n  const isExpanded = p.expanded;\n  const { vw, vh } = frame;\n\n  /* A. per-row drift (frozen while a detail is open) */\n  if (!isExpanded && !reduceMotion) {\n    for (let r = 0; r < p.rowOffsets.length; r += 1) {\n      const offset = p.rowOffsets[r];\n      if (offset === undefined) {\n        continue;\n      }\n      p.rowOffsets[r] = offset + rowDriftSpeed(r);\n    }\n  }\n\n  /* B. pan lerp toward target */\n  p.panOff.x += (p.panTarget.x - p.panOff.x) * PAN_LERP;\n  p.panOff.y += (p.panTarget.y - p.panOff.y) * PAN_LERP;\n\n  /* C. void position (world coords) */\n  if (isExpanded) {\n    if (!p.pinned) {\n      const ddx = p.panTarget.x - p.panOff.x;\n      const ddy = p.panTarget.y - p.panOff.y;\n      if (Math.abs(ddx) < 0.5 && Math.abs(ddy) < 0.5) {\n        p.pinned = true;\n      }\n    }\n    if (p.pinned) {\n      p.voidWorld.x = vw / 2 - p.panOff.x;\n      p.voidWorld.y = vh / 2 - p.panOff.y;\n    }\n  } else if (p.mouse.has) {\n    const tx = p.mouse.x - p.panOff.x;\n    const ty = p.mouse.y - p.panOff.y;\n    p.voidWorld.x += (tx - p.voidWorld.x) * VOID_LERP;\n    p.voidWorld.y += (ty - p.voidWorld.y) * VOID_LERP;\n  }\n\n  /* D. target radius: idle < hover < expanded, scaled by intro emerge */\n  let baseR: number;\n  if (isExpanded) {\n    baseR = frame.expandedRadius;\n  } else if (p.hovering) {\n    baseR = frame.hoverRadius;\n  } else {\n    baseR = frame.idleRadius;\n  }\n  p.targetRadius = baseR * introVals.voidEmerge;\n  p.voidRadius += (p.targetRadius - p.voidRadius) * RADIUS_LERP;\n};\n\n/* Step E for one cell: writes its transform and opacity for this frame (skipped\n   when culled) and returns its distance from the void centre. */\nconst placeCell = (\n  cell: Cell,\n  el: HTMLDivElement,\n  p: PhysicsState,\n  introVals: IntroState,\n  frame: Frame,\n): number => {\n  const { vw, vh, tileW, tileH, halfDiag } = frame;\n  const R = p.voidRadius;\n  const SOFT = R * SOFT_PUSH_RATIO;\n  const vX = p.voidWorld.x;\n  const vY = p.voidWorld.y;\n  const { entrance } = introVals;\n  const revealActive = entrance < 1;\n\n  /* Re-home this cell into whichever torus repeat sits nearest the frame\n     centre — this is what makes a finite cell array look infinite. */\n  const driftedX = cell.baseX + (p.rowOffsets[cell.rowIndex] ?? 0);\n  const baseScreenX0 = driftedX + p.panOff.x;\n  const baseScreenY0 = cell.baseY + p.panOff.y;\n  const kX = Math.round((vw / 2 - baseScreenX0) / tileW);\n  const kY = Math.round((vh / 2 - baseScreenY0) / tileH);\n  const effectiveWorldX = driftedX + kX * tileW;\n  const effectiveWorldY = cell.baseY + kY * tileH;\n\n  const dx = effectiveWorldX - vX;\n  const dy = effectiveWorldY - vY;\n  // oxlint-disable-next-line unicorn/prefer-modern-math-apis -- Math.hypot is slower and this runs ~500 times a frame; same result for pixel-scale inputs\n  const dist = Math.sqrt(dx * dx + dy * dy);\n\n  const baseScreenX = effectiveWorldX + p.panOff.x;\n  const baseScreenY = effectiveWorldY + p.panOff.y;\n  if (baseScreenX + cell.width < -CULL_MARGIN) {\n    return dist;\n  }\n  if (baseScreenX > vw + CULL_MARGIN) {\n    return dist;\n  }\n  if (baseScreenY + cell.height < -CULL_MARGIN) {\n    return dist;\n  }\n  if (baseScreenY > vh + CULL_MARGIN) {\n    return dist;\n  }\n\n  let finalWorldX = effectiveWorldX;\n  let finalWorldY = effectiveWorldY;\n  let s = cell.scale;\n\n  if (dist < SOFT) {\n    const t = 1 - dist / SOFT;\n    const push = R * t * t;\n    let dirX: number;\n    let dirY: number;\n    if (dist < 0.5) {\n      /* Dead centre: fall back to the cell's own deterministic angle\n         instead of dividing by zero. */\n      dirX = Math.cos(cell.angle);\n      dirY = Math.sin(cell.angle);\n    } else {\n      dirX = dx / dist;\n      dirY = dy / dist;\n    }\n    finalWorldX = effectiveWorldX + dirX * push;\n    finalWorldY = effectiveWorldY + dirY * push;\n    if (dist > R * 0.55 && dist < R * 1.05) {\n      /* Ring bump — makes the void rim read as a lens, not a hole. */\n      const ringT =\n        smoothstep(R * 0.55, R * 0.8, dist) * (1 - smoothstep(R * 0.85, R * 1.05, dist));\n      s *= 1 + 0.06 * ringT;\n    }\n  }\n\n  let alpha = 1;\n  let introOffsetX = 0;\n  let introOffsetY = 0;\n  if (revealActive) {\n    const distFromCenter = Math.hypot(cell.baseX, cell.baseY);\n    const cellEntrance = entrance * 1 - (distFromCenter / halfDiag) * 0.4;\n    const entranceAlpha = Math.max(0, Math.min(1, cellEntrance * 1.5));\n    alpha = entranceAlpha;\n    s *= 0.3 + 0.7 * entranceAlpha;\n    if (distFromCenter > 1) {\n      const dirX = cell.baseX / distFromCenter;\n      const dirY = cell.baseY / distFromCenter;\n      const flyMag = (1 - entranceAlpha) * 420;\n      introOffsetX = dirX * flyMag;\n      introOffsetY = dirY * flyMag;\n    }\n  }\n\n  const screenX = finalWorldX + introOffsetX + p.panOff.x - cell.width / 2;\n  const screenY = finalWorldY + introOffsetY + p.panOff.y - cell.height / 2;\n  el.style.transform =\n    `translate3d(${screenX.toFixed(2)}px, ${screenY.toFixed(2)}px, 0) ` +\n    `rotate(${cell.rotation.toFixed(2)}deg) scale(${s.toFixed(3)})`;\n  el.style.opacity = alpha < 0.999 ? alpha.toFixed(3) : \"1\";\n  return dist;\n};\n\ninterface GravityWallProps {\n  /** Non-empty by construction, so `photos[0]` needs no guard. */\n  photos: readonly [Photo, ...Photo[]];\n}\n\n/** Membership toggle for one of the photo-slug sets (liked / saved). */\nconst useSlugToggle = (setSlugs: Dispatch<SetStateAction<Set<string>>>, slug: string) =>\n  useCallback(() => {\n    setSlugs((prev) => {\n      const next = new Set(prev);\n      if (next.has(slug)) {\n        next.delete(slug);\n      } else {\n        next.add(slug);\n      }\n      return next;\n    });\n  }, [setSlugs, slug]);\n\nexport const GravityWall: FC<GravityWallProps> = ({ photos }) => {\n  const [dims, setDims] = useState<Dims | null>(null);\n  const [featuredIdx, setFeaturedIdx] = useState(0);\n  const [expanded, setExpanded] = useState(false);\n  const [liked, setLiked] = useState<Set<string>>(() => new Set());\n  const [saved, setSaved] = useState<Set<string>>(() => new Set());\n\n  const sectionRef = useRef<HTMLElement | null>(null);\n  const anchorRef = useRef<HTMLDivElement | null>(null);\n  const itemEls = useRef<(HTMLDivElement | null)[]>([]);\n  /* Frame origin in client coordinates, so pointer maths stays correct even if\n     the section is not flush with the document origin. Anything that can move\n     the section relative to the viewport — resize, scroll, pointer entry — has\n     to refresh it, or `onMove` maps the cursor to the wrong world point. */\n  const originRef = useRef({ left: 0, top: 0 });\n  const reduceMotionRef = useRef(false);\n  /* The last dims we handed to `setDims`. The tolerance check has to run\n     against this rather than against the updater's `prev` — see `measure`. */\n  const lastDimsRef = useRef<Dims | null>(null);\n\n  const physics = useRef<PhysicsState>({\n    expanded: false,\n    featuredIdx: 0,\n    featuredPhoto: 0,\n    hovering: false,\n    mouse: { has: false, x: 0, y: 0 },\n    panOff: { x: 0, y: 0 },\n    panTarget: { x: 0, y: 0 },\n    pinned: false,\n    rowOffsets: [],\n    targetRadius: 0,\n    voidRadius: 0,\n    voidWorld: { x: 0, y: 0 },\n  });\n  const intro = useRef<IntroState>({\n    entrance: 0,\n    featuredEmerge: 0,\n    voidEmerge: 0,\n  });\n\n  const built = useMemo(() => (dims ? buildCells(dims, photos) : null), [dims, photos]);\n\n  const featuredCell =\n    built && built.cells.length > 0\n      ? built.cells[Math.min(featuredIdx, built.cells.length - 1)]\n      : undefined;\n  const currentPhoto: Photo = featuredCell\n    ? (photos[featuredCell.photoIndex] ?? photos[0])\n    : photos[0];\n\n  /* Autonomous motion (row drift + intro) is suppressed under reduced motion;\n     the pointer-driven displacement stays, since it is direct feedback. */\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const apply = () => {\n      reduceMotionRef.current = mq.matches;\n    };\n    apply();\n    mq.addEventListener(\"change\", apply);\n    return () => mq.removeEventListener(\"change\", apply);\n  }, []);\n\n  /* Re-reads the section box and republishes the origin. Returns the rect so\n     the caller that also needs the size does not pay for a second layout read. */\n  const refreshOrigin = useCallback((): DOMRect | null => {\n    const section = sectionRef.current;\n    if (!section) {\n      return null;\n    }\n    const rect = section.getBoundingClientRect();\n    originRef.current.left = rect.left;\n    originRef.current.top = rect.top;\n    return rect;\n  }, []);\n\n  /* ── Frame sizing, measured from the section rather than the window ── */\n  useEffect(() => {\n    const section = sectionRef.current;\n    if (!section) {\n      return;\n    }\n\n    let raf = 0;\n    const measure = () => {\n      const rect = refreshOrigin();\n      if (!rect) {\n        return;\n      }\n      const vw = Math.round(rect.width);\n      const vh = Math.round(rect.height);\n      /* A freshly-mounted iframe reports 0x0 on the first callback. Building\n         from that yields an empty cell array and NaN opacities. */\n      if (vw <= 0 || vh <= 0) {\n        return;\n      }\n      const isMobile = vw < MOBILE_BREAKPOINT;\n\n      /* Deliberately NOT `setDims(prev => …)`. React may replay a queued\n         updater against its *base* state on later renders — and it will replay\n         forever if the update landed on a lane the render does not include.\n         The first measure runs inside the passive-effect flush, which React\n         drives from a Scheduler `message` event; when that scheduler callback\n         sits at idle priority the update is tagged IdleLane, and every\n         subsequent (default-lane) render skips it. An updater that mints a\n         fresh object would then see `prev === null` every replay, hand back a\n         new `Dims` identity on every render, invalidate `built`, restart both\n         the rAF loop and the GSAP intro, and never settle — a black frame plus\n         \"Maximum update depth exceeded\". Comparing against a ref and passing a\n         concrete value makes the update replay-safe: however often React\n         re-applies it, `dims` keeps one identity. */\n      const last = lastDimsRef.current;\n      if (\n        last &&\n        last.isMobile === isMobile &&\n        Math.abs(last.vw - vw) < 24 &&\n        Math.abs(last.vh - vh) < 24\n      ) {\n        return;\n      }\n      const next: Dims = { isMobile, vh, vw };\n      lastDimsRef.current = next;\n      setDims(next);\n    };\n\n    /* Coalesce to one measure per frame — a drag-resize would otherwise\n       rebuild several hundred cells synchronously on every observer tick. */\n    const observer = new ResizeObserver(() => {\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(measure);\n    });\n    observer.observe(section);\n    measure();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      observer.disconnect();\n    };\n  }, [refreshOrigin]);\n\n  /* Single writer for \"which cell is featured\", so the photo identity used to\n     survive rebuilds can never drift out of step with the index. */\n  const setFeatured = useCallback((idx: number, cells: readonly Cell[]) => {\n    const p = physics.current;\n    const cell = cells[idx];\n    if (!cell) {\n      return;\n    }\n    p.featuredIdx = idx;\n    p.featuredPhoto = cell.photoIndex;\n    setFeaturedIdx(idx);\n  }, []);\n\n  /* ── Actions ─────────────────────────────────────────────────────── */\n  const open = useCallback(() => {\n    const p = physics.current;\n    if (p.expanded || !dims) {\n      return;\n    }\n    p.pinned = false;\n    p.panTarget.x = dims.vw / 2 - p.voidWorld.x;\n    p.panTarget.y = dims.vh / 2 - p.voidWorld.y;\n    p.expanded = true;\n    setExpanded(true);\n  }, [dims]);\n\n  const close = useCallback(() => {\n    const p = physics.current;\n    p.pinned = false;\n    p.expanded = false;\n    setExpanded(false);\n  }, []);\n\n  const navigate = useCallback(\n    (dir: 1 | -1) => {\n      if (!built || !dims) {\n        return;\n      }\n      const { cells, tile } = built;\n      if (cells.length === 0) {\n        return;\n      }\n      const p = physics.current;\n      const newIdx = (p.featuredIdx + dir + cells.length) % cells.length;\n      const nextCell = cells[newIdx];\n      if (!nextCell) {\n        return;\n      }\n\n      const driftedX = nextCell.baseX + (p.rowOffsets[nextCell.rowIndex] ?? 0);\n      const baseScreenX0 = driftedX + p.panOff.x;\n      const baseScreenY0 = nextCell.baseY + p.panOff.y;\n      /* Pick the torus repeat closest to frame centre, so prev/next never\n         travels the long way around the wrap. */\n      const kX = Math.round((dims.vw / 2 - baseScreenX0) / tile.width);\n      const kY = Math.round((dims.vh / 2 - baseScreenY0) / tile.height);\n      const nextWorldX = driftedX + kX * tile.width;\n      const nextWorldY = nextCell.baseY + kY * tile.height;\n\n      p.pinned = true;\n      p.panTarget.x = dims.vw / 2 - nextWorldX;\n      p.panTarget.y = dims.vh / 2 - nextWorldY;\n      setFeatured(newIdx, cells);\n    },\n    [built, dims, setFeatured],\n  );\n\n  const goNext = useCallback(() => navigate(1), [navigate]);\n  const goPrev = useCallback(() => navigate(-1), [navigate]);\n\n  const toggleLike = useSlugToggle(setLiked, currentPhoto.slug);\n  const toggleSave = useSlugToggle(setSaved, currentPhoto.slug);\n\n  /* ── Pointer / touch / hover listeners ───────────────────────────── */\n  useEffect(() => {\n    const section = sectionRef.current;\n    if (!section) {\n      return;\n    }\n    const p = physics.current;\n\n    const onMove = (e: PointerEvent) => {\n      p.mouse.x = e.clientX - originRef.current.left;\n      p.mouse.y = e.clientY - originRef.current.top;\n      p.mouse.has = true;\n    };\n    const onTouch = (e: TouchEvent) => {\n      const [touch] = e.touches;\n      if (!touch) {\n        return;\n      }\n      p.mouse.x = touch.clientX - originRef.current.left;\n      p.mouse.y = touch.clientY - originRef.current.top;\n      p.mouse.has = true;\n    };\n    const onEnter = () => {\n      refreshOrigin();\n      p.hovering = true;\n    };\n    const onLeave = () => {\n      p.hovering = false;\n    };\n\n    section.addEventListener(\"pointermove\", onMove);\n    section.addEventListener(\"touchmove\", onTouch, { passive: true });\n    section.addEventListener(\"pointerenter\", onEnter);\n    section.addEventListener(\"pointerleave\", onLeave);\n    /* Captured, so a scrolling *ancestor* counts too and not just the document:\n       `pointerenter` cannot fire for a cursor already inside the section, and\n       the ResizeObserver does not fire on scroll, so without this the void\n       detaches from the cursor by exactly the scroll delta. */\n    window.addEventListener(\"scroll\", refreshOrigin, { capture: true, passive: true });\n    return () => {\n      section.removeEventListener(\"pointermove\", onMove);\n      section.removeEventListener(\"touchmove\", onTouch);\n      section.removeEventListener(\"pointerenter\", onEnter);\n      section.removeEventListener(\"pointerleave\", onLeave);\n      window.removeEventListener(\"scroll\", refreshOrigin, { capture: true });\n    };\n  }, [refreshOrigin]);\n\n  /* ── Keyboard (only while a detail is open) ──────────────────────── */\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (!physics.current.expanded || e.repeat) {\n        return;\n      }\n      if (e.key === \"Escape\") {\n        close();\n      } else if (e.key === \"ArrowRight\") {\n        goNext();\n      } else if (e.key === \"ArrowLeft\") {\n        goPrev();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [close, goNext, goPrev]);\n\n  /* ── The rAF physics loop ────────────────────────────────────────── */\n  useEffect(() => {\n    if (!built || !dims) {\n      return;\n    }\n    const { cells, tile } = built;\n    const { vw, vh, isMobile } = dims;\n    const p = physics.current;\n    const introVals = intro.current;\n    const tileW = tile.width;\n    const tileH = tile.height;\n\n    /* The absolute radii are tuned for a full desktop viewport; cap them\n       against the frame so a short preview never loses the whole wall. */\n    const minDim = Math.min(vw, vh);\n    const idleRadius = Math.min(\n      isMobile ? VOID_RADIUS_IDLE_MOBILE : VOID_RADIUS_IDLE_DESKTOP,\n      minDim * VOID_RADIUS_CAP_RATIO,\n    );\n    const hoverRadius = Math.min(\n      isMobile ? VOID_RADIUS_HOVER_MOBILE : VOID_RADIUS_HOVER_DESKTOP,\n      minDim * VOID_RADIUS_CAP_RATIO,\n    );\n    const expandedRadius = Math.min(\n      isMobile ? VOID_RADIUS_EXPANDED_MOBILE : VOID_RADIUS_EXPANDED_DESKTOP,\n      minDim * VOID_RADIUS_CAP_RATIO_EXPANDED,\n    );\n\n    /* Reset world framing for this build. */\n    p.panOff.x = vw / 2;\n    p.panOff.y = vh / 2;\n    p.panTarget.x = vw / 2;\n    p.panTarget.y = vh / 2;\n    p.voidWorld.x = 0;\n    p.voidWorld.y = 0;\n    p.rowOffsets = Array.from({ length: tile.rows }, () => 0);\n    p.pinned = false;\n\n    /* Seed featured with the cell nearest world centre. While a detail is open\n       the photo on screen has to survive instead: a rebuild reindexes every\n       cell, so holding `featuredIdx` would silently swap the picture being\n       looked at — the open photo is re-found by identity. */\n    if (p.expanded) {\n      const kept = cells.findIndex((cell) => cell.photoIndex === p.featuredPhoto);\n      setFeatured(kept === -1 ? 0 : kept, cells);\n    } else {\n      let best = 0;\n      let bestD = Infinity;\n      for (let i = 0; i < cells.length; i += 1) {\n        const cell = cells[i];\n        if (!cell) {\n          continue;\n        }\n        const d = Math.hypot(cell.baseX, cell.baseY);\n        if (d < bestD) {\n          bestD = d;\n          best = i;\n        }\n      }\n      setFeatured(best, cells);\n    }\n\n    const frame: Frame = {\n      expandedRadius,\n      halfDiag: Math.hypot(vw, vh) / 2,\n      hoverRadius,\n      idleRadius,\n      tileH,\n      tileW,\n      vh,\n      vw,\n    };\n\n    let raf = 0;\n    const tick = () => {\n      advanceVoid(p, introVals, frame, reduceMotionRef.current);\n      const isExpanded = p.expanded;\n      const R = p.voidRadius;\n      const vX = p.voidWorld.x;\n      const vY = p.voidWorld.y;\n\n      let nearestIdx = p.featuredIdx;\n      let nearestDist = Infinity;\n\n      for (let i = 0; i < cells.length; i += 1) {\n        const cell = cells[i];\n        if (!cell) {\n          continue;\n        }\n        const el = itemEls.current[i];\n        if (!el) {\n          continue;\n        }\n        const dist = placeCell(cell, el, p, introVals, frame);\n        if (dist < nearestDist) {\n          nearestDist = dist;\n          nearestIdx = i;\n        }\n      }\n\n      /* F. featured index update (only on real change, only when closed) */\n      if (nearestIdx !== p.featuredIdx && nearestDist < R * 0.9 && !isExpanded) {\n        setFeatured(nearestIdx, cells);\n      }\n\n      /* G. position the featured anchor */\n      const anchor = anchorRef.current;\n      if (anchor) {\n        let sx: number;\n        let sy: number;\n        if (isExpanded && p.pinned) {\n          sx = vw / 2;\n          sy = vh / 2;\n        } else {\n          sx = vX + p.panOff.x;\n          sy = vY + p.panOff.y;\n        }\n        const fEm = introVals.featuredEmerge;\n        anchor.style.transform = `translate3d(${sx.toFixed(2)}px, ${sy.toFixed(2)}px, 0) scale(${fEm.toFixed(3)})`;\n        anchor.style.opacity = fEm < 0.999 ? fEm.toFixed(3) : \"1\";\n      }\n\n      raf = requestAnimationFrame(tick);\n    };\n\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [built, dims, setFeatured]);\n\n  /* ── Cinematic intro (runs once, snaps to final on teardown) ─────── */\n  /* Deliberately keyed on *whether* a wall exists rather than on `built`\n     itself: `built` gets a new identity on every resize, and re-running the\n     effect mid-intro would kill the timeline, snap the values to 1 in cleanup\n     and immediately replay the fly-in from 0. `dims` never returns to null\n     once measured, so this runs exactly once per mount — including the second\n     mount of a StrictMode double-invoke, which still replays the intro. */\n  const hasBuilt = built !== null;\n  useEffect(() => {\n    if (!hasBuilt) {\n      return;\n    }\n\n    const introVals = intro.current;\n\n    if (reduceMotionRef.current) {\n      introVals.entrance = 1;\n      introVals.voidEmerge = 1;\n      introVals.featuredEmerge = 1;\n      return;\n    }\n\n    introVals.entrance = 0;\n    introVals.voidEmerge = 0;\n    introVals.featuredEmerge = 0;\n    physics.current.voidRadius = 0;\n    physics.current.targetRadius = 0;\n\n    const tl = gsap.timeline({ delay: 0.1 });\n    tl.to(introVals, { duration: 1.7, ease: \"power2.inOut\", entrance: 1 }, 0);\n    tl.to(introVals, { duration: 1.1, ease: \"power2.out\", voidEmerge: 1 }, 0.2);\n    tl.to(introVals, { duration: 0.8, ease: \"back.out(1.4)\", featuredEmerge: 1 }, 0.7);\n\n    return () => {\n      tl.kill();\n      /* Snap to the settled state — leaving `entrance` at 0 renders a black\n         frame, since it drives every cell's opacity. */\n      introVals.entrance = 1;\n      introVals.voidEmerge = 1;\n      introVals.featuredEmerge = 1;\n    };\n  }, [hasBuilt]);\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-label=\"Interactive photo wall\"\n      className=\"relative isolate h-full w-full touch-none overflow-hidden bg-[#0a0a0a] text-white select-none\"\n    >\n      <div aria-hidden className=\"absolute inset-0\">\n        {built && <Wall cells={built.cells} photos={photos} itemsRef={itemEls} />}\n      </div>\n\n      {/* Symmetric vignette — replaces the source's corner gradient, which\n          existed only to darken the ground behind the headline. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 z-[14]\"\n        style={{\n          background:\n            \"radial-gradient(ellipse 78% 78% at 50% 50%, transparent 40%, rgb(10 10 10 / 0.5) 100%)\",\n        }}\n      />\n\n      {/* Dim overlay (fades in while expanded, backdrop click closes) */}\n      <div\n        aria-hidden\n        onClick={close}\n        className=\"absolute inset-0 z-20 transition-opacity duration-300\"\n        style={{\n          background: \"rgb(10 10 10 / 0.55)\",\n          opacity: expanded ? 1 : 0,\n          pointerEvents: expanded ? \"auto\" : \"none\",\n        }}\n      />\n\n      {/* Featured anchor (positioned every frame by the tick) */}\n      <div\n        ref={anchorRef}\n        className=\"pointer-events-none absolute top-0 left-0 z-30 will-change-transform\"\n        style={{ opacity: 0 }}\n      >\n        {built && dims && (\n          <FeaturedCard\n            photo={currentPhoto}\n            expanded={expanded}\n            isMobile={dims.isMobile}\n            vw={dims.vw}\n            vh={dims.vh}\n            liked={liked.has(currentPhoto.slug)}\n            saved={saved.has(currentPhoto.slug)}\n            onOpen={open}\n            onClose={close}\n            onNext={goNext}\n            onPrev={goPrev}\n            onToggleLike={toggleLike}\n            onToggleSave={toggleSave}\n          />\n        )}\n      </div>\n    </section>\n  );\n};\n","path":"/gravity-wall.tsx","target":"uicapsule/gravity-wall/gravity-wall.tsx","type":"registry:file"},{"content":"/* Photo metadata for the wall.\n *\n * Each seed's `id` is an Unsplash photo id; both renditions are derived from it\n * at module scope, so the component needs no CMS, no CDN and no local assets.\n * Aspects are only ever 3:4, 1:1 or 3:2 — the wall's row packing relies on that\n * narrow set to keep cell heights uniform.\n *\n * Title, category and description describe what is actually in the frame. They\n * deliberately make no claim about *where* a photo was taken: the ids are stock\n * images, so any place name would be invented, and an invented place name that\n * contradicts the picture reads as a bug. `year` is flavour, not a claim. */\n\nconst FULL_HEIGHT = 900;\nconst THUMB_HEIGHT = 300;\n\nconst unsplashUrl = (id: string, aspect: number, height: number) =>\n  `https://images.unsplash.com/photo-${id}?w=${Math.round(\n    height * aspect,\n  )}&h=${height}&fit=crop&q=80&auto=format`;\n\n/* Portrait (3:4) < square (1:1) < landscape (3:2). */\ntype Aspect = 0.75 | 1 | 1.5;\n\n/* Closed set, so a typo can't mint a one-off category that appears exactly\n   once in the deck. */\ntype Category = \"Landscape\" | \"Nature\" | \"Forest\" | \"Water\" | \"Night\" | \"Architecture\";\n\ninterface PhotoSeed {\n  id: string;\n  title: string;\n  category: Category;\n  description: string;\n  aspect: Aspect;\n  year: number;\n}\n\nexport interface Photo {\n  slug: string;\n  title: string;\n  category: Category;\n  description: string;\n  aspect: number;\n  /** Large rendition — only requested once a card is expanded. */\n  imageUrl: string;\n  /** Small rendition — every one of the ~500 wall cells uses this. */\n  thumbUrl: string;\n  year: number;\n}\n\nconst PHOTO_SEEDS: readonly [PhotoSeed, ...PhotoSeed[]] = [\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description:\n      \"Morning broke quietly above the cloud line — fifteen minutes after sunrise, before the wind picked up.\",\n    id: \"1506905925346-21bda4d32df4\",\n    title: \"Granite Light\",\n    year: 2023,\n  },\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description:\n      \"A figure on the last outcrop, for scale. The saddle behind looked closer than it was.\",\n    id: \"1469474968028-56623f02e42e\",\n    title: \"Far Saddle\",\n    year: 2022,\n  },\n  {\n    aspect: 1.5,\n    category: \"Water\",\n    description: \"Open swell an hour from land — the same shape arriving forever, never twice.\",\n    id: \"1518837695005-2083093ee35b\",\n    title: \"Long Water\",\n    year: 2021,\n  },\n  {\n    aspect: 1.5,\n    category: \"Forest\",\n    description: \"A path between trunks two hundred years older than the path.\",\n    id: \"1441974231531-c6227db76b6e\",\n    title: \"Cathedral\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Landscape\",\n    description: \"Cloud came up the valley between five and six — by seven the ridge was clear.\",\n    id: \"1470071459604-3b5ec3a7fe05\",\n    title: \"First Breath\",\n    year: 2022,\n  },\n  {\n    aspect: 1,\n    category: \"Forest\",\n    description:\n      \"A footbridge with nothing on either side but green, and no reason at all to hurry across it.\",\n    id: \"1447752875215-b2761acb3c5d\",\n    title: \"The Crossing\",\n    year: 2024,\n  },\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description:\n      \"Ridge behind ridge behind ridge, each one a shade paler, until the sky takes over.\",\n    id: \"1500964757637-c85e8a162699\",\n    title: \"Every Ridge\",\n    year: 2020,\n  },\n  {\n    aspect: 1.5,\n    category: \"Water\",\n    description: \"One boat, one wake, and water clear enough to make the depth a guess.\",\n    id: \"1501785888041-af3ef285b470\",\n    title: \"Still Passage\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Night\",\n    description: \"Every photon that landed here had been travelling for thousands of years.\",\n    id: \"1444080748397-f442aa95c3e5\",\n    title: \"Old Light\",\n    year: 2024,\n  },\n  {\n    aspect: 0.75,\n    category: \"Landscape\",\n    description:\n      \"Water found the one soft seam in the rock and spent a few million years widening it.\",\n    id: \"1431794062232-2a99a5431c6c\",\n    title: \"The Cut\",\n    year: 2021,\n  },\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description: \"The meadow keeps its own hours. The wall behind it keeps none at all.\",\n    id: \"1426604966848-d7adac402bff\",\n    title: \"The Big Wall\",\n    year: 2022,\n  },\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description: \"Distance stacked into layers — you can count them off like rings in a stump.\",\n    id: \"1490604001847-b712b0c2f967\",\n    title: \"Blue Distance\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Water\",\n    description: \"From above, the break stops being a wave and becomes a drawing.\",\n    id: \"1505144808419-1957a94ca61e\",\n    title: \"The Carve\",\n    year: 2022,\n  },\n  {\n    aspect: 1,\n    category: \"Landscape\",\n    description: \"The summit surfaced for about a minute, then the cloud closed over it again.\",\n    id: \"1505765050516-f72dcac9c60e\",\n    title: \"Above the Weather\",\n    year: 2024,\n  },\n  {\n    aspect: 1.5,\n    category: \"Forest\",\n    description:\n      \"Fog sitting in the trees at the height of a person, which is where fog is strangest.\",\n    id: \"1418065460487-3e41a6c84dc5\",\n    title: \"Low Cloud\",\n    year: 2022,\n  },\n  {\n    aspect: 1,\n    category: \"Nature\",\n    description: \"Two hundred years of growing outward instead of upward, because it could.\",\n    id: \"1502082553048-f009c37129b9\",\n    title: \"The Only Tree\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Nature\",\n    description: \"Twenty minutes when the field held more light than the sky above it.\",\n    id: \"1495107334309-fcf20504a5ab\",\n    title: \"Late Green\",\n    year: 2020,\n  },\n  {\n    aspect: 1,\n    category: \"Landscape\",\n    description: \"The river comes down in steps, and the mountain behind it does the same.\",\n    id: \"1472213984618-c79aaec7fef0\",\n    title: \"The Stair\",\n    year: 2022,\n  },\n  {\n    aspect: 1.5,\n    category: \"Architecture\",\n    description: \"The grid catches the last of the sun and holds it, one avenue at a time.\",\n    id: \"1480714378408-67cf0d13bc1b\",\n    title: \"Gridlight\",\n    year: 2024,\n  },\n  {\n    aspect: 1.5,\n    category: \"Architecture\",\n    description: \"Steel enough to cross a strait, painted a colour that argues with the sky.\",\n    id: \"1449034446853-66c86144b0ad\",\n    title: \"Red Span\",\n    year: 2023,\n  },\n  {\n    aspect: 1.5,\n    category: \"Landscape\",\n    description: \"Nothing out here but red rock and one road that refuses to go straight.\",\n    id: \"1500530855697-b586d89ba3ee\",\n    title: \"Long Way Round\",\n    year: 2024,\n  },\n  {\n    aspect: 0.75,\n    category: \"Architecture\",\n    description: \"A façade that is mostly shade — making it is the only job the building has here.\",\n    id: \"1488972685288-c3fd157d7c7a\",\n    title: \"Fin & Shadow\",\n    year: 2022,\n  },\n  {\n    aspect: 0.75,\n    category: \"Night\",\n    description: \"A million rooms, from far enough away that they read as a single thing.\",\n    id: \"1444723121867-7a241cacace9\",\n    title: \"The Basin\",\n    year: 2023,\n  },\n  {\n    aspect: 1,\n    category: \"Nature\",\n    description:\n      \"They had been standing like that long before the fog, and stayed after it lifted.\",\n    id: \"1465379944081-7f47de8d74ac\",\n    title: \"Slow Company\",\n    year: 2023,\n  },\n  {\n    aspect: 1,\n    category: \"Nature\",\n    description: \"Late June — every horizon the same shade of unrepeatable violet.\",\n    id: \"1499002238440-d264edd596ec\",\n    title: \"Field of Hours\",\n    year: 2024,\n  },\n  {\n    aspect: 0.75,\n    category: \"Landscape\",\n    description: \"The water gave the mountain back, one stop darker than it was lent.\",\n    id: \"1483728642387-6c3bdd6c93e5\",\n    title: \"Cold Mirror\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Night\",\n    description: \"Every lit window is somebody deciding not to go home just yet.\",\n    id: \"1486325212027-8081e485255e\",\n    title: \"Night Shift\",\n    year: 2023,\n  },\n  {\n    aspect: 1,\n    category: \"Water\",\n    description: \"A tree that decided to grow where the lake was, and somehow won the argument.\",\n    id: \"1494500764479-0c8f2919a3d8\",\n    title: \"Found Water\",\n    year: 2023,\n  },\n  {\n    aspect: 1.5,\n    category: \"Forest\",\n    description: \"Ten metres in, the sound changes before the light does.\",\n    id: \"1448375240586-882707db888b\",\n    title: \"Deep Green\",\n    year: 2022,\n  },\n  {\n    aspect: 0.75,\n    category: \"Architecture\",\n    description: \"Every plane set at an angle that catches a different hour of the day.\",\n    id: \"1487958449943-2429e8be8625\",\n    title: \"White Facets\",\n    year: 2023,\n  },\n  {\n    aspect: 0.75,\n    category: \"Architecture\",\n    description: \"Corrugated steel bent into a curve it has no business holding.\",\n    id: \"1486718448742-163732cd1544\",\n    title: \"Spiral\",\n    year: 2022,\n  },\n  {\n    aspect: 1.5,\n    category: \"Water\",\n    description: \"Somebody's grandfather built it out over the water, and nobody has argued since.\",\n    id: \"1470770841072-f978cf4d019e\",\n    title: \"The Boathouse\",\n    year: 2022,\n  },\n];\n\nconst toPhoto = (seed: PhotoSeed): Photo => ({\n  aspect: seed.aspect,\n  category: seed.category,\n  description: seed.description,\n  imageUrl: unsplashUrl(seed.id, seed.aspect, FULL_HEIGHT),\n  slug: seed.id,\n  thumbUrl: unsplashUrl(seed.id, seed.aspect, THUMB_HEIGHT),\n  title: seed.title,\n  year: seed.year,\n});\n\nconst [firstSeed, ...restSeeds] = PHOTO_SEEDS;\n\n/* Typed as a non-empty tuple so `PHOTOS[0]` narrows to `Photo` under\n   noUncheckedIndexedAccess without an assertion. */\nexport const PHOTOS: readonly [Photo, ...Photo[]] = [toPhoto(firstSeed), ...restSeeds.map(toPhoto)];\n","path":"/photos.ts","target":"uicapsule/gravity-wall/photos.ts","type":"registry:file"},{"content":"\"use client\";\n\nimport { GravityWall } from \"./gravity-wall\";\nimport { PHOTOS } from \"./photos\";\n\nconst Preview = () => <GravityWall photos={PHOTOS} />;\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/gravity-wall/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/gravity-wall","name":"gravity-wall","registryDependencies":[],"title":"Gravity Wall","type":"registry:block"}