{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-cloud",
  "title": "Logo Cloud",
  "description": "A trusted-by logo wall that optically equalises logos of wildly different aspect ratios, as a static grid or a reduced-motion-aware marquee.",
  "dependencies": [
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/logo-cloud.tsx",
      "content": "import * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport type { LogoCloudData, LogoCloudItem } from \"./logo-cloud.contract\"\n\n/** Keyframes ship via React 19 hoisted <style> — dedupe by href, no Tailwind config edits. */\nconst KEYFRAMES = `@keyframes zy-logo-cloud{to{transform:translateX(-50%)}}`\n\nexport type LogoCloudVariant = \"grid\" | \"marquee\"\nexport type LogoCloudTreatment = \"color\" | \"grayscale\" | \"mono\"\n\nexport interface LogoCloudProps extends LogoCloudData {\n  /** Static wrapping wall, or a seamless belt that freezes under prefers-reduced-motion. */\n  variant?: LogoCloudVariant\n  /** How much of the artwork's own colour survives. See TREATMENT for the trade-offs. */\n  treatment?: LogoCloudTreatment\n  /**\n   * Optical normalisation strength, 0–1.\n   * 1 = every logo gets the same *area*, which is what \"the same size\" means to the eye\n   * (measured area spread on a 1:1 … 5.5:1 … 1:2 set: 1.28x). 0 = every logo gets the\n   * same height, the naive look, where the caps below are the only thing keeping wide\n   * wordmarks in check (same set: 4.5x, and 11x with the caps removed as well).\n   * Intermediate values buy long wordmarks back some height.\n   */\n  balance?: number\n  /** Marquee only: seconds per full loop. Lower is faster. */\n  speed?: number\n  onRetry?: () => void\n  className?: string\n}\n\n/** Ratios outside this band are broken data, not a design decision — clamp rather than explode. */\nconst MIN_RATIO = 0.15\nconst MAX_RATIO = 14\n/** Hard caps in units of --logo-cloud-size, so one extreme mark can't dominate the row. */\nconst MAX_WIDTH = 2.6\nconst MAX_HEIGHT = 1.25\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))\nconst finite = (n: number, fallback: number) => (Number.isFinite(n) ? n : fallback)\n\n/**\n * Optical alignment — the whole point of this block.\n *\n * Logos arrive at wildly different aspect ratios (square app marks vs. 5:1 wordmarks).\n * Sizing them by a fixed height, the obvious move, equalises the wrong quantity: at a\n * shared height the wordmark covers `ratio` times more of the page and reads as louder.\n * So normalise the *area* instead: with w/h = r fixed, w = r^(1-k) and h = r^(-k) give\n * area r^(1-2k), which is constant at k = 0.5. `balance` interpolates k between 0 and\n * 0.5; the caps then bound the extremes, always by scaling both axes together so the\n * artwork is never distorted.\n *\n * Returned values are multiples of --logo-cloud-size, not pixels — the consumer keeps\n * a single CSS variable as the size knob.\n */\nexport function opticalBox(\n  item: Pick<LogoCloudItem, \"width\" | \"height\"> & { scale?: number },\n  balance = 1,\n): { w: number; h: number } {\n  const width = Math.max(finite(item.width, 1), Number.EPSILON)\n  const height = Math.max(finite(item.height, 1), Number.EPSILON)\n  const ratio = clamp(width / height, MIN_RATIO, MAX_RATIO)\n  const k = clamp(finite(balance, 1), 0, 1) * 0.5\n  const nudge = clamp(finite(item.scale ?? 1, 1), 0.5, 1.5)\n\n  let w = ratio ** (1 - k) * nudge\n  let h = ratio ** -k * nudge\n  if (w > MAX_WIDTH) {\n    h *= MAX_WIDTH / w\n    w = MAX_WIDTH\n  }\n  if (h > MAX_HEIGHT) {\n    w *= MAX_HEIGHT / h\n    h = MAX_HEIGHT\n  }\n  return { w, h }\n}\n\nconst boxStyle = ({ w, h }: { w: number; h: number }): React.CSSProperties => ({\n  width: `calc(var(--logo-cloud-size) * ${w.toFixed(4)})`,\n  height: `calc(var(--logo-cloud-size) * ${h.toFixed(4)})`,\n})\n\n/**\n * Neutralising treatments. `reveal` only ever applies to logos that are real links —\n * a colour change on hover is an affordance, and inert artwork must not claim one.\n */\nconst TREATMENT: Record<LogoCloudTreatment, { base: string; reveal: string }> = {\n  // Ships the artwork as authored: right when every logo already has a dark-mode pair.\n  // Nothing to un-filter on hover, so a linked logo dims instead.\n  color: { base: \"\", reveal: \"group-hover/logo:opacity-80 group-focus-visible/logo:opacity-80\" },\n  // Desaturates but keeps luminance, so a light logo still needs its own darkSrc.\n  grayscale: {\n    base: \"opacity-75 grayscale\",\n    reveal:\n      \"group-hover/logo:opacity-100 group-hover/logo:grayscale-0 group-focus-visible/logo:opacity-100 group-focus-visible/logo:grayscale-0\",\n  },\n  // Flattens to a single-ink silhouette that follows the theme — at the cost of all\n  // internal colour (multi-colour marks and colour-coded logos lose their identity).\n  mono: {\n    base: \"opacity-70 brightness-0 dark:invert\",\n    reveal: \"group-hover/logo:opacity-100 group-focus-visible/logo:opacity-100\",\n  },\n}\n\nfunction LogoImage({\n  balance,\n  item,\n  linked,\n  treatment,\n}: {\n  balance: number\n  item: LogoCloudItem\n  linked: boolean\n  treatment: LogoCloudTreatment\n}) {\n  const style = boxStyle(opticalBox(item, balance))\n  const classes = cn(\n    \"max-w-full object-contain transition-[filter,opacity] duration-200 motion-reduce:transition-none\",\n    TREATMENT[treatment].base,\n    linked && TREATMENT[treatment].reveal,\n  )\n  return (\n    <>\n      {/* eslint-disable-next-line @next/next/no-img-element -- portable registry source, logo host is consumer-supplied */}\n      <img\n        alt={item.name}\n        className={cn(classes, item.darkSrc && \"dark:hidden\")}\n        decoding=\"async\"\n        height={item.height}\n        loading=\"lazy\"\n        src={item.src}\n        style={style}\n        width={item.width}\n      />\n      {item.darkSrc && (\n        // eslint-disable-next-line @next/next/no-img-element -- see above\n        <img\n          alt={item.name}\n          className={cn(classes, \"hidden dark:block\")}\n          decoding=\"async\"\n          height={item.height}\n          loading=\"lazy\"\n          src={item.darkSrc}\n          style={style}\n          width={item.width}\n        />\n      )}\n    </>\n  )\n}\n\nfunction LogoCell({\n  balance,\n  cellClassName,\n  item,\n  treatment,\n}: {\n  balance: number\n  cellClassName: string\n  item: LogoCloudItem\n  treatment: LogoCloudTreatment\n}) {\n  const media = <LogoImage balance={balance} item={item} linked={Boolean(item.href)} treatment={treatment} />\n  return (\n    <li className={cellClassName}>\n      {item.href ? (\n        <a\n          className=\"group/logo flex items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n          href={item.href}\n        >\n          {media}\n        </a>\n      ) : (\n        media\n      )}\n    </li>\n  )\n}\n\n/**\n * Even rhythm on wide walls, but never wider than 40% of the row — a 375px phone then\n * still gets two columns instead of an eight-row tower of single logos.\n */\nconst GRID_CELL = \"flex min-w-[min(var(--logo-cloud-cell),40%)] items-center justify-center\"\nconst BELT_CELL = \"flex shrink-0 items-center justify-center\"\nconst WALL = \"flex flex-wrap items-center justify-center gap-x-10 gap-y-8\"\n\n/** Skeleton ratios span the same range as real logo data, so the placeholder wall has the\n *  same rhythm and height range as the one that replaces it. */\nconst SKELETON_RATIOS = [1, 3.4, 2.2, 4.8, 1.5, 3]\n\nexport function LogoCloud({\n  balance = 1,\n  className,\n  eyebrow,\n  items,\n  onRetry,\n  speed = 32,\n  status,\n  treatment = \"grayscale\",\n  variant = \"grid\",\n}: LogoCloudProps) {\n  // The belt renders twice; the second copy is loop filler only — aria-hidden so screen\n  // readers don't hear every brand twice, inert so its links stay out of the tab order.\n  const belt = (filler: boolean) => (\n    <ul\n      aria-hidden={filler || undefined}\n      className={cn(\n        \"flex w-max shrink-0 items-center gap-[var(--logo-cloud-gap)] pr-[var(--logo-cloud-gap)]\",\n        // The trailing gap is loop maths; with the animation off it is just a lopsided margin.\n        filler ? \"motion-reduce:hidden\" : \"motion-reduce:pr-0\",\n      )}\n      inert={filler || undefined}\n    >\n      {items.map(item => (\n        <LogoCell balance={balance} cellClassName={BELT_CELL} item={item} key={item.id} treatment={treatment} />\n      ))}\n    </ul>\n  )\n\n  return (\n    <section\n      className={cn(\n        \"flex w-full flex-col gap-8 [--logo-cloud-cell:9rem] [--logo-cloud-gap:2.5rem] [--logo-cloud-size:2.5rem]\",\n        className,\n      )}\n    >\n      {status === \"loading\" && (\n        <div aria-hidden=\"true\" className={WALL}>\n          {SKELETON_RATIOS.map(ratio => (\n            <div className={GRID_CELL} key={ratio}>\n              <div\n                className=\"animate-pulse rounded bg-muted\"\n                style={boxStyle(opticalBox({ height: 1, width: ratio }, balance))}\n              />\n            </div>\n          ))}\n        </div>\n      )}\n\n      {status === \"empty\" && (\n        <div className=\"flex flex-col items-center gap-2 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">No logos yet</p>\n          <p className=\"text-sm text-muted-foreground\">Customer logos appear here once they are published.</p>\n        </div>\n      )}\n\n      {status === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 rounded-xl border bg-card py-14 text-center\">\n          <p className=\"text-sm font-medium\">Couldn&apos;t load logos</p>\n          <p className=\"text-sm text-muted-foreground\">The data source didn&apos;t respond.</p>\n          {onRetry && (\n            <button\n              className=\"cursor-pointer rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              onClick={onRetry}\n              type=\"button\"\n            >\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {status === \"ready\" && (\n        <>\n          {eyebrow && <p className=\"text-center text-sm font-medium text-muted-foreground\">{eyebrow}</p>}\n\n          {variant === \"grid\" ? (\n            <ul className={WALL}>\n              {items.map(item => (\n                <LogoCell\n                  balance={balance}\n                  cellClassName={GRID_CELL}\n                  item={item}\n                  key={item.id}\n                  treatment={treatment}\n                />\n              ))}\n            </ul>\n          ) : (\n            <div\n              className={cn(\n                \"group/belt relative w-full overflow-hidden [--logo-cloud-fade:8%]\",\n                \"[mask-image:linear-gradient(to_right,transparent,black_var(--logo-cloud-fade),black_calc(100%_-_var(--logo-cloud-fade)),transparent)]\",\n                // Reduced motion: the belt becomes a static strip the user scrolls by hand.\n                \"motion-reduce:overflow-x-auto motion-reduce:[mask-image:none]\",\n              )}\n              style={\n                { \"--logo-cloud-duration\": `${clamp(finite(speed, 32), 4, 600)}s` } as React.CSSProperties\n              }\n            >\n              <style href=\"zyeon-logo-cloud\" precedence=\"medium\">\n                {KEYFRAMES}\n              </style>\n              <div\n                className={cn(\n                  \"flex w-max [animation:zy-logo-cloud_var(--logo-cloud-duration)_linear_infinite]\",\n                  \"group-hover/belt:[animation-play-state:paused]\",\n                  // Static fallback: no animation, and the single remaining copy centres\n                  // itself instead of hugging the left edge on a wide screen.\n                  \"motion-reduce:mx-auto motion-reduce:[animation:none]\",\n                )}\n              >\n                {belt(false)}\n                {belt(true)}\n              </div>\n            </div>\n          )}\n        </>\n      )}\n    </section>\n  )\n}\n\nexport default LogoCloud\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/logo-cloud.contract.ts",
      "content": "import { z } from \"zod\"\n\nexport const logoCloudItemSchema = z.object({\n  id: z.string(),\n  /** Company or product name — becomes the image alt text and the accessible name. */\n  name: z.string(),\n  /** Logo artwork URL (SVG preferred). */\n  src: z.string(),\n  /** Artwork for dark surfaces; when present it swaps in under `.dark` via a CSS-only pair. */\n  darkSrc: z.string().optional(),\n  /**\n   * Intrinsic artwork width in px (an SVG `viewBox` width is fine). Never rendered as-is —\n   * it only feeds the optical-size formula, which needs the aspect ratio.\n   */\n  width: z.number().positive(),\n  /** Intrinsic artwork height in px. */\n  height: z.number().positive(),\n  /**\n   * Real destination for this logo. Omit it and the logo renders as inert artwork:\n   * no link, no pointer cursor, no hover reveal.\n   */\n  href: z.string().optional(),\n  /**\n   * Per-logo optical nudge, clamped to 0.5–1.5. The formula gets ~90% of marks right;\n   * this is the escape hatch for the airy or ink-heavy ones the ratio can't see.\n   */\n  scale: z.number().positive().optional(),\n})\n\nexport const logoCloudSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  /** Lead-in line above the wall, e.g. \"Trusted by teams at\". Omit to render logos alone. */\n  eyebrow: z.string().optional(),\n  items: z.array(logoCloudItemSchema),\n})\n\nexport type LogoCloudItem = z.infer<typeof logoCloudItemSchema>\nexport type LogoCloudData = z.infer<typeof logoCloudSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:block"
}