{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-wall",
  "title": "Image Wall",
  "author": "AlgorizeTech",
  "description": "A wall of screenshots that never ends, and a camera hanging over it: it rests zoomed in on one card, then every few seconds pulls back, glides to the next and settles on it. Use it wherever a set of images should keep showing itself — a hero, a gallery band, a showcase.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/ImageWallBlock.tsx",
      "content": "\"use client\";\n\nimport React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// ==========================================\n// IMAGE WALL\n// A wall of screenshots that never ends, and a camera hanging over it. The\n// camera rests zoomed in on one card; every few seconds it pulls back, glides\n// to the next card and settles on it, brightening that card while the rest\n// stay dimmed. The wall is one tile of cards in staggered columns, repeated\n// around itself eight times, so however far the camera travels it never\n// finds an edge. The wall fills whatever box it is given.\n// ==========================================\n\nexport interface WallItem {\n  /** Small mono caption at the top of the card. */\n  title: string;\n  /** The screenshot, cropped from the top into a 493:332 frame. */\n  image: string;\n}\n\nexport interface ImageWallProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onFocus\"> {\n  /** The cards on the wall, in reading order down the columns. */\n  items: WallItem[];\n  /** Staggered columns in one tile. */\n  columns?: number;\n  /** Milliseconds the camera rests on a card before moving on. `0` holds still. */\n  interval?: number;\n  /**\n   * How the camera walks the wall: `serial` goes card by card in order;\n   * `shuffle` visits every card once per round in a random order. Either\n   * way no card comes round again before all the others have had a turn.\n   */\n  order?: \"serial\" | \"shuffle\";\n  /** Camera scale while resting on a card. */\n  zoom?: number;\n  /** Camera scale on phones and small viewports (under 640px). Defaults to 0.32. */\n  mobileZoom?: number;\n  /**\n   * Milliseconds the camera drifts across the wall, pulled back with no card\n   * in focus, before its first landing — time for whatever sits beside the\n   * wall to arrive. `0` starts on the first card.\n   */\n  intro?: number;\n  /**\n   * Where in the window the resting card sits, as shares of its width and\n   * height — `{ x: 0.5, y: 0.5 }`, the middle, by default. Two walls side by\n   * side read better with one card resting high and the other low.\n   */\n  anchor?: { x?: number; y?: number };\n  /** Called with the index of the card the camera has settled on. */\n  onFocus?: (index: number) => void;\n}\n\n// ------------------------------------------\n// The wall's geometry, in wall units: 400 per column, 384-wide cards, 16\n// between rows; every other column starts 72 lower so the rows stagger.\n// ------------------------------------------\nconst COL_W = 400;\nconst CARD_W = 384;\nconst GAP = 16;\nconst STAGGER = 72;\n/** How far the camera pulls back mid-flight, as a share of its resting scale. */\nconst FLIGHT_PULL = 0.64;\n/** Resting scale in a short window (a band under some copy) and in a narrow one (a phone). */\nconst BAND_ZOOM = 0.72;\nconst PHONE_ZOOM = 0.5;\n// Inside the card: a 1px border, 6 of padding each side, a 16-tall caption, 6 below it.\nconst CARD_BORDER = 1;\nconst CARD_PAD = 6;\nconst CAPTION_H = 16;\nconst FRAME_W = CARD_W - (CARD_BORDER + CARD_PAD) * 2;\n/** Every screenshot sits in the same 493:332 frame. */\nexport const FRAME_RATIO = 493 / 332;\nconst FRAME_H = Math.round(FRAME_W / FRAME_RATIO);\nconst CARD_H = CARD_BORDER + CARD_PAD + CAPTION_H + CARD_PAD + FRAME_H + CARD_PAD + CARD_BORDER;\n\ninterface Placed extends WallItem {\n  left: number;\n  top: number;\n}\n\nfunction layout(items: WallItem[], columns: number) {\n  const bottoms = Array.from({ length: columns }, (_, c) => (c % 2 ? STAGGER : 0));\n  const placed: Placed[] = items.map((item, i) => {\n    const c = i % columns;\n    const card = { ...item, left: c * COL_W, top: bottoms[c] };\n    bottoms[c] += CARD_H + GAP;\n    return card;\n  });\n  return { placed, width: columns * COL_W, height: Math.max(...bottoms) };\n}\n\n// ------------------------------------------\n// Camera flights, on the Web Animations API. The canvas's `translate` and\n// `scale` run as two animations; a frame loop only reads the camera back out\n// (same curves, in JS) so the copies of the tile can be culled.\n// ------------------------------------------\ninterface Camera {\n  x: number;\n  y: number;\n  s: number;\n}\n\ntype Ease = \"linear\" | \"in1\" | \"out1\" | \"in2\" | \"out2\";\n// power1 / power2 in and out, exact as cubic-beziers and as functions.\nconst EASE_CSS: Record<Ease, string> = {\n  linear: \"linear\",\n  in1: \"cubic-bezier(0.33333, 0, 0.66667, 0.33333)\",\n  out1: \"cubic-bezier(0.33333, 0.66667, 0.66667, 1)\",\n  in2: \"cubic-bezier(0.33333, 0, 0.66667, 0)\",\n  out2: \"cubic-bezier(0.33333, 1, 0.66667, 1)\",\n};\nconst EASE_FN: Record<Ease, (p: number) => number> = {\n  linear: (p) => p,\n  in1: (p) => p * p,\n  out1: (p) => 1 - (1 - p) * (1 - p),\n  in2: (p) => p * p * p,\n  out2: (p) => 1 - (1 - p) * (1 - p) * (1 - p),\n};\n\n/** A value's path through a flight: stops (in seconds), each easing to the next. */\ninterface Stop {\n  at: number;\n  value: number;\n  ease: Ease;\n}\n\n/**\n * An `inOut` ease from `from` to `to` over [t0, t1], as an ease-in half and an\n * ease-out half meeting at the midpoint — which is exactly how GSAP's\n * power1.inOut / power2.inOut are defined.\n */\nfunction inOut(from: number, to: number, t0: number, t1: number, power: 1 | 2): Stop[] {\n  return [\n    { at: t0, value: from, ease: power === 1 ? \"in1\" : \"in2\" },\n    { at: (t0 + t1) / 2, value: (from + to) / 2, ease: power === 1 ? \"out1\" : \"out2\" },\n    { at: t1, value: to, ease: \"linear\" },\n  ];\n}\n\nfunction valueAt(stops: Stop[], t: number) {\n  for (let i = 0; i < stops.length - 1; i++) {\n    const a = stops[i];\n    const b = stops[i + 1];\n    if (t < a.at) return a.value;\n    if (t < b.at) return a.value + (b.value - a.value) * EASE_FN[a.ease]((t - a.at) / (b.at - a.at));\n  }\n  return stops[stops.length - 1].value;\n}\n\ninterface FlightPath {\n  x: Stop[];\n  /** Same stops as `x` (times and eases), other values. */\n  y: Stop[];\n  s: Stop[];\n  /** Seconds. */\n  duration: number;\n}\n\ninterface Flight {\n  /** Playing: not paused and not over. */\n  isActive: () => boolean;\n  paused: () => boolean;\n  pause: () => void;\n  resume: () => void;\n  /** Stop where it is, leaving the camera there. */\n  kill: () => void;\n}\n\nfunction fly(\n  canvas: HTMLElement,\n  path: FlightPath,\n  on: {\n    /** Every frame (and on pause): the camera, which the animations paint. */\n    update: (camera: Camera) => void;\n    /** On kill: the camera, to be painted before the animations let go. */\n    freeze: (camera: Camera) => void;\n    complete: () => void;\n  },\n): Flight {\n  const ms = path.duration * 1000;\n  const cameraAt = (time: number) => ({\n    x: valueAt(path.x, time / 1000),\n    y: valueAt(path.y, time / 1000),\n    s: valueAt(path.s, time / 1000),\n  });\n  const frames = (stops: Stop[], value: (stop: Stop, i: number) => Keyframe) =>\n    stops.map((stop, i) => ({\n      offset: stop.at / path.duration,\n      easing: EASE_CSS[stop.ease],\n      ...value(stop, i),\n    }));\n  // Held at the end until `complete` has painted the landing.\n  const options: KeyframeAnimationOptions = { duration: ms, fill: \"forwards\" };\n  const animations =\n    typeof canvas.animate === \"function\"\n      ? [\n          canvas.animate(\n            frames(path.x, (stop, i) => ({ translate: `${stop.value}px ${path.y[i]?.value ?? 0}px` })),\n            options,\n          ),\n          canvas.animate(\n            frames(path.s, (stop) => ({ scale: String(stop.value) })),\n            options,\n          ),\n        ]\n      : [];\n\n  let state: \"running\" | \"paused\" | \"over\" = \"running\";\n  let frame = 0;\n  const time = () => Math.min((animations[0]?.currentTime as number | null) ?? ms, ms);\n  const tick = () => {\n    if (state !== \"running\") return;\n    on.update(cameraAt(time()));\n    frame = requestAnimationFrame(tick);\n  };\n  const release = () => {\n    state = \"over\";\n    cancelAnimationFrame(frame);\n    for (const animation of animations) animation.cancel();\n  };\n\n  if (animations.length === 0) {\n    state = \"over\";\n    on.update(cameraAt(ms));\n    on.complete();\n  } else {\n    frame = requestAnimationFrame(tick);\n    Promise.all(animations.map((animation) => animation.finished)).then(\n      () => {\n        if (state === \"over\") return;\n        on.complete();\n        release();\n      },\n      // Cancelled: killed, or torn down.\n      () => {},\n    );\n  }\n\n  return {\n    isActive: () => state === \"running\",\n    paused: () => state === \"paused\",\n    pause: () => {\n      if (state !== \"running\") return;\n      state = \"paused\";\n      cancelAnimationFrame(frame);\n      for (const animation of animations) animation.pause();\n      on.update(cameraAt(time()));\n    },\n    resume: () => {\n      if (state !== \"paused\") return;\n      state = \"running\";\n      for (const animation of animations) animation.play();\n      frame = requestAnimationFrame(tick);\n    },\n    kill: () => {\n      if (state === \"over\") return;\n      on.freeze(cameraAt(time()));\n      release();\n    },\n  };\n}\n\nconst prefersReducedMotion = () =>\n  typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n// ------------------------------------------\n// Root\n// ------------------------------------------\n// One card on the wall. Memoised: the wall draws its tile nine times over, so\n// a camera landing must re-render only the card that lost the focus and the\n// one that gained it — never all of them.\nconst WallCard = React.memo(function WallCard({\n  card,\n  left,\n  top,\n  copy,\n  isFocus,\n}: {\n  card: Placed;\n  left: number;\n  top: number;\n  copy: boolean;\n  isFocus: boolean;\n}) {\n  return (\n    <figure\n      aria-hidden={copy || undefined}\n      className=\"absolute flex flex-col gap-1.5 overflow-hidden rounded-xl border border-foreground/10 bg-muted p-1.5 shadow-sm transition-[opacity,transform] duration-500 ease-out\"\n      style={{\n        left,\n        top,\n        width: CARD_W,\n        height: CARD_H,\n        opacity: isFocus ? 1 : REST_DIM,\n        transform: isFocus ? \"scale(1.012)\" : \"none\",\n      }}\n    >\n      <figcaption className=\"flex h-4 shrink-0 items-center px-1 font-mono text-[9px] leading-none text-muted-foreground\">\n        {card.title}\n      </figcaption>\n      <div\n        className=\"relative min-h-0 overflow-hidden rounded-lg border border-foreground/10 bg-card\"\n        style={{ height: FRAME_H }}\n      >\n        <img\n          src={card.image}\n          alt=\"\"\n          width={FRAME_W}\n          height={FRAME_H}\n          // Not lazy: the browser never counts the copies as in view\n          // inside the transformed canvas, and they share the originals'\n          // URLs anyway.\n          decoding=\"async\"\n          draggable={false}\n          className=\"absolute inset-0 h-full w-full object-cover object-top\"\n        />\n      </div>\n    </figure>\n  );\n});\n\n/** Cards at rest; the focused one is full strength. */\nconst REST_DIM = 0.55;\n/**\n * In flight every card sits at 0.45. Rather than transition hundreds of cards,\n * one layer over all of them fades to 0.45 / 0.55 of what they show at rest.\n */\nconst FLIGHT_DIM = 0.45 / REST_DIM;\n\n/** Before the first camera paint only the tile itself is drawn. */\nconst CENTER_TILE = \"0,0\";\n\n/** How long the page must be still before a paused flight resumes. */\nconst SCROLL_SETTLE = 150;\n\nconst ImageWallInner = forwardRef<HTMLDivElement, ImageWallProps>(\n  (\n    {\n      items,\n      columns = 4,\n      interval = 4500,\n      order = \"serial\",\n      zoom = 1.35,\n      mobileZoom,\n      intro = 0,\n      anchor,\n      onFocus,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const ax = anchor?.x ?? 0.5;\n    const ay = anchor?.y ?? 0.5;\n    const wrapRef = useRef<HTMLDivElement | null>(null);\n    const canvasRef = useRef<HTMLDivElement>(null);\n    const cameraRef = useRef({ x: 0, y: 0, s: zoom });\n    const flightRef = useRef<Flight | null>(null);\n\n    const { placed, width, height } = useMemo(() => layout(items, columns), [items, columns]);\n    const n = placed.length;\n\n    const [focus, setFocus] = useState(0);\n    const focusRef = useRef(0);\n    focusRef.current = focus;\n    const [flying, setFlying] = useState(false);\n    const [hover, setHover] = useState(false);\n    const [hidden, setHidden] = useState(false);\n    const [onScreen, setOnScreen] = useState(true);\n\n    // ---- Which copies of the tile are drawn. The wall is its tile and eight\n    // neighbours so the camera never sees an edge, but the window only ever\n    // overlaps a few of them: render those (plus a card's width of margin),\n    // and re-render only when the set changes, not on every camera frame.\n    const tileRef = useRef({ width, height });\n    tileRef.current = { width, height };\n    const [tiles, setTiles] = useState(CENTER_TILE);\n    const tilesRef = useRef(CENTER_TILE);\n    // The window's size, cached: `cull` runs on every camera frame, and reading\n    // clientWidth there would force a style and layout flush each time.\n    const sizeRef = useRef({ w: 0, h: 0 });\n    useLayoutEffect(() => {\n      const wrap = wrapRef.current;\n      if (!wrap) return;\n      sizeRef.current = { w: wrap.clientWidth, h: wrap.clientHeight };\n      if (typeof ResizeObserver === \"undefined\") return;\n      const ro = new ResizeObserver(([entry]) => {\n        sizeRef.current = { w: entry.contentRect.width, h: entry.contentRect.height };\n      });\n      ro.observe(wrap);\n      return () => ro.disconnect();\n    }, []);\n    const cull = useCallback(() => {\n      const wrap = wrapRef.current;\n      if (!wrap) return;\n      const { x, y, s } = cameraRef.current;\n      const { width: tw, height: th } = tileRef.current;\n      let { w, h } = sizeRef.current;\n      if (!w || !h) {\n        w = wrap.clientWidth;\n        h = wrap.clientHeight;\n      }\n      // The window in canvas coordinates, where the centre tile spans 0..tw.\n      // A copy joins once it is within a card of the window but only leaves\n      // once it is two cards clear, so a camera resting near a tile's edge\n      // never mounts and unmounts the same copy back and forth.\n      const near = (dx: number, dy: number, m: number) =>\n        dx * tw < (w - x) / s + m &&\n        (dx + 1) * tw > -x / s - m &&\n        dy * th < (h - y) / s + m &&\n        (dy + 1) * th > -y / s - m;\n      const current = new Set(tilesRef.current.split(\"|\"));\n      const keep: string[] = [];\n      for (let dy = -1; dy <= 1; dy++) {\n        for (let dx = -1; dx <= 1; dx++) {\n          const id = `${dx},${dy}`;\n          if (near(dx, dy, CARD_W) || (current.has(id) && near(dx, dy, CARD_W * 2))) keep.push(id);\n        }\n      }\n      const key = keep.join(\"|\") || CENTER_TILE;\n      if (key !== tilesRef.current) {\n        tilesRef.current = key;\n        setTiles(key);\n      }\n    }, []);\n\n    const paint = useCallback(() => {\n      const el = canvasRef.current;\n      if (!el) return;\n      const { x, y, s } = cameraRef.current;\n      // `translate` then `scale`, about the top-left corner — the transform\n      // translate3d(x, y, 0) scale(s), split into the two properties the\n      // flights animate.\n      el.style.translate = `${x}px ${y}px`;\n      el.style.scale = String(s);\n      // The server renders the canvas invisible: it has no camera until now.\n      el.style.opacity = \"1\";\n      cull();\n    }, [cull]);\n\n    // A flight's callbacks: follow the camera for culling (the animations\n    // paint it), or paint it where a killed flight stops.\n    const flightHooks = useCallback(\n      (complete: () => void) => ({\n        update: (camera: Camera) => {\n          cameraRef.current = camera;\n          cull();\n        },\n        freeze: (camera: Camera) => {\n          cameraRef.current = camera;\n          paint();\n        },\n        complete,\n      }),\n      [cull, paint],\n    );\n\n    // Where the camera rests to hold card `i` at the anchor — the middle of the window by default.\n    const restFor = useCallback(\n      (i: number) => {\n        const wrap = wrapRef.current;\n        const card = placed[i];\n        if (!wrap || !card) return { x: 0, y: 0, s: zoom };\n        const W = wrap.clientWidth;\n        const H = wrap.clientHeight;\n        // A short window (a band under some copy) pulls the camera back so a\n        // few cards show at once instead of one card spilling out of the band;\n        // a narrow one (a phone) pulls back further still, so the flight from\n        // card to card is a visible journey rather than a swap.\n        const s =\n          mobileZoom !== undefined\n            ? mobileZoom\n            : W <= 640\n              ? Math.min(zoom, PHONE_ZOOM)\n              : H < 420\n                ? Math.min(zoom, BAND_ZOOM)\n                : zoom;\n        const cx = card.left + CARD_W / 2;\n        const cy = card.top + CARD_H / 2;\n        return { x: W * ax - s * cx, y: H * ay - s * cy, s };\n      },\n      [placed, zoom, mobileZoom, ax, ay],\n    );\n\n    // ---- First frame, and every resize: snap to the focused card. On the very\n    // first frame, with an `intro`, the camera instead starts pulled back and\n    // off to one side and drifts towards the first card, landing on it only\n    // when the intro is over.\n    const introDone = useRef(false);\n    useLayoutEffect(() => {\n      const wrap = wrapRef.current;\n      if (!wrap) return;\n      const snap = () => {\n        flightRef.current?.kill();\n        cameraRef.current = restFor(focusRef.current);\n        paint();\n        setFlying(false);\n      };\n      let introLive = false;\n      if (!introDone.current && intro > 0 && n > 0 && !prefersReducedMotion()) {\n        introDone.current = true;\n        introLive = true;\n        const target = restFor(focusRef.current);\n        const cam = cameraRef.current;\n        cam.s = target.s * FLIGHT_PULL;\n        cam.x = target.x - wrap.clientWidth * 0.35;\n        cam.y = target.y - wrap.clientHeight * 0.2;\n        paint();\n        setFlying(true);\n        // x and y drift over the whole intro plus a second (power1.inOut);\n        // the scale holds, then settles in over that last second (power2.inOut).\n        const drift = intro / 1000;\n        const duration = drift + 1;\n        flightRef.current = canvasRef.current\n          ? fly(\n              canvasRef.current,\n              {\n                x: inOut(cam.x, target.x, 0, duration, 1),\n                y: inOut(cam.y, target.y, 0, duration, 1),\n                s: [{ at: 0, value: cam.s, ease: \"linear\" }, ...inOut(cam.s, target.s, drift, duration, 2)],\n                duration,\n              },\n              flightHooks(() => {\n                introLive = false;\n                cameraRef.current = restFor(focusRef.current);\n                paint();\n                setFlying(false);\n              }),\n            )\n          : null;\n      } else {\n        introDone.current = true;\n        snap();\n      }\n      const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(snap);\n      ro?.observe(wrap);\n      return () => {\n        ro?.disconnect();\n        // Torn down mid-intro — React's rehearsal unmount in development, or the\n        // geometry changing under it — stop it and let the next run start it\n        // afresh. Left alone, a killed intro never clears `flying`, and the\n        // camera never moves again.\n        if (introLive) {\n          flightRef.current?.kill();\n          introDone.current = false;\n          setFlying(false);\n        }\n      };\n    }, [restFor, paint, flightHooks, intro, n]);\n\n    // ---- The flight: pull back while gliding, then settle in.\n    const flyTo = useCallback(\n      (i: number) => {\n        const target = restFor(i);\n        flightRef.current?.kill();\n        if (prefersReducedMotion()) {\n          cameraRef.current = target;\n          paint();\n          setFocus(i);\n          return;\n        }\n        const canvas = canvasRef.current;\n        if (!canvas) return;\n        setFlying(true);\n        const cam = cameraRef.current;\n        const pull = target.s * FLIGHT_PULL;\n        // Two seconds, power2.inOut throughout: x and y glide the whole way,\n        // the scale pulls back over the first second and settles over the second.\n        flightRef.current = fly(\n          canvas,\n          {\n            x: inOut(cam.x, target.x, 0, 2, 2),\n            y: inOut(cam.y, target.y, 0, 2, 2),\n            s: [...inOut(cam.s, pull, 0, 1, 2).slice(0, -1), ...inOut(pull, target.s, 1, 2, 2)],\n            duration: 2,\n          },\n          flightHooks(() => {\n            // Land where the card is *now*: the box may have resized mid-flight.\n            cameraRef.current = restFor(i);\n            paint();\n            setFlying(false);\n            setFocus(i);\n          }),\n        );\n      },\n      [restFor, paint, flightHooks],\n    );\n\n    useEffect(() => {\n      onFocus?.(focus);\n    }, [focus, onFocus]);\n\n    // ---- Rest, then move on. In `serial` the next card is simply the next\n    // one; in `shuffle` a round is a random permutation and every card gets\n    // its turn before a new round starts (which never opens on the card that\n    // just closed the last one).\n    const roundRef = useRef<number[]>([]);\n    const nextCard = useCallback(() => {\n      const current = focusRef.current;\n      if (order === \"serial\") return (current + 1) % n;\n      if (roundRef.current.length === 0) {\n        const round = Array.from({ length: n }, (_, i) => i);\n        for (let i = round.length - 1; i > 0; i--) {\n          const j = Math.floor(Math.random() * (i + 1));\n          [round[i], round[j]] = [round[j], round[i]];\n        }\n        if (round[0] === current && n > 1) [round[0], round[n - 1]] = [round[n - 1], round[0]];\n        roundRef.current = round;\n      }\n      return roundRef.current.shift() ?? (current + 1) % n;\n    }, [order, n]);\n\n    // ---- While the page scrolls the camera holds still: a flight freezes\n    // mid-air and picks up where it left off once the scroll settles, and no\n    // new one starts. Scrolling and a flight both need every frame; this way\n    // the scroll gets them. The flag flips only when scrolling starts or stops,\n    // never per scroll event, so the wall does not re-render as the page moves.\n    const [scrolling, setScrolling] = useState(false);\n    useEffect(() => {\n      let settle = 0;\n      let active = false;\n      const onScroll = () => {\n        if (!active) {\n          active = true;\n          setScrolling(true);\n        }\n        window.clearTimeout(settle);\n        settle = window.setTimeout(() => {\n          active = false;\n          setScrolling(false);\n        }, SCROLL_SETTLE);\n      };\n      window.addEventListener(\"scroll\", onScroll, { passive: true });\n      return () => {\n        window.removeEventListener(\"scroll\", onScroll);\n        window.clearTimeout(settle);\n      };\n    }, []);\n    // A hidden tab holds a flight as well; it carries on from where it\n    // stopped once the tab is back.\n    const holdFlight = scrolling || hidden;\n    useEffect(() => {\n      const flight = flightRef.current;\n      if (!flight) return;\n      if (holdFlight) flight.pause();\n      else if (flight.paused()) flight.resume();\n    }, [holdFlight]);\n\n    const paused = hover || hidden || !onScreen || scrolling;\n    useEffect(() => {\n      if (interval <= 0 || n < 2 || paused || flying) return;\n      const t = window.setTimeout(() => flyTo(nextCard()), interval);\n      return () => window.clearTimeout(t);\n    }, [focus, interval, n, paused, flying, flyTo, nextCard]);\n\n    useEffect(\n      () => () => {\n        flightRef.current?.kill();\n      },\n      [],\n    );\n\n    useEffect(() => {\n      const onVisibility = () => setHidden(document.hidden);\n      document.addEventListener(\"visibilitychange\", onVisibility);\n      return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n    }, []);\n\n    useEffect(() => {\n      const el = wrapRef.current;\n      if (!el || typeof IntersectionObserver === \"undefined\") return;\n      const io = new IntersectionObserver((entries) => setOnScreen(entries.some((e) => e.isIntersecting)), { threshold: 0.15 });\n      io.observe(el);\n      return () => io.disconnect();\n    }, []);\n\n    // The copies of the tile the camera can currently see (see `cull`).\n    const offsets = useMemo(\n      () =>\n        tiles.split(\"|\").map((t) => {\n          const [dx, dy] = t.split(\",\").map(Number);\n          return { dx, dy };\n        }),\n      [tiles],\n    );\n\n    // The focused card, or none mid-flight. Hover, visibility and on-screen\n    // changes leave the cards alone entirely; a landing touches two of them.\n    const lit = flying ? -1 : focus;\n    const cards = useMemo(\n      () =>\n        offsets.map(({ dx, dy }) => {\n          const copy = dx !== 0 || dy !== 0;\n          return placed.map((card, i) => (\n            <WallCard\n              key={`${dx},${dy},${i}`}\n              card={card}\n              left={card.left + dx * width}\n              top={card.top + dy * height}\n              copy={copy}\n              isFocus={!copy && i === lit}\n            />\n          ));\n        }),\n      [offsets, placed, width, height, lit],\n    );\n\n    return (\n      <div\n        role=\"img\"\n        aria-label={placed[focus] ? placed[focus].title : \"Image wall\"}\n        {...props}\n        ref={(node) => {\n          wrapRef.current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n        }}\n        onMouseEnter={(e) => {\n          setHover(true);\n          props.onMouseEnter?.(e);\n        }}\n        onMouseLeave={(e) => {\n          setHover(false);\n          props.onMouseLeave?.(e);\n        }}\n        className={cn(\"relative h-96 w-full overflow-hidden\", className)}\n      >\n        <div\n          ref={canvasRef}\n          className=\"absolute left-0 top-0 opacity-0 transition-opacity duration-700 will-change-transform\"\n          style={{ width, height, transformOrigin: \"0 0\" }}\n        >\n          {/* The flight dim: one layer fading, not every card. */}\n          <div\n            className=\"absolute inset-0 transition-opacity duration-500 ease-out\"\n            style={{ opacity: flying ? FLIGHT_DIM : 1 }}\n          >\n            {cards}\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\nImageWallInner.displayName = \"ImageWall\";\n\n/**\n * Memoised: a host that re-renders on its own timers — a headline word taking\n * turns, a tools strip — must not re-render every card on the wall with it.\n * Pass stable `items`, `anchor` and `onFocus` to benefit.\n */\nexport const ImageWall = React.memo(ImageWallInner);\n\nexport default ImageWall;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/ImageWallBlock.tsx"
    }
  ],
  "docs": "Named export: ImageWall (plus the WallItem type). Pass `items` ({ title, image }[] — every image is cropped from its top edge into the same 493:332 frame) and give it a height with `className`; it fills its box. `columns` sets the tile, `interval` the rest between flights in ms (0 holds still), `order` is serial (card by card) or shuffle (every card once per round, at random), `zoom` the resting scale — a box under 420px tall eases it down to 0.72 so a few cards fit, one under 640px wide to 0.5 so the flight between cards stays visible on a phone; `intro` (ms) drifts the camera, pulled back with nothing in focus, before its first landing; `anchor` ({ x?, y? }, shares of the box, default the middle) is where the resting card sits; `onFocus` reports the card in view. The camera holds while hovered, while the tab is hidden and while off screen; under prefers-reduced-motion it cuts instead of flying.",
  "categories": [
    "media",
    "marketing"
  ],
  "type": "registry:component"
}
